Skip to content

Commit f33a070

Browse files
authored
feat: parse and extract tf variables (#168)
Required for detecting variables in coder/coder
1 parent 72ea9cc commit f33a070

File tree

7 files changed

+354
-17
lines changed

7 files changed

+354
-17
lines changed

extract/variable.go

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package extract
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/aquasecurity/trivy/pkg/iac/terraform"
7+
"github.com/hashicorp/hcl/v2"
8+
"github.com/hashicorp/hcl/v2/ext/typeexpr"
9+
"github.com/zclconf/go-cty/cty"
10+
"github.com/zclconf/go-cty/cty/convert"
11+
12+
"github.com/coder/preview/types"
13+
)
14+
15+
// VariableFromBlock extracts a terraform variable, but not its final resolved value.
16+
// code taken mostly from https://github.com/aquasecurity/trivy/blob/main/pkg/iac/scanners/terraform/parser/evaluator.go#L479
17+
func VariableFromBlock(block *terraform.Block) (tfVar types.Variable) {
18+
defer func() {
19+
// Extra safety mechanism to ensure that if a panic occurs, we do not break
20+
// everything else.
21+
if r := recover(); r != nil {
22+
tfVar = types.Variable{
23+
Name: block.Label(),
24+
Diagnostics: types.Diagnostics{
25+
{
26+
Severity: hcl.DiagError,
27+
Summary: "Panic occurred in extracting variable. This should not happen, please report this to Coder.",
28+
Detail: fmt.Sprintf("panic in variable extract: %+v", r),
29+
},
30+
},
31+
}
32+
}
33+
}()
34+
35+
attributes := block.Attributes()
36+
37+
var valType cty.Type
38+
var defaults *typeexpr.Defaults
39+
40+
if typeAttr, exists := attributes["type"]; exists {
41+
ty, def, err := typeAttr.DecodeVarType()
42+
if err != nil {
43+
var subject hcl.Range
44+
if typeAttr.HCLAttribute() != nil {
45+
subject = typeAttr.HCLAttribute().Range
46+
}
47+
return types.Variable{
48+
Name: block.Label(),
49+
Diagnostics: types.Diagnostics{&hcl.Diagnostic{
50+
Severity: hcl.DiagError,
51+
Summary: "Failed to decode variable type for " + block.Label(),
52+
Detail: err.Error(),
53+
Subject: &subject,
54+
}},
55+
}
56+
}
57+
valType = ty
58+
defaults = def
59+
}
60+
61+
var val cty.Value
62+
var defSubject hcl.Range
63+
if def, exists := attributes["default"]; exists {
64+
val = def.NullableValue()
65+
defSubject = def.HCLAttribute().Range
66+
}
67+
68+
if valType != cty.NilType {
69+
// TODO: If this code ever extracts the actual value of the variable,
70+
// then we need to source the value from that, rather than the default.
71+
if defaults != nil {
72+
val = defaults.Apply(val)
73+
}
74+
75+
canConvert := !val.IsNull() && val.IsWhollyKnown() && valType != cty.NilType
76+
77+
if canConvert {
78+
typedVal, err := convert.Convert(val, valType)
79+
if err != nil {
80+
return types.Variable{
81+
Name: block.Label(),
82+
Diagnostics: types.Diagnostics{&hcl.Diagnostic{
83+
Severity: hcl.DiagError,
84+
Summary: fmt.Sprintf("Failed to convert variable default value to type %q for %q",
85+
valType.FriendlyNameForConstraint(), block.Label()),
86+
Detail: err.Error(),
87+
Subject: &defSubject,
88+
}},
89+
}
90+
}
91+
val = typedVal
92+
}
93+
} else {
94+
valType = val.Type()
95+
}
96+
return types.Variable{
97+
Name: block.Label(),
98+
Default: val,
99+
Type: valType,
100+
Description: optionalString(block, "description"),
101+
Nullable: optionalBoolean(block, "nullable"),
102+
Sensitive: optionalBoolean(block, "sensitive"),
103+
}
104+
}

