From 1af8373853055347e140fd44fa7545c48d5eecbe Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Jul 2025 11:38:50 -0500 Subject: [PATCH 1/6] feat: parse and extract tf variables --- extract/variable.go | 83 +++++++++++++++++++++++++++++++++++++++++++++ preview.go | 3 ++ types/terraform.go | 20 +++++++++++ variables.go | 25 ++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 extract/variable.go create mode 100644 types/terraform.go create mode 100644 variables.go diff --git a/extract/variable.go b/extract/variable.go new file mode 100644 index 0000000..0ec56df --- /dev/null +++ b/extract/variable.go @@ -0,0 +1,83 @@ +package extract + +import ( + "fmt" + + "github.com/aquasecurity/trivy/pkg/iac/terraform" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/ext/typeexpr" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + + "github.com/coder/preview/types" +) + +// VariableFromBlock extracts a terraform variable, but not it's final resolved value. +// code taken mostly from https://github.com/aquasecurity/trivy/blob/main/pkg/iac/scanners/terraform/parser/evaluator.go#L479 +func VariableFromBlock(block *terraform.Block) types.Variable { + attributes := block.Attributes() + + var valType cty.Type + var defaults *typeexpr.Defaults + + if typeAttr, exists := attributes["type"]; exists { + ty, def, err := typeAttr.DecodeVarType() + if err != nil { + var subject hcl.Range + if typeAttr.HCLAttribute() != nil { + subject = typeAttr.HCLAttribute().Range + } + return types.Variable{ + Name: block.Label(), + Diagnostics: hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Failed to decode variable type for " + block.Label(), + Detail: err.Error(), + Subject: &subject, + }}, + } + } + valType = ty + defaults = def + } + + var val cty.Value + if def, exists := attributes["default"]; exists { + val = def.NullableValue() + } + + if valType != cty.NilType { + if defaults != nil { + val = defaults.Apply(val) + } + + typedVal, err := convert.Convert(val, valType) + if err != nil { + var subject hcl.Range + if attributes["default"].HCLAttribute() != nil { + subject = attributes["default"].HCLAttribute().Range + } + return types.Variable{ + Name: block.Label(), + Diagnostics: hcl.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to convert variable default value to type %q for %q", + valType.FriendlyNameForConstraint(), block.Label()), + Detail: err.Error(), + Subject: &subject, + }}, + } + } + val = typedVal + } else { + valType = val.Type() + } + return types.Variable{ + Default: val, + Type: valType, + Description: optionalString(block, "description"), + Nullable: optionalBoolean(block, "nullable"), + Sensitive: optionalBoolean(block, "sensitive"), + Ephemeral: optionalBoolean(block, "ephemeral"), + } +} diff --git a/preview.go b/preview.go index 06db808..ea4154a 100644 --- a/preview.go +++ b/preview.go @@ -42,6 +42,7 @@ type Output struct { Parameters []types.Parameter `json:"parameters"` WorkspaceTags types.TagBlocks `json:"workspace_tags"` Presets []types.Preset `json:"presets"` + Variables []types.Variable `json:"variables"` // Files is included for printing diagnostics. // They can be marshalled, but not unmarshalled. This is a limitation // of the HCL library. @@ -181,6 +182,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn rp, rpDiags := parameters(modules) presets := presets(modules, rp) tags, tagDiags := workspaceTags(modules, p.Files()) + vars := variables(modules) // Add warnings diags = diags.Extend(warnings(modules)) @@ -191,6 +193,7 @@ func Preview(ctx context.Context, input Input, dir fs.FS) (output *Output, diagn WorkspaceTags: tags, Presets: presets, Files: p.Files(), + Variables: vars, }, diags.Extend(rpDiags).Extend(tagDiags) } diff --git a/types/terraform.go b/types/terraform.go new file mode 100644 index 0000000..4b63697 --- /dev/null +++ b/types/terraform.go @@ -0,0 +1,20 @@ +package types + +import ( + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" +) + +type Variable struct { + Name string + Default cty.Value + Type cty.Type + Description string + Nullable bool + Sensitive bool + Ephemeral bool + + // Variables also have 'Validation', which is currently not implemented. + + Diagnostics hcl.Diagnostics +} diff --git a/variables.go b/variables.go new file mode 100644 index 0000000..dcbc1f8 --- /dev/null +++ b/variables.go @@ -0,0 +1,25 @@ +package preview + +import ( + "slices" + "strings" + + "github.com/aquasecurity/trivy/pkg/iac/terraform" + + "github.com/coder/preview/extract" + "github.com/coder/preview/types" +) + +func variables(modules terraform.Modules) []types.Variable { + variableBlocks := modules.GetDatasByType("variable") + vars := make([]types.Variable, 0, len(variableBlocks)) + for _, block := range variableBlocks { + vars = append(vars, extract.VariableFromBlock(block)) + } + + // Sort the variables by name for consistency + slices.SortFunc(vars, func(a, b types.Variable) int { + return strings.Compare(a.Name, b.Name) + }) + return vars +} From 8154589ba3b35576b2f6f36fc4dc850e69ebbab1 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Jul 2025 12:21:32 -0500 Subject: [PATCH 2/6] fixup some complex variables --- extract/variable.go | 20 ++++++---- preview_test.go | 84 +++++++++++++++++++++++++++++++++++++++++ testdata/static/main.tf | 26 +++++++++++++ variables.go | 2 +- 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/extract/variable.go b/extract/variable.go index 0ec56df..8322d00 100644 --- a/extract/variable.go +++ b/extract/variable.go @@ -42,21 +42,22 @@ func VariableFromBlock(block *terraform.Block) types.Variable { } var val cty.Value + var defSubject hcl.Range if def, exists := attributes["default"]; exists { val = def.NullableValue() + defSubject = def.HCLAttribute().Range } if valType != cty.NilType { + // TODO: If this code ever extracts the actual value of the variable, + // then we need to source the value from that, rather than the default. if defaults != nil { val = defaults.Apply(val) } + valOK := !val.IsNull() && val.IsWhollyKnown() typedVal, err := convert.Convert(val, valType) - if err != nil { - var subject hcl.Range - if attributes["default"].HCLAttribute() != nil { - subject = attributes["default"].HCLAttribute().Range - } + if err != nil && valOK { return types.Variable{ Name: block.Label(), Diagnostics: hcl.Diagnostics{&hcl.Diagnostic{ @@ -64,15 +65,20 @@ func VariableFromBlock(block *terraform.Block) types.Variable { Summary: fmt.Sprintf("Failed to convert variable default value to type %q for %q", valType.FriendlyNameForConstraint(), block.Label()), Detail: err.Error(), - Subject: &subject, + Subject: &defSubject, }}, } } - val = typedVal + + // If the new converted value is ok, use it. + if err == nil { + val = typedVal + } } else { valType = val.Type() } return types.Variable{ + Name: block.Label(), Default: val, Type: valType, Description: optionalString(block, "description"), diff --git a/preview_test.go b/preview_test.go index 435fb93..77827ed 100644 --- a/preview_test.go +++ b/preview_test.go @@ -16,6 +16,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/coder/preview" + "github.com/coder/preview/hclext" "github.com/coder/preview/types" "github.com/coder/terraform-provider-coder/v2/provider" ) @@ -43,6 +44,7 @@ func Test_Extract(t *testing.T) { expTags map[string]string unknownTags []string params map[string]assertParam + variables map[string]assertVariable presets func(t *testing.T, presets []types.Preset) warnings []*regexp.Regexp }{ @@ -77,6 +79,17 @@ func Test_Extract(t *testing.T) { formType(provider.ParameterFormTypeRadio), "numerical": ap().value("5"), }, + variables: map[string]assertVariable{ + "string": av().def(cty.StringVal("Hello, world!")).typeEq(cty.String), + "number": av().def(cty.NumberIntVal(7)).typeEq(cty.Number), + "boolean": av().def(cty.BoolVal(true)).typeEq(cty.Bool), + "coerce_string": av().def(cty.StringVal("5")).typeEq(cty.String), + "complex": av().typeEq(cty.Object(map[string]cty.Type{ + "list": cty.List(cty.String), + "name": cty.String, + "age": cty.Number, + })), + }, }, { name: "conditional-no-inputs", @@ -637,10 +650,68 @@ func Test_Extract(t *testing.T) { if tc.presets != nil { tc.presets(t, output.Presets) } + + // Assert variables + require.Len(t, output.Variables, len(tc.variables), "wrong number of variables expected") + for _, variable := range output.Variables { + check, ok := tc.variables[variable.Name] + require.True(t, ok, "unknown variable %s", variable.Name) + check(t, variable) + } }) } } +type assertVariable func(t *testing.T, variable types.Variable) + +func av() assertVariable { + return func(t *testing.T, v types.Variable) { + t.Helper() + assert.Empty(t, v.Diagnostics, "variable should have no diagnostics") + } +} + +func (a assertVariable) nullable(n bool) assertVariable { + return a.extend(func(t *testing.T, v types.Variable) { + assert.Equal(t, v.Nullable, n, "variable nullable check") + }) +} + +func (a assertVariable) typeEq(ty cty.Type) assertVariable { + return a.extend(func(t *testing.T, v types.Variable) { + assert.Truef(t, ty.Equals(v.Type), "%q variable type equality check", v.Name) + }) +} + +func (a assertVariable) def(def cty.Value) assertVariable { + return a.extend(func(t *testing.T, v types.Variable) { + if !assert.Truef(t, def.Equals(v.Default).True(), "%q variable default equality check", v.Name) { + exp, _ := hclext.AsString(def) + got, _ := hclext.AsString(v.Default) + t.Logf("Expected: %s, Value: %s", exp, got) + } + + }) +} + +func (a assertVariable) sensitive(s bool) assertVariable { + return a.extend(func(t *testing.T, v types.Variable) { + assert.Equal(t, v.Sensitive, s, "variable sensitive check") + }) +} + +func (a assertVariable) ephemeral(e bool) assertVariable { + return a.extend(func(t *testing.T, v types.Variable) { + assert.Equal(t, v.Ephemeral, e, "variable ephemeral check") + }) +} + +func (a assertVariable) description(d string) assertVariable { + return a.extend(func(t *testing.T, v types.Variable) { + assert.Equal(t, v.Description, d, "variable description check") + }) +} + type assertParam func(t *testing.T, parameter types.Parameter) func ap() assertParam { @@ -771,3 +842,16 @@ func (a assertParam) extend(f assertParam) assertParam { f(t, parameter) } } + +//nolint:revive +func (a assertVariable) extend(f assertVariable) assertVariable { + if a == nil { + a = func(t *testing.T, v types.Variable) {} + } + + return func(t *testing.T, v types.Variable) { + t.Helper() + (a)(t, v) + f(t, v) + } +} diff --git a/testdata/static/main.tf b/testdata/static/main.tf index 0260e72..894d705 100644 --- a/testdata/static/main.tf +++ b/testdata/static/main.tf @@ -12,6 +12,32 @@ terraform { } } +variable "string" { + default = "Hello, world!" +} + +variable "number" { + default = 7 +} + +variable "boolean" { + default = true +} + +variable "coerce_string" { + default = 5 // This will be coerced to a string + type = string +} + +variable "complex" { + type = object({ + list = list(string) + name = string + age = number + }) +} + + data "coder_workspace_tags" "custom_workspace_tags" { tags = { "zone" = "developers" diff --git a/variables.go b/variables.go index dcbc1f8..75054a8 100644 --- a/variables.go +++ b/variables.go @@ -11,7 +11,7 @@ import ( ) func variables(modules terraform.Modules) []types.Variable { - variableBlocks := modules.GetDatasByType("variable") + variableBlocks := modules.GetBlocks().OfType("variable") vars := make([]types.Variable, 0, len(variableBlocks)) for _, block := range variableBlocks { vars = append(vars, extract.VariableFromBlock(block)) From 50dedf6a83ecd75e80bfd46098aa0fb48e9bb76c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Jul 2025 12:47:31 -0500 Subject: [PATCH 3/6] treat unknown variables as nil --- extract/variable.go | 34 +++++------ preview_test.go | 106 +++++++++++++++++++++++++-------- testdata/static/main.tf | 3 + testdata/unknownoption/main.tf | 4 ++ types/terraform.go | 17 +++--- variables.go | 15 +++-- 6 files changed, 123 insertions(+), 56 deletions(-) diff --git a/extract/variable.go b/extract/variable.go index 8322d00..8120d99 100644 --- a/extract/variable.go +++ b/extract/variable.go @@ -29,7 +29,7 @@ func VariableFromBlock(block *terraform.Block) types.Variable { } return types.Variable{ Name: block.Label(), - Diagnostics: hcl.Diagnostics{&hcl.Diagnostic{ + Diagnostics: types.Diagnostics{&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Failed to decode variable type for " + block.Label(), Detail: err.Error(), @@ -55,23 +55,22 @@ func VariableFromBlock(block *terraform.Block) types.Variable { val = defaults.Apply(val) } - valOK := !val.IsNull() && val.IsWhollyKnown() - typedVal, err := convert.Convert(val, valType) - if err != nil && valOK { - return types.Variable{ - Name: block.Label(), - Diagnostics: hcl.Diagnostics{&hcl.Diagnostic{ - Severity: hcl.DiagError, - Summary: fmt.Sprintf("Failed to convert variable default value to type %q for %q", - valType.FriendlyNameForConstraint(), block.Label()), - Detail: err.Error(), - Subject: &defSubject, - }}, - } - } + canConvert := !val.IsNull() && val.IsWhollyKnown() && valType != cty.NilType - // If the new converted value is ok, use it. - if err == nil { + if canConvert { + typedVal, err := convert.Convert(val, valType) + if err != nil { + return types.Variable{ + Name: block.Label(), + Diagnostics: types.Diagnostics{&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Failed to convert variable default value to type %q for %q", + valType.FriendlyNameForConstraint(), block.Label()), + Detail: err.Error(), + Subject: &defSubject, + }}, + } + } val = typedVal } } else { @@ -84,6 +83,5 @@ func VariableFromBlock(block *terraform.Block) types.Variable { Description: optionalString(block, "description"), Nullable: optionalBoolean(block, "nullable"), Sensitive: optionalBoolean(block, "sensitive"), - Ephemeral: optionalBoolean(block, "ephemeral"), } } diff --git a/preview_test.go b/preview_test.go index 77827ed..8b5f720 100644 --- a/preview_test.go +++ b/preview_test.go @@ -80,7 +80,8 @@ func Test_Extract(t *testing.T) { "numerical": ap().value("5"), }, variables: map[string]assertVariable{ - "string": av().def(cty.StringVal("Hello, world!")).typeEq(cty.String), + "string": av().def(cty.StringVal("Hello, world!")).typeEq(cty.String). + description("test").nullable(true).sensitive(true), "number": av().def(cty.NumberIntVal(7)).typeEq(cty.Number), "boolean": av().def(cty.BoolVal(true)).typeEq(cty.Bool), "coerce_string": av().def(cty.StringVal("5")).typeEq(cty.String), @@ -162,6 +163,11 @@ func Test_Extract(t *testing.T) { "indexed_0": ap(), "indexed_1": ap(), }, + variables: map[string]assertVariable{ + "regions": av().def(cty.SetVal([]cty.Value{ + cty.StringVal("us"), cty.StringVal("eu"), cty.StringVal("au"), + })).typeEq(cty.Set(cty.String)), + }, }, { name: "external docker resource without plan data", @@ -235,6 +241,9 @@ func Test_Extract(t *testing.T) { def("m7gd.8xlarge"). value("m7gd.8xlarge"), }, + variables: map[string]assertVariable{ + "regions": av().typeEq(cty.List(cty.String)), + }, }, { name: "empty file", @@ -447,6 +456,9 @@ func Test_Extract(t *testing.T) { "team": ap().optVals("frontend", "backend", "fullstack"), "jetbrains_ide": ap(), }, + variables: map[string]assertVariable{ + "security": av().def(cty.StringVal("high")).typeEq(cty.String), + }, }, { name: "count", @@ -541,6 +553,17 @@ func Test_Extract(t *testing.T) { optVals("GO", "IU", "PY"). optNames("GoLand 2024.3", "IntelliJ IDEA Ultimate 2024.3", "PyCharm Professional 2024.3"), }, + variables: map[string]assertVariable{ + "jetbrains_ides": av().typeEq(cty.List(cty.String)).description("The list of IDE product codes."), + "releases_base_link": av(), + "channel": av(), + "download_base_link": av(), + "arch": av(), + "jetbrains_ide_versions": av().typeEq(cty.Map(cty.Object(map[string]cty.Type{ + "build_number": cty.String, + "version": cty.String, + }))), + }, }, { name: "tfvars_from_file", @@ -554,6 +577,12 @@ func Test_Extract(t *testing.T) { "variable_values": ap(). def("alex").optVals("alex", "bob", "claire", "jason"), }, + variables: map[string]assertVariable{ + "one": av(), + "two": av(), + "three": av(), + "four": av(), + }, }, { name: "tfvars_from_input", @@ -572,6 +601,12 @@ func Test_Extract(t *testing.T) { "variable_values": ap(). def("andrew").optVals("andrew", "bill", "carter", "jason"), }, + variables: map[string]assertVariable{ + "one": av(), + "two": av(), + "three": av(), + "four": av(), + }, }, { name: "unknownoption", @@ -583,6 +618,10 @@ func Test_Extract(t *testing.T) { "unknown": apWithDiags(). errorDiagnostics("The set of options cannot be resolved"), }, + variables: map[string]assertVariable{ + // For now, unknown values are treated as nil :shrug: + "unknown": av().def(cty.NilVal), + }, }, } { t.Run(tc.name, func(t *testing.T) { @@ -671,6 +710,25 @@ func av() assertVariable { } } +func avWithDiags() assertVariable { + return func(t *testing.T, parameter types.Variable) {} +} + +func (a assertVariable) errorDiagnostics(patterns ...string) assertVariable { + return a.diagnostics(hcl.DiagError, patterns...) +} + +func (a assertVariable) warnDiagnostics(patterns ...string) assertVariable { + return a.diagnostics(hcl.DiagWarning, patterns...) +} + +func (a assertVariable) diagnostics(sev hcl.DiagnosticSeverity, patterns ...string) assertVariable { + shadow := patterns + return a.extend(func(t *testing.T, v types.Variable) { + assertDiags(t, sev, v.Diagnostics, shadow...) + }) +} + func (a assertVariable) nullable(n bool) assertVariable { return a.extend(func(t *testing.T, v types.Variable) { assert.Equal(t, v.Nullable, n, "variable nullable check") @@ -690,7 +748,6 @@ func (a assertVariable) def(def cty.Value) assertVariable { got, _ := hclext.AsString(v.Default) t.Logf("Expected: %s, Value: %s", exp, got) } - }) } @@ -700,12 +757,6 @@ func (a assertVariable) sensitive(s bool) assertVariable { }) } -func (a assertVariable) ephemeral(e bool) assertVariable { - return a.extend(func(t *testing.T, v types.Variable) { - assert.Equal(t, v.Ephemeral, e, "variable ephemeral check") - }) -} - func (a assertVariable) description(d string) assertVariable { return a.extend(func(t *testing.T, v types.Variable) { assert.Equal(t, v.Description, d, "variable description check") @@ -736,23 +787,7 @@ func (a assertParam) warnDiagnostics(patterns ...string) assertParam { func (a assertParam) diagnostics(sev hcl.DiagnosticSeverity, patterns ...string) assertParam { shadow := patterns return a.extend(func(t *testing.T, parameter types.Parameter) { - checks := make([]string, len(shadow)) - copy(checks, shadow) - - DiagLoop: - for _, diag := range parameter.Diagnostics { - if diag.Severity != sev { - continue - } - for i, pat := range checks { - if strings.Contains(diag.Summary, pat) || strings.Contains(diag.Detail, pat) { - checks = append(checks[:i], checks[i+1:]...) - break DiagLoop - } - } - } - - assert.Equal(t, []string{}, checks, "missing expected diagnostic errors") + assertDiags(t, sev, parameter.Diagnostics, shadow...) }) } @@ -855,3 +890,24 @@ func (a assertVariable) extend(f assertVariable) assertVariable { f(t, v) } } + +func assertDiags(t *testing.T, sev hcl.DiagnosticSeverity, diags types.Diagnostics, patterns ...string) { + t.Helper() + checks := make([]string, len(patterns)) + copy(checks, patterns) + +DiagLoop: + for _, diag := range diags { + if diag.Severity != sev { + continue + } + for i, pat := range checks { + if strings.Contains(diag.Summary, pat) || strings.Contains(diag.Detail, pat) { + checks = append(checks[:i], checks[i+1:]...) + break DiagLoop + } + } + } + + assert.Equal(t, []string{}, checks, "missing expected diagnostic errors") +} diff --git a/testdata/static/main.tf b/testdata/static/main.tf index 894d705..8ba5fdc 100644 --- a/testdata/static/main.tf +++ b/testdata/static/main.tf @@ -14,6 +14,9 @@ terraform { variable "string" { default = "Hello, world!" + nullable = true + sensitive = true + description = "test" } variable "number" { diff --git a/testdata/unknownoption/main.tf b/testdata/unknownoption/main.tf index c1943e6..88bb4a0 100644 --- a/testdata/unknownoption/main.tf +++ b/testdata/unknownoption/main.tf @@ -13,6 +13,10 @@ terraform { } } +variable "unknown" { + default = data.docker_registry_image.ubuntu.sha256_digest +} + data "coder_parameter" "unknown" { name = "unknown" display_name = "Unknown Option Example" diff --git a/types/terraform.go b/types/terraform.go index 4b63697..d8fb0a6 100644 --- a/types/terraform.go +++ b/types/terraform.go @@ -1,20 +1,19 @@ package types import ( - "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" ) type Variable struct { - Name string - Default cty.Value - Type cty.Type - Description string - Nullable bool - Sensitive bool - Ephemeral bool + Name string `json:"name"` + // Unsure how cty values json marshal + Default cty.Value `json:"default"` + Type cty.Type `json:"type"` + Description string `json:"description"` + Nullable bool `json:"nullable"` + Sensitive bool `json:"sensitive"` // Variables also have 'Validation', which is currently not implemented. - Diagnostics hcl.Diagnostics + Diagnostics Diagnostics `json:"diagnostics"` } diff --git a/variables.go b/variables.go index 75054a8..9ce7b26 100644 --- a/variables.go +++ b/variables.go @@ -11,10 +11,17 @@ import ( ) func variables(modules terraform.Modules) []types.Variable { - variableBlocks := modules.GetBlocks().OfType("variable") - vars := make([]types.Variable, 0, len(variableBlocks)) - for _, block := range variableBlocks { - vars = append(vars, extract.VariableFromBlock(block)) + vars := make([]types.Variable, 0) + + for _, mod := range modules { + // Only extract variables from root modules. Child modules have their + // vars set in the parent module. + if mod.Parent() == nil { + variableBlocks := mod.GetBlocks().OfType("variable") + for _, block := range variableBlocks { + vars = append(vars, extract.VariableFromBlock(block)) + } + } } // Sort the variables by name for consistency From a9bb1e36ef9a4b2ffd935c127e7428b67617dd86 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Jul 2025 12:49:22 -0500 Subject: [PATCH 4/6] add extra defense --- extract/variable.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/extract/variable.go b/extract/variable.go index 8120d99..a261279 100644 --- a/extract/variable.go +++ b/extract/variable.go @@ -14,7 +14,24 @@ import ( // VariableFromBlock extracts a terraform variable, but not it's final resolved value. // code taken mostly from https://github.com/aquasecurity/trivy/blob/main/pkg/iac/scanners/terraform/parser/evaluator.go#L479 -func VariableFromBlock(block *terraform.Block) types.Variable { +func VariableFromBlock(block *terraform.Block) (tfVar types.Variable) { + defer func() { + // Extra safety mechanism to ensure that if a panic occurs, we do not break + // everything else. + if r := recover(); r != nil { + tfVar = types.Variable{ + Name: block.Label(), + Diagnostics: types.Diagnostics{ + { + Severity: hcl.DiagError, + Summary: "Panic occurred in extracting variable. This should not happen, please report this to Coder.", + Detail: fmt.Sprintf("panic in variable extract: %+v", r), + }, + }, + } + } + }() + attributes := block.Attributes() var valType cty.Type From 28466ba0e24da1f8ec8fa0408ad01905eaf6b69c Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Jul 2025 13:08:55 -0500 Subject: [PATCH 5/6] fix e2e --- preview_test.go | 1 - testdata/static/main.tf | 5 +++++ testdata/unknownoption/main.tf | 2 +- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/preview_test.go b/preview_test.go index 8b5f720..c75cf6a 100644 --- a/preview_test.go +++ b/preview_test.go @@ -619,7 +619,6 @@ func Test_Extract(t *testing.T) { errorDiagnostics("The set of options cannot be resolved"), }, variables: map[string]assertVariable{ - // For now, unknown values are treated as nil :shrug: "unknown": av().def(cty.NilVal), }, }, diff --git a/testdata/static/main.tf b/testdata/static/main.tf index 8ba5fdc..2f0a642 100644 --- a/testdata/static/main.tf +++ b/testdata/static/main.tf @@ -38,6 +38,11 @@ variable "complex" { name = string age = number }) + default = { + list = [] + name = "John Doe" + age = 30 + } } diff --git a/testdata/unknownoption/main.tf b/testdata/unknownoption/main.tf index 88bb4a0..1ac5c91 100644 --- a/testdata/unknownoption/main.tf +++ b/testdata/unknownoption/main.tf @@ -14,7 +14,7 @@ terraform { } variable "unknown" { - default = data.docker_registry_image.ubuntu.sha256_digest + default = null } data "coder_parameter" "unknown" { From 6d83d057b50567f71c29845096fa2a87090b3ba4 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 23 Jul 2025 13:12:58 -0500 Subject: [PATCH 6/6] Update extract/variable.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extract/variable.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extract/variable.go b/extract/variable.go index a261279..2385a54 100644 --- a/extract/variable.go +++ b/extract/variable.go @@ -12,7 +12,7 @@ import ( "github.com/coder/preview/types" ) -// VariableFromBlock extracts a terraform variable, but not it's final resolved value. +// VariableFromBlock extracts a terraform variable, but not its final resolved value. // code taken mostly from https://github.com/aquasecurity/trivy/blob/main/pkg/iac/scanners/terraform/parser/evaluator.go#L479 func VariableFromBlock(block *terraform.Block) (tfVar types.Variable) { defer func() {