diff --git a/attrs/values.go b/attrs/values.go new file mode 100644 index 0000000..f79c80e --- /dev/null +++ b/attrs/values.go @@ -0,0 +1,57 @@ +package attrs + +import ( + "github.com/aquasecurity/trivy/pkg/iac/terraform" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/gocty" +) + +type Values interface { + // Attr returns the value of the attribute with the given key. + // If the attribute does not exist, it returns cty.NilVal. + Attr(key string) cty.Value +} + +func NewValues[T *terraform.Block | map[string]any](block T) Values { + switch v := any(block).(type) { + case *terraform.Block: + return &blockValues{Values: v} + case map[string]any: + return &mapValues{Values: v} + default: + panic("unsupported type") + } +} + +type blockValues struct { + Values *terraform.Block +} + +func (b *blockValues) Attr(key string) cty.Value { + attr := b.Values.GetAttribute(key) + if attr.IsNil() { + return cty.NilVal + } + return attr.Value() +} + +type mapValues struct { + Values map[string]any +} + +func (m *mapValues) Attr(key string) cty.Value { + val, ok := m.Values[key] + if !ok { + return cty.NilVal + } + + gt, err := gocty.ImpliedType(val) + if err != nil { + return cty.NilVal + } + v, err := gocty.ToCtyValue(val, gt) + if err != nil { + return cty.NilVal + } + return v +} diff --git a/cli/clidisplay/resources.go b/cli/clidisplay/resources.go index cac6111..9c6aa4f 100644 --- a/cli/clidisplay/resources.go +++ b/cli/clidisplay/resources.go @@ -79,7 +79,7 @@ func Parameters(writer io.Writer, params []types.Parameter) { _, _ = fmt.Fprintln(writer, tableWriter.Render()) } -func formatOptions(selected string, options []*types.RichParameterOption) string { +func formatOptions(selected string, options []*types.ParameterOption) string { var str strings.Builder sep := "" found := false diff --git a/extract/json.go b/extract/json.go new file mode 100644 index 0000000..cc85881 --- /dev/null +++ b/extract/json.go @@ -0,0 +1,64 @@ +package extract + +import ( + "fmt" +) + +type stateParse struct { + errors []error + values map[string]any +} + +func newStateParse(v map[string]any) *stateParse { + return &stateParse{ + errors: make([]error, 0), + values: v, + } +} + +func (p *stateParse) optionalString(key string) string { + return optional[string](p.values, key) +} + +func (p *stateParse) optionalInteger(key string) int64 { + return optional[int64](p.values, key) +} + +func (p *stateParse) optionalBool(key string) bool { + return optional[bool](p.values, key) +} + +func (p *stateParse) nullableInteger(key string) *int64 { + if p.values[key] == nil { + return nil + } + v := optional[int64](p.values, key) + return &v +} + +func (p *stateParse) string(key string) string { + v, err := expected[string](p.values, key) + if err != nil { + p.errors = append(p.errors, err) + return "" + } + return v +} + +func optional[T any](vals map[string]any, key string) T { + v, _ := expected[T](vals, key) + return v +} + +func expected[T any](vals map[string]any, key string) (T, error) { + v, ok := vals[key] + if !ok { + return *new(T), fmt.Errorf("missing required key %q", key) + } + + val, ok := v.(T) + if !ok { + return *new(T), fmt.Errorf("key %q is not of type %T", key, v) + } + return val, nil +} diff --git a/extract/parameter.go b/extract/parameter.go new file mode 100644 index 0000000..f6eb559 --- /dev/null +++ b/extract/parameter.go @@ -0,0 +1,266 @@ +package extract + +import ( + "fmt" + "strings" + + "github.com/aquasecurity/trivy/pkg/iac/terraform" + "github.com/hashicorp/hcl/v2" + "github.com/zclconf/go-cty/cty" + + "github.com/coder/preview/hclext" + "github.com/coder/preview/types" +) + +func ParameterFromBlock(block *terraform.Block) (types.Parameter, hcl.Diagnostics) { + diags := required(block, "name", "type") + if diags.HasErrors() { + return types.Parameter{}, diags + } + + pType, typDiag := requiredString("type", block) + if typDiag != nil { + diags = diags.Append(typDiag) + } + + pName, nameDiag := requiredString("name", block) + if nameDiag != nil { + diags = diags.Append(nameDiag) + } + + pVal, valDiags := richParameterValue(block) + diags = diags.Extend(valDiags) + + if diags.HasErrors() { + return types.Parameter{}, diags + } + + p := types.Parameter{ + Value: types.ParameterValue{ + Value: pVal, + }, + RichParameter: types.RichParameter{ + Name: pName, + Description: optionalString(block, "description"), + Type: pType, + Mutable: optionalBoolean(block, "mutable"), + // Default value is always written as a string, then converted + // to the correct type. + DefaultValue: optionalString(block, "default"), + Icon: optionalString(block, "icon"), + Options: make([]*types.ParameterOption, 0), + Validations: make([]*types.ParameterValidation, 0), + Required: optionalBoolean(block, "required"), + DisplayName: optionalString(block, "display_name"), + Order: optionalInteger(block, "order"), + Ephemeral: optionalBoolean(block, "ephemeral"), + BlockName: block.NameLabel(), + }, + } + + for _, b := range block.GetBlocks("option") { + opt, optDiags := ParameterOptionFromBlock(b) + diags = diags.Extend(optDiags) + + if optDiags.HasErrors() { + continue + } + + p.Options = append(p.Options, &opt) + } + + for _, b := range block.GetBlocks("validation") { + valid, validDiags := ParameterValidationFromBlock(b) + diags = diags.Extend(validDiags) + + if validDiags.HasErrors() { + continue + } + + p.Validations = append(p.Validations, &valid) + } + + return p, diags +} + +func ParameterValidationFromBlock(block *terraform.Block) (types.ParameterValidation, hcl.Diagnostics) { + diags := required(block, "error") + if diags.HasErrors() { + return types.ParameterValidation{}, diags + } + + pErr, errDiag := requiredString("error", block) + if errDiag != nil { + diags = diags.Append(errDiag) + } + + if diags.HasErrors() { + return types.ParameterValidation{}, diags + } + + p := types.ParameterValidation{ + Regex: optionalString(block, "regex"), + Error: pErr, + Min: nullableInteger(block, "min"), + Max: nullableInteger(block, "max"), + Monotonic: optionalString(block, "monotonic"), + } + + return p, diags +} + +func ParameterOptionFromBlock(block *terraform.Block) (types.ParameterOption, hcl.Diagnostics) { + diags := required(block, "name", "value") + if diags.HasErrors() { + return types.ParameterOption{}, diags + } + + pName, nameDiag := requiredString("name", block) + if nameDiag != nil { + diags = diags.Append(nameDiag) + } + + pVal, valDiag := requiredString("value", block) + if valDiag != nil { + diags = diags.Append(valDiag) + } + + if diags.HasErrors() { + return types.ParameterOption{}, diags + } + + p := types.ParameterOption{ + Name: pName, + Description: optionalString(block, "description"), + Value: pVal, + Icon: optionalString(block, "icon"), + } + + return p, diags +} + +func requiredString(key string, block *terraform.Block) (string, *hcl.Diagnostic) { + tyAttr := block.GetAttribute(key) + tyVal := tyAttr.Value() + if tyVal.Type() != cty.String { + diag := &hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Invalid %q attribute", key), + Detail: fmt.Sprintf("Expected a string, got %q", tyVal.Type().FriendlyName()), + Subject: &(tyAttr.HCLAttribute().Range), + //Context: &(block.HCLBlock().DefRange), + Expression: tyAttr.HCLAttribute().Expr, + EvalContext: block.Context().Inner(), + } + + if !tyVal.IsWhollyKnown() { + refs := hclext.ReferenceNames(tyAttr.HCLAttribute().Expr) + if len(refs) > 0 { + diag.Detail = fmt.Sprintf("Value is not known, check the references [%s] are resolvable", + strings.Join(refs, ", ")) + } + } + + return "", diag + } + + return tyVal.AsString(), nil +} + +func optionalBoolean(block *terraform.Block, key string) bool { + attr := block.GetAttribute(key) + if attr == nil || attr.IsNil() { + return false + } + val := attr.Value() + if val.Type() != cty.Bool { + return false + } + + return val.True() +} + +func nullableInteger(block *terraform.Block, key string) *int64 { + attr := block.GetAttribute(key) + if attr == nil || attr.IsNil() { + return nil + } + val := attr.Value() + if val.Type() != cty.Number { + return nil + } + + i, acc := val.AsBigFloat().Int64() + var _ = acc // acc should be 0 + + return &i +} + +func optionalInteger(block *terraform.Block, key string) int64 { + attr := block.GetAttribute(key) + if attr == nil || attr.IsNil() { + return 0 + } + val := attr.Value() + if val.Type() != cty.Number { + return 0 + } + + i, acc := val.AsBigFloat().Int64() + var _ = acc // acc should be 0 + + return i +} + +func optionalString(block *terraform.Block, key string) string { + attr := block.GetAttribute(key) + if attr == nil || attr.IsNil() { + return "" + } + val := attr.Value() + if val.Type() != cty.String { + return "" + } + + return val.AsString() +} + +func required(block *terraform.Block, keys ...string) hcl.Diagnostics { + var diags hcl.Diagnostics + for _, key := range keys { + attr := block.GetAttribute(key) + if attr == nil || attr.IsNil() || attr.Value() == cty.NilVal { + r := block.HCLBlock().Body.MissingItemRange() + diags = diags.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: fmt.Sprintf("Missing required attribute %q", key), + Detail: fmt.Sprintf("The %s attribute is required", key), + Subject: &r, + Extra: nil, + }) + } + } + return diags +} + +func richParameterValue(block *terraform.Block) (cty.Value, hcl.Diagnostics) { + // Find the value of the parameter from the context. + paramPath := append([]string{"data"}, block.Labels()...) + valueRef := hclext.ScopeTraversalExpr(append(paramPath, "value")...) + return valueRef.Value(block.Context().Inner()) +} + +func ParameterCtyType(typ string) (cty.Type, error) { + switch typ { + case "string": + return cty.String, nil + case "number": + return cty.Number, nil + case "bool": + return cty.Bool, nil + case "list(string)": + return cty.List(cty.String), nil + default: + return cty.Type{}, fmt.Errorf("unsupported type: %q", typ) + } +} diff --git a/extract/state.go b/extract/state.go new file mode 100644 index 0000000..c4972e9 --- /dev/null +++ b/extract/state.go @@ -0,0 +1,141 @@ +package extract + +import ( + "errors" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/zclconf/go-cty/cty" + + "github.com/coder/preview/types" +) + +func ParametersFromState(state *tfjson.StateModule) ([]types.Parameter, error) { + parameters := make([]types.Parameter, 0) + for _, resource := range state.Resources { + if resource.Mode != "data" { + continue + } + + if resource.Type != types.BlockTypeParameter { + continue + } + + param, err := ParameterFromState(resource) + if err != nil { + return nil, err + } + parameters = append(parameters, param) + } + + for _, cm := range state.ChildModules { + cParams, err := ParametersFromState(cm) + if err != nil { + return nil, fmt.Errorf("child module %q: %w", cm.Address, err) + } + parameters = append(parameters, cParams...) + } + + return parameters, nil +} + +func ParameterFromState(block *tfjson.StateResource) (types.Parameter, error) { + st := newStateParse(block.AttributeValues) + + options, err := convertKeyList[*types.ParameterOption](st.values, "option", parameterOption) + if err != nil { + return types.Parameter{}, fmt.Errorf("convert param options: %w", err) + } + + validations, err := convertKeyList(st.values, "validation", parameterValidation) + if err != nil { + return types.Parameter{}, fmt.Errorf("convert param validations: %w", err) + } + + param := types.Parameter{ + Value: types.ParameterValue{ + Value: cty.StringVal(st.string("value")), + }, + RichParameter: types.RichParameter{ + Name: st.string("name"), + Description: st.optionalString("description"), + Type: st.string("type"), + Mutable: st.optionalBool("mutable"), + DefaultValue: st.optionalString("default"), + Icon: st.optionalString("icon"), + Options: options, + Validations: validations, + Required: st.optionalBool("required"), + DisplayName: st.optionalString("display_name"), + Order: st.optionalInteger("order"), + Ephemeral: st.optionalBool("ephemeral"), + BlockName: block.Name, + }, + } + + if len(st.errors) > 0 { + return types.Parameter{}, errors.Join(st.errors...) + } + + return param, nil +} + +func convertKeyList[T any](vals map[string]any, key string, convert func(map[string]any) (T, error)) ([]T, error) { + list := make([]T, 0) + value, ok := vals[key] + if !ok { + return list, nil + } + + elems, ok := value.([]any) + if !ok { + return list, fmt.Errorf("option is not a list, found %T", elems) + } + + for _, elem := range elems { + elemMap, ok := elem.(map[string]any) + if !ok { + return list, fmt.Errorf("option is not a map, found %T", elem) + } + + converted, err := convert(elemMap) + if err != nil { + return list, fmt.Errorf("option: %w", err) + } + list = append(list, converted) + } + return list, nil +} + +func parameterValidation(vals map[string]any) (*types.ParameterValidation, error) { + st := newStateParse(vals) + + opt := types.ParameterValidation{ + Regex: st.optionalString("regex"), + Error: st.optionalString("error"), + Min: st.nullableInteger("min"), + Max: st.nullableInteger("max"), + Monotonic: st.optionalString("monotonic"), + } + + if len(st.errors) > 0 { + return nil, errors.Join(st.errors...) + } + return &opt, nil +} + +func parameterOption(vals map[string]any) (*types.ParameterOption, error) { + st := newStateParse(vals) + + opt := types.ParameterOption{ + Name: st.string("name"), + Description: st.optionalString("description"), + Value: st.string("value"), + Icon: st.optionalString("icon"), + } + + if len(st.errors) > 0 { + return nil, errors.Join(st.errors...) + } + return &opt, nil +} diff --git a/go.mod b/go.mod index d44fdc1..4f7b5a1 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,10 @@ go 1.23.5 require ( github.com/aquasecurity/trivy v0.58.2 github.com/coder/serpent v0.10.0 + github.com/hashicorp/go-version v1.7.0 + github.com/hashicorp/hc-install v0.9.1 github.com/hashicorp/hcl/v2 v2.23.0 + github.com/hashicorp/terraform-exec v0.22.0 github.com/hashicorp/terraform-json v0.24.0 github.com/jedib0t/go-pretty/v6 v6.6.5 github.com/stretchr/testify v1.10.0 @@ -25,6 +28,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect + github.com/ProtonMail/go-crypto v1.1.5 // indirect github.com/agext/levenshtein v1.2.3 // indirect github.com/apparentlymart/go-cidr v1.1.0 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect @@ -35,6 +39,7 @@ require ( github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect github.com/census-instrumentation/opencensus-proto v0.4.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/cloudflare/circl v1.5.0 // indirect github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78 // indirect github.com/coder/pretty v0.0.0-20230908205945-e89ba86370e0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect @@ -53,9 +58,9 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-getter v1.7.8 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/go-safetemp v1.0.0 // indirect github.com/hashicorp/go-uuid v1.0.3 // indirect - github.com/hashicorp/go-version v1.7.0 // indirect github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 // indirect github.com/klauspost/compress v1.17.11 // indirect github.com/liamg/memoryfs v1.6.0 // indirect diff --git a/go.sum b/go.sum index 3d16ab9..d276213 100644 --- a/go.sum +++ b/go.sum @@ -923,8 +923,12 @@ github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9n github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-getter v1.7.8 h1:mshVHx1Fto0/MydBekWan5zUipGq7jO0novchgMmSiY= github.com/hashicorp/go-getter v1.7.8/go.mod h1:2c6CboOEb9jG6YvmC9xdD+tyAFsrUaJPedwXDGr0TM4= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= @@ -934,8 +938,12 @@ github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKe github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= +github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= +github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= diff --git a/internal/verify/cmp.go b/internal/verify/cmp.go new file mode 100644 index 0000000..8e6cf80 --- /dev/null +++ b/internal/verify/cmp.go @@ -0,0 +1,42 @@ +package verify + +import ( + "testing" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/preview" + "github.com/coder/preview/extract" + "github.com/coder/preview/types" +) + +func Compare(t *testing.T, pr *preview.Output, values *tfjson.StateModule) { + passed := CompareParameters(t, pr, values) + + // TODO: Compare workspace tags + + if !passed { + t.Fatalf("paramaters failed expectations") + } +} + +func CompareParameters(t *testing.T, pr *preview.Output, values *tfjson.StateModule) bool { + t.Helper() + + // Assert expected parameters + stateParams, err := extract.ParametersFromState(values) + require.NoError(t, err, "extract parameters from state") + + passed := assert.Equal(t, len(stateParams), len(pr.Parameters), "number of parameters") + + types.SortParameters(stateParams) + types.SortParameters(pr.Parameters) + for i, param := range stateParams { + // TODO: A better compare function would be easier to debug + assert.Equal(t, param, pr.Parameters[i], "parameter %q %d", param.BlockName, i) + } + + return passed +} diff --git a/internal/verify/exec.go b/internal/verify/exec.go new file mode 100644 index 0000000..5b3ceda --- /dev/null +++ b/internal/verify/exec.go @@ -0,0 +1,216 @@ +package verify + +import ( + "bytes" + "context" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/hc-install/product" + "github.com/hashicorp/hc-install/releases" + "github.com/hashicorp/hc-install/src" + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" + "github.com/stretchr/testify/require" +) + +type WorkingExecutable struct { + Executable + WorkingDir string + TF *tfexec.Terraform +} + +type Executable struct { + ExecPath string + Version string + DirPath string + + ins src.Installable +} + +func (e Executable) WorkingDir(dir string) (WorkingExecutable, error) { + tf, err := tfexec.NewTerraform(dir, e.ExecPath) + if err != nil { + return WorkingExecutable{}, fmt.Errorf("create terraform exec: %w", err) + } + + return WorkingExecutable{ + Executable: e, + WorkingDir: dir, + TF: tf, + }, nil +} + +func (e WorkingExecutable) Init(ctx context.Context) error { + return e.TF.Init(ctx, tfexec.Upgrade(true)) +} + +func (e WorkingExecutable) Plan(ctx context.Context, outPath string) (bool, error) { + changes, err := e.TF.Plan(ctx, tfexec.Out(outPath)) + return changes, err +} + +func (e WorkingExecutable) Apply(ctx context.Context) ([]byte, error) { + var out bytes.Buffer + err := e.TF.ApplyJSON(ctx, &out) + return out.Bytes(), err +} + +func (e WorkingExecutable) ShowPlan(ctx context.Context, planPath string) (*tfjson.Plan, error) { + return e.TF.ShowPlanFile(ctx, planPath) +} + +func (e WorkingExecutable) Show(ctx context.Context) (*tfjson.State, error) { + return e.TF.Show(ctx) +} + +// TerraformTestVersions returns a list of Terraform versions to test. +func TerraformTestVersions(ctx context.Context) []src.Installable { + lv := LatestTerraformVersion(ctx) + return []src.Installable{ + lv, + } +} + +func InstallTerraforms(ctx context.Context, t *testing.T, installables ...src.Installable) []Executable { + // All terraform versions are installed in the same root directory + root := t.TempDir() + + execPaths := make([]Executable, 0, len(installables)) + + for _, installable := range installables { + ex := Executable{ + ins: installable, + } + switch tfi := installable.(type) { + case *releases.ExactVersion: + ver := tfi.Version.String() + t.Logf("Installing Terraform %s", ver) + tfi.InstallDir = filepath.Join(root, ver) + + err := os.Mkdir(tfi.InstallDir, 0o755) + require.NoErrorf(t, err, "tf install %q", ver) + + ex.Version = ver + ex.DirPath = tfi.InstallDir + case *releases.LatestVersion: + t.Logf("Installing latest Terraform") + ver := "latest" + tfi.InstallDir = filepath.Join(root, ver) + + err := os.Mkdir(tfi.InstallDir, 0o755) + require.NoErrorf(t, err, "tf install %q", ver) + + ex.Version = ver + ex.DirPath = tfi.InstallDir + default: + // We only support the types we know about + t.Fatalf("unknown installable type %T", tfi) + } + + execPath, err := installable.Install(ctx) + require.NoErrorf(t, err, "tf install") + ex.ExecPath = execPath + + execPaths = append(execPaths, ex) + } + + return execPaths +} + +func LatestTerraformVersion(ctx context.Context) *releases.LatestVersion { + return &releases.LatestVersion{ + Product: product.Terraform, + } +} + +// TerraformVersions will return all versions that match the constraints plus the +// current latest version. +func TerraformVersions(ctx context.Context, constraints version.Constraints) ([]*releases.ExactVersion, error) { + if len(constraints) == 0 { + return nil, fmt.Errorf("no constraints provided, don't fetch everything") + } + + srcs, err := (&releases.Versions{ + Product: product.Terraform, + Enterprise: nil, + Constraints: constraints, + ListTimeout: time.Second * 60, + Install: releases.InstallationOptions{ + Timeout: 0, + Dir: "", + SkipChecksumVerification: false, + }, + }).List(ctx) + if err != nil { + return nil, fmt.Errorf("failed to list Terraform versions: %w", err) + } + + include := make([]*releases.ExactVersion, 0) + for _, src := range srcs { + ev, ok := src.(*releases.ExactVersion) + if !ok { + return nil, fmt.Errorf("failed to cast src to ExactVersion, type was %T", src) + } + + include = append(include, ev) + } + + return include, nil +} + +// CopyTFFS is copied from os.CopyFS and ignores tfstate and lockfiles. +func CopyTFFS(dir string, fsys fs.FS) error { + return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if strings.HasPrefix(d.Name(), ".terraform") { + return nil + } + + fpath, err := filepath.Localize(path) + if err != nil { + return err + } + newPath := filepath.Join(dir, fpath) + if d.IsDir() { + return os.MkdirAll(newPath, 0777) + } + + // TODO(panjf2000): handle symlinks with the help of fs.ReadLinkFS + // once https://go.dev/issue/49580 is done. + // we also need filepathlite.IsLocal from https://go.dev/cl/564295. + if !d.Type().IsRegular() { + return &os.PathError{Op: "CopyFS", Path: path, Err: os.ErrInvalid} + } + + r, err := fsys.Open(path) + if err != nil { + return err + } + defer r.Close() + info, err := r.Stat() + if err != nil { + return err + } + w, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0666|info.Mode()&0777) + if err != nil { + return err + } + + if _, err := io.Copy(w, r); err != nil { + w.Close() + return &os.PathError{Op: "Copy", Path: newPath, Err: err} + } + return w.Close() + }) +} diff --git a/parameter.go b/parameter.go index 559451a..77a87be 100644 --- a/parameter.go +++ b/parameter.go @@ -5,6 +5,7 @@ import ( "github.com/hashicorp/hcl/v2" "github.com/zclconf/go-cty/cty" + "github.com/coder/preview/extract" "github.com/coder/preview/hclext" "github.com/coder/preview/types" ) @@ -14,70 +15,25 @@ func RichParameters(modules terraform.Modules) ([]types.Parameter, hcl.Diagnosti params := make([]types.Parameter, 0) for _, mod := range modules { - blocks := mod.GetDatasByType("coder_parameter") + blocks := mod.GetDatasByType(types.BlockTypeParameter) for _, block := range blocks { - p := newAttributeParser(block) - - var paramOptions []*types.RichParameterOption - optionBlocks := block.GetBlocks("option") - for _, optionBlock := range optionBlocks { - option, optDiags := paramOption(optionBlock) - if optDiags.HasErrors() { - // Add the error and continue - diags = diags.Extend(optDiags) - continue - } - paramOptions = append(paramOptions, option) + param, pDiags := extract.ParameterFromBlock(block) + if len(pDiags) > 0 { + diags = diags.Extend(pDiags) } - // Find the value of the parameter from the context. - paramValue := richParameterValue(block) - var defVal string - - // default can be nil if the references are not resolved. - def := block.GetAttribute("default").Value() - if def.Equals(cty.NilVal).True() { - defVal = "" - } else if !def.IsKnown() { - defVal = "" - } else { - defVal = p.attr("default").string() - } - - param := types.Parameter{ - Value: types.ParameterValue{ - Value: paramValue, - }, - RichParameter: types.RichParameter{ - BlockName: block.Labels()[1], - Name: p.attr("name").required().string(), - Description: p.attr("description").string(), - Type: "", - Mutable: false, - DefaultValue: defVal, - Icon: p.attr("icon").string(), - Options: paramOptions, - Validation: nil, - Required: false, - DisplayName: "", - Order: 0, - Ephemeral: false, - }, - } - diags = diags.Extend(p.diags) - if p.diags.HasErrors() { - continue + if !pDiags.HasErrors() { + params = append(params, param) } - params = append(params, param) } } return params, diags } -func paramOption(block *terraform.Block) (*types.RichParameterOption, hcl.Diagnostics) { +func paramOption(block *terraform.Block) (*types.ParameterOption, hcl.Diagnostics) { p := newAttributeParser(block) - opt := &types.RichParameterOption{ + opt := &types.ParameterOption{ Name: p.attr("name").required().string(), Description: p.attr("description").string(), // Does it need to be a string? diff --git a/plan.go b/plan.go index 5912cbb..f467b07 100644 --- a/plan.go +++ b/plan.go @@ -151,6 +151,7 @@ func toCtyValue(a any) (cty.Value, error) { // terraform show -json out.plan func ParsePlanJSON(reader io.Reader) (*tfjson.Plan, error) { plan := new(tfjson.Plan) + plan.FormatVersion = tfjson.PlanFormatVersionConstraints return plan, json.NewDecoder(reader).Decode(plan) } diff --git a/previewe2e_test.go b/previewe2e_test.go new file mode 100644 index 0000000..6c8d626 --- /dev/null +++ b/previewe2e_test.go @@ -0,0 +1,152 @@ +package preview_test + +import ( + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/preview" + "github.com/coder/preview/internal/verify" + "github.com/coder/preview/types" +) + +// Test_VerifyE2E will fully evaluate with `terraform apply` +// and verify the output of `preview` against the tfstate. This +// is the e2e test for the preview package. +// +// 1. Terraform versions listed from 'verify.TerraformTestVersions' are +// installed into a temp directory. If multiple versions exist, a e2e test +// for each tf version is run. +// 2. For each test directory in `testdata`, the following steps are performed: +// a. If the directory contains a file named `skipe2e`, skip the test. +// Some tests are not meant to be run in e2e mode as they require external +// credentials, or are invalid terraform configurations. +// b. For each terraform version, the following steps are performed: +// ---- Core Test ---- +// i. Create a working directory for the terraform version in a temp dir. +// ii. Copy the test data into the working directory. +// iii. Run `terraform init`. +// iv. Run `terraform plan` and save the output to a file. +// v. Run `terraform apply`. +// vi. Run `terraform show` to get the state. +// vii. Run `preview --plan=out.plan` to get the preview state. +// viii. Compare the preview state with the terraform state. +// ix. If the preview state is not equal to the terraform state, fail the test. +// ---- ---- +// +// The goal of the test is to compare `tfstate` with the output of `preview`. +// If `preview`'s implementation of terraform is incorrect, the test will fail. +// TODO: Adding varied parameter inputs would be a good idea. +func Test_VerifyE2E(t *testing.T) { + t.Parallel() + + installCtx, cancel := context.WithCancel(context.Background()) + + versions := verify.TerraformTestVersions(installCtx) + tfexecs := verify.InstallTerraforms(installCtx, t, versions...) + cancel() + + dirFs := os.DirFS("testdata") + entries, err := fs.ReadDir(dirFs, ".") + require.NoError(t, err) + + for _, entry := range entries { + entry := entry + if !entry.IsDir() { + t.Logf("skipping non directory file %q", entry.Name()) + continue + } + + entryFiles, err := fs.ReadDir(dirFs, filepath.Join(entry.Name())) + require.NoError(t, err, "reading test data dir") + if !slices.ContainsFunc(entryFiles, func(entry fs.DirEntry) bool { + return filepath.Ext(entry.Name()) == ".tf" + }) { + t.Logf("skipping test data dir %q, no .tf files", entry.Name()) + continue + } + + if slices.ContainsFunc(entryFiles, func(entry fs.DirEntry) bool { + return entry.Name() == "skipe2e" + }) { + t.Logf("skipping test data dir %q, skip file found", entry.Name()) + continue + } + + name := entry.Name() + t.Run(name, func(t *testing.T) { + t.Parallel() + + entryWrkPath := t.TempDir() + + for _, tfexec := range tfexecs { + tfexec := tfexec + + t.Run(tfexec.Version, func(t *testing.T) { + wp := filepath.Join(entryWrkPath, tfexec.Version) + err := os.MkdirAll(wp, 0755) + require.NoError(t, err, "creating working dir") + + t.Logf("working dir %q", wp) + + subFS, err := fs.Sub(dirFs, entry.Name()) + require.NoError(t, err, "creating sub fs") + + err = verify.CopyTFFS(wp, subFS) + require.NoError(t, err, "copying test data to working dir") + + exe, err := tfexec.WorkingDir(wp) + require.NoError(t, err, "creating working executable") + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute*2) + defer cancel() + err = exe.Init(ctx) + require.NoError(t, err, "terraform init") + + planOutFile := "tfplan" + planOutPath := filepath.Join(wp, planOutFile) + _, err = exe.Plan(ctx, planOutPath) + require.NoError(t, err, "terraform plan") + + plan, err := exe.ShowPlan(ctx, planOutPath) + require.NoError(t, err, "terraform show plan") + + pd, err := json.Marshal(plan) + require.NoError(t, err, "marshalling plan") + + err = os.WriteFile(filepath.Join(wp, "plan.json"), pd, 0644) + require.NoError(t, err, "writing plan.json") + + _, err = exe.Apply(ctx) + require.NoError(t, err, "terraform apply") + + state, err := exe.Show(ctx) + require.NoError(t, err, "terraform show") + + output, diags := preview.Preview(context.Background(), + preview.Input{ + PlanJSONPath: "plan.json", + ParameterValues: map[string]types.ParameterValue{}, + }, + os.DirFS(wp)) + if diags.HasErrors() { + t.Logf("diags: %s", diags) + } + require.False(t, diags.HasErrors(), "preview errors") + + if state.Values == nil { + t.Fatalf("state values are nil") + } + verify.Compare(t, output, state.Values.RootModule) + }) + } + }) + } +} diff --git a/testdata/test/main.tf b/testdata/README.md similarity index 100% rename from testdata/test/main.tf rename to testdata/README.md diff --git a/testdata/badparam/skipe2e b/testdata/badparam/skipe2e new file mode 100644 index 0000000..6ba48e8 --- /dev/null +++ b/testdata/badparam/skipe2e @@ -0,0 +1 @@ +will fail plan/apply \ No newline at end of file diff --git a/testdata/conditional/main.tf b/testdata/conditional/main.tf index 74b1a26..43dd995 100644 --- a/testdata/conditional/main.tf +++ b/testdata/conditional/main.tf @@ -55,9 +55,6 @@ data "coder_parameter" "compute" { description = "How much compute do you need?" type = "string" default = data.coder_parameter.project.value == "massive" ? "huge" : "small" - validation { - - } dynamic "option" { for_each = local.use_options diff --git a/testdata/dockerdata/main.tf b/testdata/dockerdata/main.tf index 3a1c28b..90ca055 100644 --- a/testdata/dockerdata/main.tf +++ b/testdata/dockerdata/main.tf @@ -26,32 +26,31 @@ data "coder_parameter" "example" { name = "Example" description = "An example parameter that has no purpose." type = "string" + default = data.docker_registry_image.ubuntu.sha256_digest option { name = "Ubuntu" description = data.docker_registry_image.ubuntu.name - value = try(data.docker_registry_image.ubuntu.sha256_digest, "??") + value = data.docker_registry_image.ubuntu.sha256_digest } option { name = "Centos" - description = docker_image.centos.name - value = try(docker_image.centos.repo_digest, "??") + description = data.docker_registry_image.centos.name + value = data.docker_registry_image.centos.sha256_digest } } data "coder_workspace_tags" "custom_workspace_tags" { tags = { - // If a value is required, you can do something like: - // try(docker_image.ubuntu.repo_digest, "default-value") "foo" = data.docker_registry_image.ubuntu.sha256_digest - "bar" = docker_image.centos.repo_digest + "bar" = data.docker_registry_image.centos.sha256_digest "qux" = "quux" } } # Pulls the image -resource "docker_image" "centos" { +data "docker_registry_image" "centos" { name = "centos:latest" } diff --git a/testdata/instancelist/skipe2e b/testdata/instancelist/skipe2e new file mode 100644 index 0000000..1ea4919 --- /dev/null +++ b/testdata/instancelist/skipe2e @@ -0,0 +1 @@ +requires aws credentials \ No newline at end of file diff --git a/testdata/nulldefault/skipe2e b/testdata/nulldefault/skipe2e new file mode 100644 index 0000000..e8bc361 --- /dev/null +++ b/testdata/nulldefault/skipe2e @@ -0,0 +1 @@ +No terraform to run. \ No newline at end of file diff --git a/testdata/test/vars.tfvars b/testdata/test/vars.tfvars deleted file mode 100644 index e69de29..0000000 diff --git a/types/parameter.go b/types/parameter.go index 4025534..0da2bd7 100644 --- a/types/parameter.go +++ b/types/parameter.go @@ -4,11 +4,34 @@ import ( "crypto/sha256" "encoding/json" "fmt" + "slices" + "strings" "github.com/zclconf/go-cty/cty" ) +const ( + BlockTypeParameter = "coder_parameter" + BlockTypeWorkspaceTag = "coder_workspace_tag" +) + +func SortParameters(lists []Parameter) { + slices.SortFunc(lists, func(a, b Parameter) int { + order := int(a.Order - b.Order) + if order != 0 { + return order + } + + if a.BlockName != b.BlockName { + return strings.Compare(a.BlockName, b.BlockName) + } + + return strings.Compare(a.Name, b.Name) + }) +} + type Parameter struct { + // TODO: Might be value in a "Lazy" parameter Value ParameterValue RichParameter } @@ -24,12 +47,12 @@ type RichParameter struct { Mutable bool `json:"mutable"` DefaultValue string `json:"default_value"` Icon string `json:"icon"` - Options []*RichParameterOption `json:"options"` - Validation *ParameterValidation `json:"validation"` + Options []*ParameterOption `json:"options"` + Validations []*ParameterValidation `json:"validations"` Required bool `json:"required"` // legacy_variable_name was removed (= 14) DisplayName string `json:"display_name"` - Order int32 `json:"order"` + Order int64 `json:"order"` Ephemeral bool `json:"ephemeral"` // HCL props @@ -39,12 +62,12 @@ type RichParameter struct { type ParameterValidation struct { Regex string `json:"validation_regex"` Error string `json:"validation_error"` - Min *int32 `json:"validation_min"` - Max *int32 `json:"validation_max"` + Min *int64 `json:"validation_min"` + Max *int64 `json:"validation_max"` Monotonic string `json:"validation_monotonic"` } -type RichParameterOption struct { +type ParameterOption struct { Name string `json:"name" hcl:"name,attr"` Description string `json:"description" hcl:"description,attr"` Value string `json:"value" hcl:"value,attr"` diff --git a/types/parameter_test.go b/types/parameter_test.go index cb6f6de..3d5b92c 100644 --- a/types/parameter_test.go +++ b/types/parameter_test.go @@ -38,7 +38,7 @@ func TestParameterEquality(t *testing.T) { func randomParameter(src *rand.ChaCha8) *types.RichParameter { ty := randomElement(src, "string", "number", "bool", "list(string)") - opts := make([]*types.RichParameterOption, randomInt(src, 0, 5)) + opts := make([]*types.ParameterOption, randomInt(src, 0, 5)) for i := range opts { opts[i] = randomParameterOption(src, ty) } @@ -79,8 +79,8 @@ func randomValidation(src *rand.ChaCha8, ty string) *types.ParameterValidation { } } -func randomParameterOption(src *rand.ChaCha8, ty string) *types.RichParameterOption { - return &types.RichParameterOption{ +func randomParameterOption(src *rand.ChaCha8, ty string) *types.ParameterOption { + return &types.ParameterOption{ Name: randomString(src, 10), Description: randomString(src, 20), Value: randomValue(src, ty),