diff --git a/extract/variable.go b/extract/variable.go new file mode 100644 index 0000000..2385a54 --- /dev/null +++ b/extract/variable.go @@ -0,0 +1,104 @@ +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 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() { + // 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 + 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: types.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 + 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) + } + + canConvert := !val.IsNull() && val.IsWhollyKnown() && valType != cty.NilType + + 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 { + valType = val.Type() + } + return types.Variable{ + Name: block.Label(), + Default: val, + Type: valType, + Description: optionalString(block, "description"), + Nullable: optionalBoolean(block, "nullable"), + Sensitive: optionalBoolean(block, "sensitive"), + } +} 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/preview_test.go b/preview_test.go index 435fb93..c75cf6a 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,18 @@ 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). + 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), + "complex": av().typeEq(cty.Object(map[string]cty.Type{ + "list": cty.List(cty.String), + "name": cty.String, + "age": cty.Number, + })), + }, }, { name: "conditional-no-inputs", @@ -149,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", @@ -222,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", @@ -434,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", @@ -528,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", @@ -541,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", @@ -559,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", @@ -570,6 +618,9 @@ func Test_Extract(t *testing.T) { "unknown": apWithDiags(). errorDiagnostics("The set of options cannot be resolved"), }, + variables: map[string]assertVariable{ + "unknown": av().def(cty.NilVal), + }, }, } { t.Run(tc.name, func(t *testing.T) { @@ -637,10 +688,80 @@ 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 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") + }) +} + +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) 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 { @@ -665,23 +786,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...) }) } @@ -771,3 +876,37 @@ 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) + } +} + +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 0260e72..2f0a642 100644 --- a/testdata/static/main.tf +++ b/testdata/static/main.tf @@ -12,6 +12,40 @@ terraform { } } +variable "string" { + default = "Hello, world!" + nullable = true + sensitive = true + description = "test" +} + +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 + }) + default = { + list = [] + name = "John Doe" + age = 30 + } +} + + data "coder_workspace_tags" "custom_workspace_tags" { tags = { "zone" = "developers" diff --git a/testdata/unknownoption/main.tf b/testdata/unknownoption/main.tf index c1943e6..1ac5c91 100644 --- a/testdata/unknownoption/main.tf +++ b/testdata/unknownoption/main.tf @@ -13,6 +13,10 @@ terraform { } } +variable "unknown" { + default = null +} + data "coder_parameter" "unknown" { name = "unknown" display_name = "Unknown Option Example" diff --git a/types/terraform.go b/types/terraform.go new file mode 100644 index 0000000..d8fb0a6 --- /dev/null +++ b/types/terraform.go @@ -0,0 +1,19 @@ +package types + +import ( + "github.com/zclconf/go-cty/cty" +) + +type Variable struct { + 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 Diagnostics `json:"diagnostics"` +} diff --git a/variables.go b/variables.go new file mode 100644 index 0000000..9ce7b26 --- /dev/null +++ b/variables.go @@ -0,0 +1,32 @@ +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 { + 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 + slices.SortFunc(vars, func(a, b types.Variable) int { + return strings.Compare(a.Name, b.Name) + }) + return vars +}