preview.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type Output struct {
4242
Parameters []types.Parameter `json:"parameters"`
4343
WorkspaceTags types.TagBlocks `json:"workspace_tags"`
4444
Presets []types.Preset `json:"presets"`
45+
Variables []types.Variable `json:"variables"`
4546
// Files is included for printing diagnostics.
4647
// They can be marshalled, but not unmarshalled. This is a limitation
4748
// of the HCL library.
@@ -181,6 +182,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
181182
rp, rpDiags := parameters(modules)
182183
presets := presets(modules, rp)
183184
tags, tagDiags := workspaceTags(modules, p.Files())
185+
vars := variables(modules)
184186

185187
// Add warnings
186188
diags = diags.Extend(warnings(modules))
@@ -191,6 +193,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn
191193
WorkspaceTags: tags,
192194
Presets: presets,
193195
Files: p.Files(),
196+
Variables: vars,
194197
}, diags.Extend(rpDiags).Extend(tagDiags)
195198
}
196199

preview_test.go

Lines changed: 156 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/zclconf/go-cty/cty"
1717

1818
"github.com/coder/preview"
19+
"github.com/coder/preview/hclext"
1920
"github.com/coder/preview/types"
2021
"github.com/coder/terraform-provider-coder/v2/provider"
2122
)
@@ -43,6 +44,7 @@ func Test_Extract(t *testing.T) {
4344
expTags map[string]string
4445
unknownTags []string
4546
params map[string]assertParam
47+
variables map[string]assertVariable
4648
presets func(t *testing.T, presets []types.Preset)
4749
warnings []*regexp.Regexp
4850
}{
@@ -77,6 +79,18 @@ func Test_Extract(t *testing.T) {
7779
formType(provider.ParameterFormTypeRadio),
7880
"numerical": ap().value("5"),
7981
},
82+
variables: map[string]assertVariable{
83+
"string": av().def(cty.StringVal("Hello, world!")).typeEq(cty.String).
84+
description("test").nullable(true).sensitive(true),
85+
"number": av().def(cty.NumberIntVal(7)).typeEq(cty.Number),
86+
"boolean": av().def(cty.BoolVal(true)).typeEq(cty.Bool),
87+
"coerce_string": av().def(cty.StringVal("5")).typeEq(cty.String),
88+
"complex": av().typeEq(cty.Object(map[string]cty.Type{
89+
"list": cty.List(cty.String),
90+
"name": cty.String,
91+
"age": cty.Number,
92+
})),
93+
},
8094
},
8195
{
8296
name: "conditional-no-inputs",
@@ -149,6 +163,11 @@ func Test_Extract(t *testing.T) {
149163
"indexed_0": ap(),
150164
"indexed_1": ap(),
151165
},
166+
variables: map[string]assertVariable{
167+
"regions": av().def(cty.SetVal([]cty.Value{
168+
cty.StringVal("us"), cty.StringVal("eu"), cty.StringVal("au"),
169+
})).typeEq(cty.Set(cty.String)),
170+
},
152171
},
153172
{
154173
name: "external docker resource without plan data",
@@ -222,6 +241,9 @@ func Test_Extract(t *testing.T) {
222241
def("m7gd.8xlarge").
223242
value("m7gd.8xlarge"),
224243
},
244+
variables: map[string]assertVariable{
245+
"regions": av().typeEq(cty.List(cty.String)),
246+
},
225247
},
226248
{
227249
name: "empty file",
@@ -434,6 +456,9 @@ func Test_Extract(t *testing.T) {
434456
"team": ap().optVals("frontend", "backend", "fullstack"),
435457
"jetbrains_ide": ap(),
436458
},
459+
variables: map[string]assertVariable{
460+
"security": av().def(cty.StringVal("high")).typeEq(cty.String),
461+
},
437462
},
438463
{
439464
name: "count",
@@ -528,6 +553,17 @@ func Test_Extract(t *testing.T) {
528553
optVals("GO", "IU", "PY").
529554
optNames("GoLand 2024.3", "IntelliJ IDEA Ultimate 2024.3", "PyCharm Professional 2024.3"),
530555
},
556+
variables: map[string]assertVariable{
557+
"jetbrains_ides": av().typeEq(cty.List(cty.String)).description("The list of IDE product codes."),
558+
"releases_base_link": av(),
559+
"channel": av(),
560+
"download_base_link": av(),
561+
"arch": av(),
562+
"jetbrains_ide_versions": av().typeEq(cty.Map(cty.Object(map[string]cty.Type{
563+
"build_number": cty.String,
564+
"version": cty.String,
565+
}))),
566+
},
531567
},
532568
{
533569
name: "tfvars_from_file",
@@ -541,6 +577,12 @@ func Test_Extract(t *testing.T) {
541577
"variable_values": ap().
542578
def("alex").optVals("alex", "bob", "claire", "jason"),
543579
},
580+
variables: map[string]assertVariable{
581+
"one": av(),
582+
"two": av(),
583+
"three": av(),
584+
"four": av(),
585+
},
544586
},
545587
{
546588
name: "tfvars_from_input",
@@ -559,6 +601,12 @@ func Test_Extract(t *testing.T) {
559601
"variable_values": ap().
560602
def("andrew").optVals("andrew", "bill", "carter", "jason"),
561603
},
604+
variables: map[string]assertVariable{
605+
"one": av(),
606+
"two": av(),
607+
"three": av(),
608+
"four": av(),
609+
},
562610
},
563611
{
564612
name: "unknownoption",
@@ -570,6 +618,9 @@ func Test_Extract(t *testing.T) {
570618
"unknown": apWithDiags().
571619
errorDiagnostics("The set of options cannot be resolved"),
572620
},
621+
variables: map[string]assertVariable{
622+
"unknown": av().def(cty.NilVal),
623+
},
573624
},
574625
} {
575626
t.Run(tc.name, func(t *testing.T) {
@@ -637,10 +688,80 @@ func Test_Extract(t *testing.T) {
637688
if tc.presets != nil {
638689
tc.presets(t, output.Presets)
639690
}
691+
692+
// Assert variables
693+
require.Len(t, output.Variables, len(tc.variables), "wrong number of variables expected")
694+
for _, variable := range output.Variables {
695+
check, ok := tc.variables[variable.Name]
696+
require.True(t, ok, "unknown variable %s", variable.Name)
697+
check(t, variable)
698+
}
640699
})
641700
}
642701
}
643702

