From 0edca31bfd7d7589e428c7321684bd3ee099f22a Mon Sep 17 00:00:00 2001 From: Dylan Huff Date: Fri, 17 Apr 2026 21:44:20 +0000 Subject: [PATCH] feat: extract coder_secret requirements into Output --- extract/secret.go | 54 +++++++++++++ preview.go | 25 +++--- preview_test.go | 114 ++++++++++++++++++++++++++++ secret.go | 30 ++++++++ testdata/secretsbasic/main.tf | 9 +++ testdata/secretsbasic/skipe2e | 1 + testdata/secretsconditional/main.tf | 12 +++ testdata/secretsconditional/skipe2e | 1 + types/secret.go | 29 +++++++ 9 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 extract/secret.go create mode 100644 secret.go create mode 100644 testdata/secretsbasic/main.tf create mode 100644 testdata/secretsbasic/skipe2e create mode 100644 testdata/secretsconditional/main.tf create mode 100644 testdata/secretsconditional/skipe2e create mode 100644 types/secret.go diff --git a/extract/secret.go b/extract/secret.go new file mode 100644 index 0000000..8da81f4 --- /dev/null +++ b/extract/secret.go @@ -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) { + // 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 +} diff --git a/preview.go b/preview.go index 32ac43f..3490dc4 100644 --- a/preview.go +++ b/preview.go @@ -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. @@ -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) { diff --git a/preview_test.go b/preview_test.go index 49a7fb6..33fe697 100644 --- a/preview_test.go +++ b/preview_test.go @@ -9,6 +9,7 @@ import ( "slices" "strings" "testing" + "testing/fstest" "github.com/hashicorp/hcl/v2" "github.com/stretchr/testify/assert" @@ -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", @@ -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", @@ -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") }) } } @@ -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) + }) + } +} diff --git a/secret.go b/secret.go new file mode 100644 index 0000000..7f50d85 --- /dev/null +++ b/secret.go @@ -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 +} diff --git a/testdata/secretsbasic/main.tf b/testdata/secretsbasic/main.tf new file mode 100644 index 0000000..646732f --- /dev/null +++ b/testdata/secretsbasic/main.tf @@ -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" +} diff --git a/testdata/secretsbasic/skipe2e b/testdata/secretsbasic/skipe2e new file mode 100644 index 0000000..6da9986 --- /dev/null +++ b/testdata/secretsbasic/skipe2e @@ -0,0 +1 @@ +coder_secret is not yet in the released coder provider \ No newline at end of file diff --git a/testdata/secretsconditional/main.tf b/testdata/secretsconditional/main.tf new file mode 100644 index 0000000..29dc34c --- /dev/null +++ b/testdata/secretsconditional/main.tf @@ -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" +} diff --git a/testdata/secretsconditional/skipe2e b/testdata/secretsconditional/skipe2e new file mode 100644 index 0000000..6da9986 --- /dev/null +++ b/testdata/secretsconditional/skipe2e @@ -0,0 +1 @@ +coder_secret is not yet in the released coder provider \ No newline at end of file diff --git a/types/secret.go b/types/secret.go new file mode 100644 index 0000000..15ffa6a --- /dev/null +++ b/types/secret.go @@ -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) + }) +}