Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions extract/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package extract

import (
"github.com/aquasecurity/trivy/pkg/iac/terraform"
"github.com/hashicorp/hcl/v2"

"github.com/coder/preview/types"
)

// SecretFromBlock decodes a `data "coder_secret" {}` Terraform block into a
// SecretRequirement. Exactly one of `env` or `file` must be set, and
// `help_message` is required. Returns (nil, diags) on validation failure.
func SecretFromBlock(block *terraform.Block) (*types.SecretRequirement, hcl.Diagnostics) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reccomend doing a panic recover guard:
https://github.com/coder/preview/blob/main/extract/preset.go#L13-L30

Things can panic in the type system pretty easily if misused.

// help_message is required AND must be a string; requiredString
// handles both checks and emits a proper type diagnostic.
var diags hcl.Diagnostics
help, helpDiag := requiredString(block, "help_message")
if helpDiag != nil {
diags = diags.Append(helpDiag)
}

env := optionalString(block, "env")
file := optionalString(block, "file")

// Mutual exclusivity: exactly one of env/file must be set.
switch {
case env == "" && file == "":
r := block.HCLBlock().Body.MissingItemRange()
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid "coder_secret" block`,
Detail: `Exactly one of "env" or "file" must be set, neither were set`,
Subject: &r,
})
case env != "" && file != "":
r := block.HCLBlock().Body.MissingItemRange()
diags = diags.Append(&hcl.Diagnostic{
Severity: hcl.DiagError,
Summary: `Invalid "coder_secret" block`,
Detail: `Exactly one of "env" or "file" must be set, both were set`,
Subject: &r,
})
}

if diags.HasErrors() {
return nil, diags
}

return &types.SecretRequirement{
Env: env,
File: file,
HelpMessage: help,
}, diags
}
25 changes: 14 additions & 11 deletions preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,11 @@ type Output struct {
// JSON marshalling is handled in the custom methods.
ModuleOutput cty.Value `json:"-"`

Parameters []types.Parameter `json:"parameters"`
WorkspaceTags types.TagBlocks `json:"workspace_tags"`
Presets []types.Preset `json:"presets"`
Variables []types.Variable `json:"variables"`
Parameters []types.Parameter `json:"parameters"`
WorkspaceTags types.TagBlocks `json:"workspace_tags"`
Presets []types.Preset `json:"presets"`
Variables []types.Variable `json:"variables"`
SecretRequirements []types.SecretRequirement `json:"secret_requirements"`
// Files is included for printing diagnostics.
// They can be marshalled, but not unmarshalled. This is a limitation
// of the HCL library.
Expand Down Expand Up @@ -279,18 +280,20 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
preValidPresets := presets(modules, rp)
tags, tagDiags := workspaceTags(modules, p.Files())
vars := variables(modules)
secretReqs, secretDiags := secrets(modules)

// Add warnings
diags = diags.Extend(warnings(modules))

return &Output{
ModuleOutput: outputs,
Parameters: rp,
WorkspaceTags: tags,
Presets: preValidPresets,
Files: p.Files(),
Variables: vars,
}, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags)
ModuleOutput: outputs,
Parameters: rp,
WorkspaceTags: tags,
Presets: preValidPresets,
Files: p.Files(),
Variables: vars,
SecretRequirements: secretReqs,
}, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags).Extend(secretDiags)
}

func (i Input) RichParameterValue(key string) (string, bool) {
Expand Down
114 changes: 114 additions & 0 deletions preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"slices"
"strings"
"testing"
"testing/fstest"

"github.com/hashicorp/hcl/v2"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -48,6 +49,7 @@ func Test_Extract(t *testing.T) {
presetsFuncs func(t *testing.T, presets []types.Preset)
presets map[string]assertPreset
warnings []*regexp.Regexp
secretRequirements []types.SecretRequirement
}{
{
name: "bad param values",
Expand Down Expand Up @@ -657,6 +659,38 @@ func Test_Extract(t *testing.T) {
prebuildCount(1),
},
},
{
name: "secrets basic",
dir: "secretsbasic",
secretRequirements: []types.SecretRequirement{
{Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT"},
{File: "~/.aws/credentials", HelpMessage: "Add AWS creds"},
},
},
{
name: "secrets conditional off",
dir: "secretsconditional",
input: preview.Input{
ParameterValues: map[string]string{"use_github": "false"},
},
params: map[string]assertParam{
"use_github": ap().value("false"),
},
secretRequirements: nil,
},
{
name: "secrets conditional on",
dir: "secretsconditional",
input: preview.Input{
ParameterValues: map[string]string{"use_github": "true"},
},
params: map[string]assertParam{
"use_github": ap().value("true"),
},
secretRequirements: []types.SecretRequirement{
{Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT"},
},
},
{
name: "override",
dir: "override",
Expand Down Expand Up @@ -756,6 +790,10 @@ func Test_Extract(t *testing.T) {
require.True(t, ok, "unknown variable %s", variable.Name)
check(t, variable)
}

// Assert secret requirements
require.ElementsMatch(t, tc.secretRequirements, output.SecretRequirements,
"secret requirements do not match expected")
})
}
}
Expand Down Expand Up @@ -1105,3 +1143,79 @@ DiagLoop:

assert.Equal(t, []string{}, checks, "missing expected diagnostic errors")
}

func Test_SecretRequirementErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
tf string
wantDiag string // substring match on summary+" "+detail
}{
{
name: "missing help_message",
tf: `
data "coder_secret" "x" {
env = "X"
}
`,
wantDiag: `help_message`,
},
{
name: "help_message null",
tf: `
data "coder_secret" "x" {
env = "X"
help_message = null
}
`,
wantDiag: `help_message`,
},
{
name: "help_message wrong type (number)",
tf: `
data "coder_secret" "x" {
env = "X"
help_message = 42
}
`,
wantDiag: `Expected a string`,
},
{
name: "neither env nor file",
tf: `
data "coder_secret" "x" {
help_message = "need one"
}
`,
wantDiag: `Exactly one of "env" or "file" must be set`,
},
{
name: "both env and file",
tf: `
data "coder_secret" "x" {
env = "X"
file = "~/y"
help_message = "ok"
}
`,
wantDiag: `Exactly one of "env" or "file" must be set`,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
fsys := fstest.MapFS{"main.tf": &fstest.MapFile{Data: []byte(tc.tf)}}
_, diags := preview.Preview(context.Background(), preview.Input{}, fsys)
require.True(t, diags.HasErrors(), "expected errors; got %v", diags)
var found bool
for _, d := range diags {
if strings.Contains(d.Summary+" "+d.Detail, tc.wantDiag) {
found = true
break
}
}
require.True(t, found,
"no diag matching %q; got: %v", tc.wantDiag, diags)
})
}
}
30 changes: 30 additions & 0 deletions secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
package preview

import (
"github.com/aquasecurity/trivy/pkg/iac/terraform"
"github.com/hashicorp/hcl/v2"

"github.com/coder/preview/extract"
"github.com/coder/preview/types"
)

func secrets(modules terraform.Modules) ([]types.SecretRequirement, hcl.Diagnostics) {
diags := make(hcl.Diagnostics, 0)
reqs := make([]types.SecretRequirement, 0)

for _, mod := range modules {
blocks := mod.GetDatasByType(types.BlockTypeSecret)
for _, block := range blocks {
req, rDiags := extract.SecretFromBlock(block)
if len(rDiags) > 0 {
diags = diags.Extend(rDiags)
}
if req != nil {
reqs = append(reqs, *req)
}
}
}

types.SortSecretRequirements(reqs)
return reqs, diags
}
9 changes: 9 additions & 0 deletions testdata/secretsbasic/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
data "coder_secret" "gh" {
env = "GITHUB_TOKEN"
help_message = "Add a GitHub PAT"
}

data "coder_secret" "aws" {
file = "~/.aws/credentials"
help_message = "Add AWS creds"
}
1 change: 1 addition & 0 deletions testdata/secretsbasic/skipe2e
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
coder_secret is not yet in the released coder provider
12 changes: 12 additions & 0 deletions testdata/secretsconditional/main.tf
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
data "coder_parameter" "use_github" {
name = "use_github"
type = "bool"
default = "false"
mutable = true
}

data "coder_secret" "gh" {
count = data.coder_parameter.use_github.value == "true" ? 1 : 0
env = "GITHUB_TOKEN"
help_message = "Add a GitHub PAT"
}
1 change: 1 addition & 0 deletions testdata/secretsconditional/skipe2e
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
coder_secret is not yet in the released coder provider
29 changes: 29 additions & 0 deletions types/secret.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package types

import (
"slices"
"strings"
)

// @typescript-ignore BlockTypeSecret
const BlockTypeSecret = "coder_secret"

// SecretRequirement describes a `data "coder_secret"` block declared in a
// template. Exactly one of Env or File will be non-empty; validation of that
// invariant happens during extraction.
type SecretRequirement struct {
Env string `json:"env"`
File string `json:"file"`
HelpMessage string `json:"help_message"`
}

// SortSecretRequirements orders requirements first by Env then by File so
// diagnostic output is stable across runs.
func SortSecretRequirements(reqs []SecretRequirement) {
slices.SortFunc(reqs, func(a, b SecretRequirement) int {
if c := strings.Compare(a.Env, b.Env); c != 0 {
return c
}
return strings.Compare(a.File, b.File)
})
}
Loading