Skip to content

Commit 0edca31

Browse files
feat: extract coder_secret requirements into Output
1 parent 2b53f0c commit 0edca31

9 files changed

Lines changed: 264 additions & 11 deletions

File tree

extract/secret.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package extract
2+
3+
import (
4+
"github.com/aquasecurity/trivy/pkg/iac/terraform"
5+
"github.com/hashicorp/hcl/v2"
6+
7+
"github.com/coder/preview/types"
8+
)
9+
10+
// SecretFromBlock decodes a `data "coder_secret" {}` Terraform block into a
11+
// SecretRequirement. Exactly one of `env` or `file` must be set, and
12+
// `help_message` is required. Returns (nil, diags) on validation failure.
13+
func SecretFromBlock(block *terraform.Block) (*types.SecretRequirement, hcl.Diagnostics) {
14+
// help_message is required AND must be a string; requiredString
15+
// handles both checks and emits a proper type diagnostic.
16+
var diags hcl.Diagnostics
17+
help, helpDiag := requiredString(block, "help_message")
18+
if helpDiag != nil {
19+
diags = diags.Append(helpDiag)
20+
}
21+
22+
env := optionalString(block, "env")
23+
file := optionalString(block, "file")
24+
25+
// Mutual exclusivity: exactly one of env/file must be set.
26+
switch {
27+
case env == "" && file == "":
28+
r := block.HCLBlock().Body.MissingItemRange()
29+
diags = diags.Append(&hcl.Diagnostic{
30+
Severity: hcl.DiagError,
31+
Summary: `Invalid "coder_secret" block`,
32+
Detail: `Exactly one of "env" or "file" must be set, neither were set`,
33+
Subject: &r,
34+
})
35+
case env != "" && file != "":
36+
r := block.HCLBlock().Body.MissingItemRange()
37+
diags = diags.Append(&hcl.Diagnostic{
38+
Severity: hcl.DiagError,
39+
Summary: `Invalid "coder_secret" block`,
40+
Detail: `Exactly one of "env" or "file" must be set, both were set`,
41+
Subject: &r,
42+
})
43+
}
44+
45+
if diags.HasErrors() {
46+
return nil, diags
47+
}
48+
49+
return &types.SecretRequirement{
50+
Env: env,
51+
File: file,
52+
HelpMessage: help,
53+
}, diags
54+
}

preview.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,11 @@ type Output struct {
4040
// JSON marshalling is handled in the custom methods.
4141
ModuleOutput cty.Value `json:"-"`
4242

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

283285
// Add warnings
284286
diags = diags.Extend(warnings(modules))
285287

286288
return &Output{
287-
ModuleOutput: outputs,
288-
Parameters: rp,
289-
WorkspaceTags: tags,
290-
Presets: preValidPresets,
291-
Files: p.Files(),
292-
Variables: vars,
293-
}, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags)
289+
ModuleOutput: outputs,
290+
Parameters: rp,
291+
WorkspaceTags: tags,
292+
Presets: preValidPresets,
293+
Files: p.Files(),
294+
Variables: vars,
295+
SecretRequirements: secretReqs,
296+
}, diags.Extend(overrideDiags).Extend(rpDiags).Extend(tagDiags).Extend(secretDiags)
294297
}
295298

296299
func (i Input) RichParameterValue(key string) (string, bool) {

preview_test.go

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"slices"
1010
"strings"
1111
"testing"
12+
"testing/fstest"
1213

1314
"github.com/hashicorp/hcl/v2"
1415
"github.com/stretchr/testify/assert"
@@ -48,6 +49,7 @@ func Test_Extract(t *testing.T) {
4849
presetsFuncs func(t *testing.T, presets []types.Preset)
4950
presets map[string]assertPreset
5051
warnings []*regexp.Regexp
52+
secretRequirements []types.SecretRequirement
5153
}{
5254
{
5355
name: "bad param values",
@@ -657,6 +659,38 @@ func Test_Extract(t *testing.T) {
657659
prebuildCount(1),
658660
},
659661
},
662+
{
663+
name: "secrets basic",
664+
dir: "secretsbasic",
665+
secretRequirements: []types.SecretRequirement{
666+
{Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT"},
667+
{File: "~/.aws/credentials", HelpMessage: "Add AWS creds"},
668+
},
669+
},
670+
{
671+
name: "secrets conditional off",
672+
dir: "secretsconditional",
673+
input: preview.Input{
674+
ParameterValues: map[string]string{"use_github": "false"},
675+
},
676+
params: map[string]assertParam{
677+
"use_github": ap().value("false"),
678+
},
679+
secretRequirements: nil,
680+
},
681+
{
682+
name: "secrets conditional on",
683+
dir: "secretsconditional",
684+
input: preview.Input{
685+
ParameterValues: map[string]string{"use_github": "true"},
686+
},
687+
params: map[string]assertParam{
688+
"use_github": ap().value("true"),
689+
},
690+
secretRequirements: []types.SecretRequirement{
691+
{Env: "GITHUB_TOKEN", HelpMessage: "Add a GitHub PAT"},
692+
},
693+
},
660694
{
661695
name: "override",
662696
dir: "override",
@@ -756,6 +790,10 @@ func Test_Extract(t *testing.T) {
756790
require.True(t, ok, "unknown variable %s", variable.Name)
757791
check(t, variable)
758792
}
793+
794+
// Assert secret requirements
795+
require.ElementsMatch(t, tc.secretRequirements, output.SecretRequirements,
796+
"secret requirements do not match expected")
759797
})
760798
}
761799
}
@@ -1105,3 +1143,79 @@ DiagLoop:
11051143

