Skip to content

feat: parse and extract tf variables #168

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions extract/variable.go
Original file line number Diff line number Diff line change
@@ -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 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) (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()
}
Comment on lines +40 to +95
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This code is taken from trivy. Unfortunately it is not exported, so I had to copy paste it.

return types.Variable{
Name: block.Label(),
Default: val,
Type: valType,
Description: optionalString(block, "description"),
Nullable: optionalBoolean(block, "nullable"),
Sensitive: optionalBoolean(block, "sensitive"),
}
}
3 changes: 3 additions & 0 deletions preview.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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))
Expand All @@ -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)
}

Expand Down
173 changes: 156 additions & 17 deletions preview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
}{
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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) {
Expand Down Expand Up @@ -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 {
Expand All @@ -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...)
})
}

Expand Down Expand Up @@ -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")
}
Loading
Loading