703+
type assertVariable func(t *testing.T, variable types.Variable)
704+
705+
func av() assertVariable {
706+
return func(t *testing.T, v types.Variable) {
707+
t.Helper()
708+
assert.Empty(t, v.Diagnostics, "variable should have no diagnostics")
709+
}
710+
}
711+
712+
func avWithDiags() assertVariable {
713+
return func(t *testing.T, parameter types.Variable) {}
714+
}
715+
716+
func (a assertVariable) errorDiagnostics(patterns ...string) assertVariable {
717+
return a.diagnostics(hcl.DiagError, patterns...)
718+
}
719+
720+
func (a assertVariable) warnDiagnostics(patterns ...string) assertVariable {
721+
return a.diagnostics(hcl.DiagWarning, patterns...)
722+
}
723+
724+
func (a assertVariable) diagnostics(sev hcl.DiagnosticSeverity, patterns ...string) assertVariable {
725+
shadow := patterns
726+
return a.extend(func(t *testing.T, v types.Variable) {
727+
assertDiags(t, sev, v.Diagnostics, shadow...)
728+
})
729+
}
730+
731+
func (a assertVariable) nullable(n bool) assertVariable {
732+
return a.extend(func(t *testing.T, v types.Variable) {
733+
assert.Equal(t, v.Nullable, n, "variable nullable check")
734+
})
735+
}
736+
737+
func (a assertVariable) typeEq(ty cty.Type) assertVariable {
738+
return a.extend(func(t *testing.T, v types.Variable) {
739+
assert.Truef(t, ty.Equals(v.Type), "%q variable type equality check", v.Name)
740+
})
741+
}
742+
743+
func (a assertVariable) def(def cty.Value) assertVariable {
744+
return a.extend(func(t *testing.T, v types.Variable) {
745+
if !assert.Truef(t, def.Equals(v.Default).True(), "%q variable default equality check", v.Name) {
746+
exp, _ := hclext.AsString(def)
747+
got, _ := hclext.AsString(v.Default)
748+
t.Logf("Expected: %s, Value: %s", exp, got)
749+
}
750+
})
751+
}
752+
753+
func (a assertVariable) sensitive(s bool) assertVariable {
754+
return a.extend(func(t *testing.T, v types.Variable) {
755+
assert.Equal(t, v.Sensitive, s, "variable sensitive check")
756+
})
757+
}
758+
759+
func (a assertVariable) description(d string) assertVariable {
760+
return a.extend(func(t *testing.T, v types.Variable) {
761+
assert.Equal(t, v.Description, d, "variable description check")
762+
})
763+
}
764+
644765
type assertParam func(t *testing.T, parameter types.Parameter)
645766