11061144
assert.Equal(t, []string{}, checks, "missing expected diagnostic errors")
11071145
}
1146+
1147+
func Test_SecretRequirementErrors(t *testing.T) {
1148+
t.Parallel()
1149+
tests := []struct {
1150+
name string
1151+
tf string
1152+
wantDiag string // substring match on summary+" "+detail
1153+
}{
1154+
{
1155+
name: "missing help_message",
1156+
tf: `
1157+
data "coder_secret" "x" {
1158+
env = "X"
1159+
}
1160+
`,
1161+
wantDiag: `help_message`,
1162+
},
1163+
{
1164+
name: "help_message null",
1165+
tf: `
1166+
data "coder_secret" "x" {
1167+
env = "X"
1168+
help_message = null
1169+
}
1170+
`,
1171+
wantDiag: `help_message`,
1172+
},
1173+
{
1174+
name: "help_message wrong type (number)",
1175+
tf: `
1176+
data "coder_secret" "x" {
1177+
env = "X"
1178+
help_message = 42
1179+
}
1180+
`,
1181+
wantDiag: `Expected a string`,
1182+
},
1183+
{
1184+
name: "neither env nor file",
1185+
tf: `
1186+
data "coder_secret" "x" {
1187+
help_message = "need one"
1188+
}
1189+
`,
1190+
wantDiag: `Exactly one of "env" or "file" must be set`,
1191+
},
1192+
{
1193+
name: "both env and file",
1194+
tf: `
1195+
data "coder_secret" "x" {
1196+
env = "X"
1197+
file = "~/y"
1198+
help_message = "ok"
1199+
}
1200+
`,
1201+
wantDiag: `Exactly one of "env" or "file" must be set`,
1202+
},
1203+
}
1204+
for _, tc := range tests {
1205+
t.Run(tc.name, func(t *testing.T) {
1206+
t.Parallel()
1207+
fsys := fstest.MapFS{"main.tf": &fstest.MapFile{Data: []byte(tc.tf)}}
1208+
_, diags := preview.Preview(context.Background(), preview.Input{}, fsys)
1209+
require.True(t, diags.HasErrors(), "expected errors; got %v", diags)
1210+
var found bool
1211+
for _, d := range diags {
1212+
if strings.Contains(d.Summary+" "+d.Detail, tc.wantDiag) {
1213+
found = true
1214+
break
1215+
}
1216+
}
1217+
require.True(t, found,
1218+
"no diag matching %q; got: %v", tc.wantDiag, diags)
1219+
})
1220+
}
1221+
}

secret.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package preview
2+
3+
import (
4+
"github.com/aquasecurity/trivy/pkg/iac/terraform"
5+
"github.com/hashicorp/hcl/v2"
6+
7+
"github.com/coder/preview/extract"
8+
"github.com/coder/preview/types"
9+
)
10+
11+
func secrets(modules terraform.Modules) ([]types.SecretRequirement, hcl.Diagnostics) {
12+
diags := make(hcl.Diagnostics, 0)
13+
reqs := make([]types.SecretRequirement, 0)
14+
15+
for _, mod := range modules {
16+
blocks := mod.GetDatasByType(types.BlockTypeSecret)
17+
for _, block := range blocks {
18+
req, rDiags := extract.SecretFromBlock(block)
19+
if len(rDiags) > 0 {
20+
diags = diags.Extend(rDiags)
21+
}
22+
if req != nil {
23+
reqs = append(reqs, *req)
24+
}
25+
}
26+
}
27+
28+
types.SortSecretRequirements(reqs)
29+
return reqs, diags
30+
}

testdata/secretsbasic/main.tf

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
data "coder_secret" "gh" {
2+
env = "GITHUB_TOKEN"
3+
help_message = "Add a GitHub PAT"
4+
}
5+
6+
data "coder_secret" "aws" {
7+
file = "~/.aws/credentials"
8+
help_message = "Add AWS creds"
9+
}

testdata/secretsbasic/skipe2e

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
coder_secret is not yet in the released coder provider
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
data "coder_parameter" "use_github" {
2+
name = "use_github"
3+
type = "bool"
4+
default = "false"
5+
mutable = true
6+
}
7+
8+
data "coder_secret" "gh" {
9+
count = data.coder_parameter.use_github.value == "true" ? 1 : 0
10+
env = "GITHUB_TOKEN"
11+
help_message = "Add a GitHub PAT"
12+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
coder_secret is not yet in the released coder provider

types/secret.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package types
2+
3+
import (
4+
"slices"
5+
"strings"
6+
)
7+
8+
// @typescript-ignore BlockTypeSecret
9+
const BlockTypeSecret = "coder_secret"
10+
11+
// SecretRequirement describes a `data "coder_secret"` block declared in a
12+
// template. Exactly one of Env or File will be non-empty; validation of that
13+
// invariant happens during extraction.
14+
type SecretRequirement struct {
15+
Env string `json:"env"`
16+
File string `json:"file"`
17+
HelpMessage string `json:"help_message"`
18+
}
19+
20+
// SortSecretRequirements orders requirements first by Env then by File so
21+
// diagnostic output is stable across runs.
22+
func SortSecretRequirements(reqs []SecretRequirement) {
23+
slices.SortFunc(reqs, func(a, b SecretRequirement) int {
24+
if c := strings.Compare(a.Env, b.Env); c != 0 {
25+
return c
26+
}
27+
return strings.Compare(a.File, b.File)
28+
})
29+
}

0 commit comments

Comments
 (0)