646767
func ap() assertParam {
@@ -665,23 +786,7 @@ func (a assertParam) warnDiagnostics(patterns ...string) assertParam {
665786
func (a assertParam) diagnostics(sev hcl.DiagnosticSeverity, patterns ...string) assertParam {
666787
shadow := patterns
667788
return a.extend(func(t *testing.T, parameter types.Parameter) {
668-
checks := make([]string, len(shadow))
669-
copy(checks, shadow)
670-
671-
DiagLoop:
672-
for _, diag := range parameter.Diagnostics {
673-
if diag.Severity != sev {
674-
continue
675-
}
676-
for i, pat := range checks {
677-
if strings.Contains(diag.Summary, pat) || strings.Contains(diag.Detail, pat) {
678-
checks = append(checks[:i], checks[i+1:]...)
679-
break DiagLoop
680-
}
681-
}
682-
}
683-
684-
assert.Equal(t, []string{}, checks, "missing expected diagnostic errors")
789+
assertDiags(t, sev, parameter.Diagnostics, shadow...)
685790
})
686791
}
687792

@@ -771,3 +876,37 @@ func (a assertParam) extend(f assertParam) assertParam {
771876
f(t, parameter)
772877
}
773878
}
879+
880+
//nolint:revive
881+
func (a assertVariable) extend(f assertVariable) assertVariable {
882+
if a == nil {
883+
a = func(t *testing.T, v types.Variable) {}
884+
}
885+
886+
return func(t *testing.T, v types.Variable) {
887+
t.Helper()
888+
(a)(t, v)
889+
f(t, v)
890+
}
891+
}
892+
893+
func assertDiags(t *testing.T, sev hcl.DiagnosticSeverity, diags types.Diagnostics, patterns ...string) {
894+
t.Helper()
895+
checks := make([]string, len(patterns))
896+
copy(checks, patterns)
897+
898+
DiagLoop:
899+
for _, diag := range diags {
900+
if diag.Severity != sev {
901+
continue
902+
}
903+
for i, pat := range checks {
904+
if strings.Contains(diag.Summary, pat) || strings.Contains(diag.Detail, pat) {
905+
checks = append(checks[:i], checks[i+1:]...)
906+
break DiagLoop
907+
}
908+
}
909+
}
910+
911+
assert.Equal(t, []string{}, checks, "missing expected diagnostic errors")
912+
}

0 commit comments

Comments
 (0)