diff --git a/config/hcl2_shim_util.go b/config/hcl2_shim_util.go new file mode 100644 index 000000000..f50bec3ee --- /dev/null +++ b/config/hcl2_shim_util.go @@ -0,0 +1,163 @@ +package config + +import ( + "fmt" + "math/big" + + hcl2 "github.com/hashicorp/hcl2/hcl" + "github.com/zclconf/go-cty/cty" +) + +// --------------------------------------------------------------------------- +// This file contains some helper functions that are used to shim between +// HCL2 concepts and HCL/HIL concepts, to help us mostly preserve the existing +// public API that was built around HCL/HIL-oriented approaches. +// --------------------------------------------------------------------------- + +// configValueFromHCL2 converts a value from HCL2 (really, from the cty dynamic +// types library that HCL2 uses) to a value type that matches what would've +// been produced from the HCL-based interpolator for an equivalent structure. +// +// This function will transform a cty null value into a Go nil value, which +// isn't a possible outcome of the HCL/HIL-based decoder and so callers may +// need to detect and reject any null values. +func configValueFromHCL2(v cty.Value) interface{} { + if !v.IsKnown() { + return UnknownVariableValue + } + if v.IsNull() { + return nil + } + + switch v.Type() { + case cty.Bool: + return v.True() // like HCL.BOOL + case cty.String: + return v.AsString() // like HCL token.STRING or token.HEREDOC + case cty.Number: + // We can't match HCL _exactly_ here because it distinguishes between + // int and float values, but we'll get as close as we can by using + // an int if the number is exactly representable, and a float if not. + // The conversion to float will force precision to that of a float64, + // which is potentially losing information from the specific number + // given, but no worse than what HCL would've done in its own conversion + // to float. + + f := v.AsBigFloat() + if i, acc := f.Int64(); acc == big.Exact { + // if we're on a 32-bit system and the number is too big for 32-bit + // int then we'll fall through here and use a float64. + const MaxInt = int(^uint(0) >> 1) + const MinInt = -MaxInt - 1 + if i <= int64(MaxInt) && i >= int64(MinInt) { + return int(i) // Like HCL token.NUMBER + } + } + + f64, _ := f.Float64() + return f64 // like HCL token.FLOAT + } + + if v.Type().IsListType() || v.Type().IsSetType() || v.Type().IsTupleType() { + l := make([]interface{}, 0, v.LengthInt()) + it := v.ElementIterator() + for it.Next() { + _, ev := it.Element() + l = append(l, configValueFromHCL2(ev)) + } + return l + } + + if v.Type().IsMapType() || v.Type().IsObjectType() { + l := make(map[string]interface{}) + it := v.ElementIterator() + for it.Next() { + ek, ev := it.Element() + l[ek.AsString()] = configValueFromHCL2(ev) + } + return l + } + + // If we fall out here then we have some weird type that we haven't + // accounted for. This should never happen unless the caller is using + // capsule types, and we don't currently have any such types defined. + panic(fmt.Errorf("can't convert %#v to config value", v)) +} + +// hcl2SingleAttrBody is a weird implementation of hcl2.Body that acts as if +// it has a single attribute whose value is the given expression. +// +// This is used to shim Resource.RawCount and Output.RawConfig to behave +// more like they do in the old HCL loader. +type hcl2SingleAttrBody struct { + Name string + Expr hcl2.Expression +} + +var _ hcl2.Body = hcl2SingleAttrBody{} + +func (b hcl2SingleAttrBody) Content(schema *hcl2.BodySchema) (*hcl2.BodyContent, hcl2.Diagnostics) { + content, all, diags := b.content(schema) + if !all { + // This should never happen because this body implementation should only + // be used by code that is aware that it's using a single-attr body. + diags = append(diags, &hcl2.Diagnostic{ + Severity: hcl2.DiagError, + Summary: "Invalid attribute", + Detail: fmt.Sprintf("The correct attribute name is %q.", b.Name), + Subject: b.Expr.Range().Ptr(), + }) + } + return content, diags +} + +func (b hcl2SingleAttrBody) PartialContent(schema *hcl2.BodySchema) (*hcl2.BodyContent, hcl2.Body, hcl2.Diagnostics) { + content, all, diags := b.content(schema) + var remain hcl2.Body + if all { + // If the request matched the one attribute we represent, then the + // remaining body is empty. + remain = hcl2.EmptyBody() + } else { + remain = b + } + return content, remain, diags +} + +func (b hcl2SingleAttrBody) content(schema *hcl2.BodySchema) (*hcl2.BodyContent, bool, hcl2.Diagnostics) { + ret := &hcl2.BodyContent{} + all := false + var diags hcl2.Diagnostics + + for _, attrS := range schema.Attributes { + if attrS.Name == b.Name { + attrs, _ := b.JustAttributes() + ret.Attributes = attrs + all = true + } else if attrS.Required { + diags = append(diags, &hcl2.Diagnostic{ + Severity: hcl2.DiagError, + Summary: "Missing attribute", + Detail: fmt.Sprintf("The attribute %q is required.", attrS.Name), + Subject: b.Expr.Range().Ptr(), + }) + } + } + + return ret, all, diags +} + +func (b hcl2SingleAttrBody) JustAttributes() (hcl2.Attributes, hcl2.Diagnostics) { + return hcl2.Attributes{ + b.Name: { + Expr: b.Expr, + Name: b.Name, + NameRange: b.Expr.Range(), + Range: b.Expr.Range(), + }, + }, nil +} + +func (b hcl2SingleAttrBody) MissingItemRange() hcl2.Range { + return b.Expr.Range() +} diff --git a/config/hcl2_shim_util_test.go b/config/hcl2_shim_util_test.go new file mode 100644 index 000000000..1ec1e6174 --- /dev/null +++ b/config/hcl2_shim_util_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "fmt" + "reflect" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestConfigValueFromHCL2(t *testing.T) { + tests := []struct { + Input cty.Value + Want interface{} + }{ + { + cty.True, + true, + }, + { + cty.False, + false, + }, + { + cty.NumberIntVal(12), + int(12), + }, + { + cty.NumberFloatVal(12.5), + float64(12.5), + }, + { + cty.StringVal("hello world"), + "hello world", + }, + { + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Ermintrude"), + "age": cty.NumberIntVal(19), + "address": cty.ObjectVal(map[string]cty.Value{ + "street": cty.ListVal([]cty.Value{cty.StringVal("421 Shoreham Loop")}), + "city": cty.StringVal("Fridgewater"), + "state": cty.StringVal("MA"), + "zip": cty.StringVal("91037"), + }), + }), + map[string]interface{}{ + "name": "Ermintrude", + "age": int(19), + "address": map[string]interface{}{ + "street": []interface{}{"421 Shoreham Loop"}, + "city": "Fridgewater", + "state": "MA", + "zip": "91037", + }, + }, + }, + { + cty.MapVal(map[string]cty.Value{ + "foo": cty.StringVal("bar"), + "bar": cty.StringVal("baz"), + }), + map[string]interface{}{ + "foo": "bar", + "bar": "baz", + }, + }, + { + cty.TupleVal([]cty.Value{ + cty.StringVal("foo"), + cty.True, + }), + []interface{}{ + "foo", + true, + }, + }, + { + cty.NullVal(cty.String), + nil, + }, + { + cty.UnknownVal(cty.String), + UnknownVariableValue, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%#v", test.Input), func(t *testing.T) { + got := configValueFromHCL2(test.Input) + if !reflect.DeepEqual(got, test.Want) { + t.Errorf("wrong result\ninput: %#v\ngot: %#v\nwant: %#v", test.Input, got, test.Want) + } + }) + } +} diff --git a/config/loader_hcl2.go b/config/loader_hcl2.go new file mode 100644 index 000000000..ced193bb5 --- /dev/null +++ b/config/loader_hcl2.go @@ -0,0 +1,461 @@ +package config + +import ( + "fmt" + "sort" + "strings" + + gohcl2 "github.com/hashicorp/hcl2/gohcl" + hcl2 "github.com/hashicorp/hcl2/hcl" + hcl2parse "github.com/hashicorp/hcl2/hclparse" + "github.com/zclconf/go-cty/cty" +) + +// hcl2Configurable is an implementation of configurable that knows +// how to turn a HCL Body into a *Config object. +type hcl2Configurable struct { + SourceFilename string + Body hcl2.Body +} + +// hcl2Loader is a wrapper around a HCL parser that provides a fileLoaderFunc. +type hcl2Loader struct { + Parser *hcl2parse.Parser +} + +// For the moment we'll just have a global loader since we don't have anywhere +// better to stash this. +// TODO: refactor the loader API so that it uses some sort of object we can +// stash the parser inside. +var globalHCL2Loader = newHCL2Loader() + +// newHCL2Loader creates a new hcl2Loader containing a new HCL Parser. +// +// HCL parsers retain information about files that are loaded to aid in +// producing diagnostic messages, so all files within a single configuration +// should be loaded with the same parser to ensure the availability of +// full diagnostic information. +func newHCL2Loader() hcl2Loader { + return hcl2Loader{ + Parser: hcl2parse.NewParser(), + } +} + +// loadFile is a fileLoaderFunc that knows how to read a HCL2 file and turn it +// into a hcl2Configurable. +func (l hcl2Loader) loadFile(filename string) (configurable, []string, error) { + var f *hcl2.File + var diags hcl2.Diagnostics + if strings.HasSuffix(filename, ".json") { + f, diags = l.Parser.ParseJSONFile(filename) + } else { + f, diags = l.Parser.ParseHCLFile(filename) + } + if diags.HasErrors() { + // Return diagnostics as an error; callers may type-assert this to + // recover the original diagnostics, if it doesn't end up wrapped + // in another error. + return nil, nil, diags + } + + return &hcl2Configurable{ + SourceFilename: filename, + Body: f.Body, + }, nil, nil +} + +func (t *hcl2Configurable) Config() (*Config, error) { + config := &Config{} + + // these structs are used only for the initial shallow decoding; we'll + // expand this into the main, public-facing config structs afterwards. + type atlas struct { + Name string `hcl:"name"` + Include *[]string `hcl:"include"` + Exclude *[]string `hcl:"exclude"` + } + type module struct { + Name string `hcl:"name,label"` + Source string `hcl:"source,attr"` + Config hcl2.Body `hcl:",remain"` + } + type provider struct { + Name string `hcl:"name,label"` + Alias *string `hcl:"alias,attr"` + Version *string `hcl:"version,attr"` + Config hcl2.Body `hcl:",remain"` + } + type resourceLifecycle struct { + CreateBeforeDestroy *bool `hcl:"create_before_destroy,attr"` + PreventDestroy *bool `hcl:"prevent_destroy,attr"` + IgnoreChanges *[]string `hcl:"ignore_changes,attr"` + } + type connection struct { + Config hcl2.Body `hcl:",remain"` + } + type provisioner struct { + Type string `hcl:"type,label"` + + When *string `hcl:"when,attr"` + OnFailure *string `hcl:"on_failure,attr"` + + Connection *connection `hcl:"connection,block"` + Config hcl2.Body `hcl:",remain"` + } + type managedResource struct { + Type string `hcl:"type,label"` + Name string `hcl:"name,label"` + + CountExpr hcl2.Expression `hcl:"count,attr"` + Provider *string `hcl:"provider,attr"` + DependsOn *[]string `hcl:"depends_on,attr"` + + Lifecycle *resourceLifecycle `hcl:"lifecycle,block"` + Provisioners []provisioner `hcl:"provisioner,block"` + Connection *connection `hcl:"connection,block"` + + Config hcl2.Body `hcl:",remain"` + } + type dataResource struct { + Type string `hcl:"type,label"` + Name string `hcl:"name,label"` + + CountExpr hcl2.Expression `hcl:"count,attr"` + Provider *string `hcl:"provider,attr"` + DependsOn *[]string `hcl:"depends_on,attr"` + + Config hcl2.Body `hcl:",remain"` + } + type variable struct { + Name string `hcl:"name,label"` + + DeclaredType *string `hcl:"type,attr"` + Default *cty.Value `hcl:"default,attr"` + Description *string `hcl:"description,attr"` + Sensitive *bool `hcl:"sensitive,attr"` + } + type output struct { + Name string `hcl:"name,label"` + + ValueExpr hcl2.Expression `hcl:"value,attr"` + DependsOn *[]string `hcl:"depends_on,attr"` + Description *string `hcl:"description,attr"` + Sensitive *bool `hcl:"sensitive,attr"` + } + type locals struct { + Definitions hcl2.Attributes `hcl:",remain"` + } + type backend struct { + Type string `hcl:"type,label"` + Config hcl2.Body `hcl:",remain"` + } + type terraform struct { + RequiredVersion *string `hcl:"required_version,attr"` + Backend *backend `hcl:"backend,block"` + } + type topLevel struct { + Atlas *atlas `hcl:"atlas,block"` + Datas []dataResource `hcl:"data,block"` + Modules []module `hcl:"module,block"` + Outputs []output `hcl:"output,block"` + Providers []provider `hcl:"provider,block"` + Resources []managedResource `hcl:"resource,block"` + Terraform *terraform `hcl:"terraform,block"` + Variables []variable `hcl:"variable,block"` + Locals []*locals `hcl:"locals,block"` + } + + var raw topLevel + diags := gohcl2.DecodeBody(t.Body, nil, &raw) + if diags.HasErrors() { + // Do some minimal decoding to see if we can at least get the + // required Terraform version, which might help explain why we + // couldn't parse the rest. + if raw.Terraform != nil && raw.Terraform.RequiredVersion != nil { + config.Terraform = &Terraform{ + RequiredVersion: *raw.Terraform.RequiredVersion, + } + } + + // We return the diags as an implementation of error, which the + // caller than then type-assert if desired to recover the individual + // diagnostics. + // FIXME: The current API gives us no way to return warnings in the + // absense of any errors. + return config, diags + } + + if raw.Terraform != nil { + var reqdVersion string + var backend *Backend + + if raw.Terraform.RequiredVersion != nil { + reqdVersion = *raw.Terraform.RequiredVersion + } + if raw.Terraform.Backend != nil { + backend = new(Backend) + backend.Type = raw.Terraform.Backend.Type + + // We don't permit interpolations or nested blocks inside the + // backend config, so we can decode the config early here and + // get direct access to the values, which is important for the + // config hashing to work as expected. + var config map[string]string + configDiags := gohcl2.DecodeBody(raw.Terraform.Backend.Config, nil, &config) + diags = append(diags, configDiags...) + + raw := make(map[string]interface{}, len(config)) + for k, v := range config { + raw[k] = v + } + + var err error + backend.RawConfig, err = NewRawConfig(raw) + if err != nil { + diags = append(diags, &hcl2.Diagnostic{ + Severity: hcl2.DiagError, + Summary: "Invalid backend configuration", + Detail: fmt.Sprintf("Error in backend configuration: %s", err), + }) + } + } + + config.Terraform = &Terraform{ + RequiredVersion: reqdVersion, + Backend: backend, + } + } + + if raw.Atlas != nil { + var include, exclude []string + if raw.Atlas.Include != nil { + include = *raw.Atlas.Include + } + if raw.Atlas.Exclude != nil { + exclude = *raw.Atlas.Exclude + } + config.Atlas = &AtlasConfig{ + Name: raw.Atlas.Name, + Include: include, + Exclude: exclude, + } + } + + for _, rawM := range raw.Modules { + m := &Module{ + Name: rawM.Name, + Source: rawM.Source, + RawConfig: NewRawConfigHCL2(rawM.Config), + } + config.Modules = append(config.Modules, m) + } + + for _, rawV := range raw.Variables { + v := &Variable{ + Name: rawV.Name, + } + if rawV.DeclaredType != nil { + v.DeclaredType = *rawV.DeclaredType + } + if rawV.Default != nil { + v.Default = configValueFromHCL2(*rawV.Default) + } + if rawV.Description != nil { + v.Description = *rawV.Description + } + + config.Variables = append(config.Variables, v) + } + + for _, rawO := range raw.Outputs { + o := &Output{ + Name: rawO.Name, + } + + if rawO.Description != nil { + o.Description = *rawO.Description + } + if rawO.DependsOn != nil { + o.DependsOn = *rawO.DependsOn + } + if rawO.Sensitive != nil { + o.Sensitive = *rawO.Sensitive + } + + // The result is expected to be a map like map[string]interface{}{"value": something}, + // so we'll fake that with our hcl2SingleAttrBody shim. + o.RawConfig = NewRawConfigHCL2(hcl2SingleAttrBody{ + Name: "value", + Expr: rawO.ValueExpr, + }) + + config.Outputs = append(config.Outputs, o) + } + + for _, rawR := range raw.Resources { + r := &Resource{ + Mode: ManagedResourceMode, + Type: rawR.Type, + Name: rawR.Name, + } + if rawR.Lifecycle != nil { + var l ResourceLifecycle + if rawR.Lifecycle.CreateBeforeDestroy != nil { + l.CreateBeforeDestroy = *rawR.Lifecycle.CreateBeforeDestroy + } + if rawR.Lifecycle.PreventDestroy != nil { + l.PreventDestroy = *rawR.Lifecycle.PreventDestroy + } + if rawR.Lifecycle.IgnoreChanges != nil { + l.IgnoreChanges = *rawR.Lifecycle.IgnoreChanges + } + r.Lifecycle = l + } + if rawR.Provider != nil { + r.Provider = *rawR.Provider + } + if rawR.DependsOn != nil { + r.DependsOn = *rawR.DependsOn + } + + var defaultConnInfo *RawConfig + if rawR.Connection != nil { + defaultConnInfo = NewRawConfigHCL2(rawR.Connection.Config) + } + + for _, rawP := range rawR.Provisioners { + p := &Provisioner{ + Type: rawP.Type, + } + + switch { + case rawP.When == nil: + p.When = ProvisionerWhenCreate + case *rawP.When == "create": + p.When = ProvisionerWhenCreate + case *rawP.When == "destroy": + p.When = ProvisionerWhenDestroy + default: + p.When = ProvisionerWhenInvalid + } + + switch { + case rawP.OnFailure == nil: + p.OnFailure = ProvisionerOnFailureFail + case *rawP.When == "fail": + p.OnFailure = ProvisionerOnFailureFail + case *rawP.When == "continue": + p.OnFailure = ProvisionerOnFailureContinue + default: + p.OnFailure = ProvisionerOnFailureInvalid + } + + if rawP.Connection != nil { + p.ConnInfo = NewRawConfigHCL2(rawP.Connection.Config) + } else { + p.ConnInfo = defaultConnInfo + } + + p.RawConfig = NewRawConfigHCL2(rawP.Config) + + r.Provisioners = append(r.Provisioners, p) + } + + // The old loader records the count expression as a weird RawConfig with + // a single-element map inside. Since the rest of the world is assuming + // that, we'll mimic it here. + { + countBody := hcl2SingleAttrBody{ + Name: "count", + Expr: rawR.CountExpr, + } + + r.RawCount = NewRawConfigHCL2(countBody) + r.RawCount.Key = "count" + } + + r.RawConfig = NewRawConfigHCL2(rawR.Config) + + config.Resources = append(config.Resources, r) + + } + + for _, rawR := range raw.Datas { + r := &Resource{ + Mode: DataResourceMode, + Type: rawR.Type, + Name: rawR.Name, + } + + if rawR.Provider != nil { + r.Provider = *rawR.Provider + } + if rawR.DependsOn != nil { + r.DependsOn = *rawR.DependsOn + } + + // The old loader records the count expression as a weird RawConfig with + // a single-element map inside. Since the rest of the world is assuming + // that, we'll mimic it here. + { + countBody := hcl2SingleAttrBody{ + Name: "count", + Expr: rawR.CountExpr, + } + + r.RawCount = NewRawConfigHCL2(countBody) + r.RawCount.Key = "count" + } + + r.RawConfig = NewRawConfigHCL2(rawR.Config) + + config.Resources = append(config.Resources, r) + } + + for _, rawP := range raw.Providers { + p := &ProviderConfig{ + Name: rawP.Name, + } + + if rawP.Alias != nil { + p.Alias = *rawP.Alias + } + if rawP.Version != nil { + p.Version = *rawP.Version + } + + // The result is expected to be a map like map[string]interface{}{"value": something}, + // so we'll fake that with our hcl2SingleAttrBody shim. + p.RawConfig = NewRawConfigHCL2(rawP.Config) + + config.ProviderConfigs = append(config.ProviderConfigs, p) + } + + for _, rawL := range raw.Locals { + names := make([]string, 0, len(rawL.Definitions)) + for n := range rawL.Definitions { + names = append(names, n) + } + sort.Strings(names) + for _, n := range names { + attr := rawL.Definitions[n] + l := &Local{ + Name: n, + RawConfig: NewRawConfigHCL2(hcl2SingleAttrBody{ + Name: "value", + Expr: attr.Expr, + }), + } + config.Locals = append(config.Locals, l) + } + } + + // FIXME: The current API gives us no way to return warnings in the + // absense of any errors. + var err error + if diags.HasErrors() { + err = diags + } + + return config, err +} diff --git a/config/loader_hcl2_test.go b/config/loader_hcl2_test.go new file mode 100644 index 000000000..feb530094 --- /dev/null +++ b/config/loader_hcl2_test.go @@ -0,0 +1,510 @@ +package config + +import ( + "reflect" + "testing" + + "github.com/zclconf/go-cty/cty" + + gohcl2 "github.com/hashicorp/hcl2/gohcl" + hcl2 "github.com/hashicorp/hcl2/hcl" +) + +func TestHCL2ConfigurableConfigurable(t *testing.T) { + var _ configurable = new(hcl2Configurable) +} + +func TestHCL2Basic(t *testing.T) { + loader := globalHCL2Loader + cbl, _, err := loader.loadFile("test-fixtures/basic-hcl2.tf") + if err != nil { + if diags, isDiags := err.(hcl2.Diagnostics); isDiags { + for _, diag := range diags { + t.Logf("- %s", diag.Error()) + } + t.Fatalf("unexpected diagnostics in load") + } else { + t.Fatalf("unexpected error in load: %s", err) + } + } + + cfg, err := cbl.Config() + if err != nil { + if diags, isDiags := err.(hcl2.Diagnostics); isDiags { + for _, diag := range diags { + t.Logf("- %s", diag.Error()) + } + t.Fatalf("unexpected diagnostics in decode") + } else { + t.Fatalf("unexpected error in decode: %s", err) + } + } + + // Unfortunately the config structure isn't DeepEqual-friendly because + // of all the nested RawConfig, etc structures, so we'll need to + // hand-assert each item. + + // The "terraform" block + if cfg.Terraform == nil { + t.Fatalf("Terraform field is nil") + } + if got, want := cfg.Terraform.RequiredVersion, "foo"; got != want { + t.Errorf("wrong Terraform.RequiredVersion %q; want %q", got, want) + } + if cfg.Terraform.Backend == nil { + t.Fatalf("Terraform.Backend is nil") + } + if got, want := cfg.Terraform.Backend.Type, "baz"; got != want { + t.Errorf("wrong Terraform.Backend.Type %q; want %q", got, want) + } + if got, want := cfg.Terraform.Backend.RawConfig.Raw, map[string]interface{}{"something": "nothing"}; !reflect.DeepEqual(got, want) { + t.Errorf("wrong Terraform.Backend.RawConfig.Raw %#v; want %#v", got, want) + } + + // The "atlas" block + if cfg.Atlas == nil { + t.Fatalf("Atlas field is nil") + } + if got, want := cfg.Atlas.Name, "example/foo"; got != want { + t.Errorf("wrong Atlas.Name %q; want %q", got, want) + } + + // "module" blocks + if got, want := len(cfg.Modules), 1; got != want { + t.Errorf("Modules slice has wrong length %#v; want %#v", got, want) + } else { + m := cfg.Modules[0] + if got, want := m.Name, "child"; got != want { + t.Errorf("wrong Modules[0].Name %#v; want %#v", got, want) + } + if got, want := m.Source, "./baz"; got != want { + t.Errorf("wrong Modules[0].Source %#v; want %#v", got, want) + } + want := map[string]string{"toasty": "true"} + var got map[string]string + gohcl2.DecodeBody(m.RawConfig.Body, nil, &got) + if !reflect.DeepEqual(got, want) { + t.Errorf("wrong Modules[0].RawConfig.Body %#v; want %#v", got, want) + } + } + + // "resource" blocks + if got, want := len(cfg.Resources), 5; got != want { + t.Errorf("Resources slice has wrong length %#v; want %#v", got, want) + } else { + { + r := cfg.Resources[0] + + if got, want := r.Id(), "aws_security_group.firewall"; got != want { + t.Errorf("wrong Resources[0].Id() %#v; want %#v", got, want) + } + + wantConfig := map[string]string{} + var gotConfig map[string]string + gohcl2.DecodeBody(r.RawConfig.Body, nil, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Resources[0].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + + wantCount := map[string]string{"count": "5"} + var gotCount map[string]string + gohcl2.DecodeBody(r.RawCount.Body, nil, &gotCount) + if !reflect.DeepEqual(gotCount, wantCount) { + t.Errorf("wrong Resources[0].RawCount.Body %#v; want %#v", gotCount, wantCount) + } + if got, want := r.RawCount.Key, "count"; got != want { + t.Errorf("wrong Resources[0].RawCount.Key %#v; want %#v", got, want) + } + + if got, want := len(r.Provisioners), 0; got != want { + t.Errorf("wrong Resources[0].Provisioners length %#v; want %#v", got, want) + } + if got, want := len(r.DependsOn), 0; got != want { + t.Errorf("wrong Resources[0].DependsOn length %#v; want %#v", got, want) + } + if got, want := r.Provider, "another"; got != want { + t.Errorf("wrong Resources[0].Provider %#v; want %#v", got, want) + } + if got, want := r.Lifecycle, (ResourceLifecycle{}); !reflect.DeepEqual(got, want) { + t.Errorf("wrong Resources[0].Lifecycle %#v; want %#v", got, want) + } + } + { + r := cfg.Resources[1] + + if got, want := r.Id(), "aws_instance.web"; got != want { + t.Errorf("wrong Resources[1].Id() %#v; want %#v", got, want) + } + if got, want := r.Provider, ""; got != want { + t.Errorf("wrong Resources[1].Provider %#v; want %#v", got, want) + } + + if got, want := len(r.Provisioners), 1; got != want { + t.Errorf("wrong Resources[1].Provisioners length %#v; want %#v", got, want) + } else { + p := r.Provisioners[0] + + if got, want := p.Type, "file"; got != want { + t.Errorf("wrong Resources[1].Provisioners[0].Type %#v; want %#v", got, want) + } + + wantConfig := map[string]string{ + "source": "foo", + "destination": "bar", + } + var gotConfig map[string]string + gohcl2.DecodeBody(p.RawConfig.Body, nil, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Resources[1].Provisioners[0].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + + wantConn := map[string]string{ + "default": "true", + } + var gotConn map[string]string + gohcl2.DecodeBody(p.ConnInfo.Body, nil, &gotConn) + if !reflect.DeepEqual(gotConn, wantConn) { + t.Errorf("wrong Resources[1].Provisioners[0].ConnInfo.Body %#v; want %#v", gotConn, wantConn) + } + } + + // We'll use these throwaway structs to more easily decode and + // compare the main config body. + type instanceNetworkInterface struct { + DeviceIndex int `hcl:"device_index"` + Description string `hcl:"description"` + } + type instanceConfig struct { + AMI string `hcl:"ami"` + SecurityGroups []string `hcl:"security_groups"` + NetworkInterface instanceNetworkInterface `hcl:"network_interface,block"` + } + var gotConfig instanceConfig + wantConfig := instanceConfig{ + AMI: "ami-abc123", + SecurityGroups: []string{"foo", "sg-firewall"}, + NetworkInterface: instanceNetworkInterface{ + DeviceIndex: 0, + Description: "Main network interface", + }, + } + ctx := &hcl2.EvalContext{ + Variables: map[string]cty.Value{ + "var": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("ami-abc123"), + }), + "aws_security_group": cty.ObjectVal(map[string]cty.Value{ + "firewall": cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("sg-firewall"), + }), + }), + }, + } + diags := gohcl2.DecodeBody(r.RawConfig.Body, ctx, &gotConfig) + if len(diags) != 0 { + t.Errorf("unexpected diagnostics decoding Resources[1].RawConfig.Body") + for _, diag := range diags { + t.Logf("- %s", diag.Error()) + } + } + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Resources[1].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + + } + { + r := cfg.Resources[2] + + if got, want := r.Id(), "aws_instance.db"; got != want { + t.Errorf("wrong Resources[2].Id() %#v; want %#v", got, want) + } + if got, want := r.DependsOn, []string{"aws_instance.web"}; !reflect.DeepEqual(got, want) { + t.Errorf("wrong Resources[2].DependsOn %#v; want %#v", got, want) + } + + if got, want := len(r.Provisioners), 1; got != want { + t.Errorf("wrong Resources[2].Provisioners length %#v; want %#v", got, want) + } else { + p := r.Provisioners[0] + + if got, want := p.Type, "file"; got != want { + t.Errorf("wrong Resources[2].Provisioners[0].Type %#v; want %#v", got, want) + } + + wantConfig := map[string]string{ + "source": "here", + "destination": "there", + } + var gotConfig map[string]string + gohcl2.DecodeBody(p.RawConfig.Body, nil, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Resources[2].Provisioners[0].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + + wantConn := map[string]string{ + "default": "false", + } + var gotConn map[string]string + gohcl2.DecodeBody(p.ConnInfo.Body, nil, &gotConn) + if !reflect.DeepEqual(gotConn, wantConn) { + t.Errorf("wrong Resources[2].Provisioners[0].ConnInfo.Body %#v; want %#v", gotConn, wantConn) + } + } + } + { + r := cfg.Resources[3] + + if got, want := r.Id(), "data.do.simple"; got != want { + t.Errorf("wrong Resources[3].Id() %#v; want %#v", got, want) + } + if got, want := r.DependsOn, []string(nil); !reflect.DeepEqual(got, want) { + t.Errorf("wrong Resources[3].DependsOn %#v; want %#v", got, want) + } + if got, want := r.Provider, "do.foo"; got != want { + t.Errorf("wrong Resources[3].Provider %#v; want %#v", got, want) + } + + wantConfig := map[string]string{ + "foo": "baz", + } + var gotConfig map[string]string + gohcl2.DecodeBody(r.RawConfig.Body, nil, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Resources[3].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + } + { + r := cfg.Resources[4] + + if got, want := r.Id(), "data.do.depends"; got != want { + t.Errorf("wrong Resources[4].Id() %#v; want %#v", got, want) + } + if got, want := r.DependsOn, []string{"data.do.simple"}; !reflect.DeepEqual(got, want) { + t.Errorf("wrong Resources[4].DependsOn %#v; want %#v", got, want) + } + if got, want := r.Provider, ""; got != want { + t.Errorf("wrong Resources[4].Provider %#v; want %#v", got, want) + } + + wantConfig := map[string]string{} + var gotConfig map[string]string + gohcl2.DecodeBody(r.RawConfig.Body, nil, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Resources[4].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + } + } + + // "variable" blocks + if got, want := len(cfg.Variables), 3; got != want { + t.Errorf("Variables slice has wrong length %#v; want %#v", got, want) + } else { + { + v := cfg.Variables[0] + + if got, want := v.Name, "foo"; got != want { + t.Errorf("wrong Variables[0].Name %#v; want %#v", got, want) + } + if got, want := v.Default, "bar"; got != want { + t.Errorf("wrong Variables[0].Default %#v; want %#v", got, want) + } + if got, want := v.Description, "barbar"; got != want { + t.Errorf("wrong Variables[0].Description %#v; want %#v", got, want) + } + if got, want := v.DeclaredType, ""; got != want { + t.Errorf("wrong Variables[0].DeclaredType %#v; want %#v", got, want) + } + } + { + v := cfg.Variables[1] + + if got, want := v.Name, "bar"; got != want { + t.Errorf("wrong Variables[1].Name %#v; want %#v", got, want) + } + if got, want := v.Default, interface{}(nil); got != want { + t.Errorf("wrong Variables[1].Default %#v; want %#v", got, want) + } + if got, want := v.Description, ""; got != want { + t.Errorf("wrong Variables[1].Description %#v; want %#v", got, want) + } + if got, want := v.DeclaredType, "string"; got != want { + t.Errorf("wrong Variables[1].DeclaredType %#v; want %#v", got, want) + } + } + { + v := cfg.Variables[2] + + if got, want := v.Name, "baz"; got != want { + t.Errorf("wrong Variables[2].Name %#v; want %#v", got, want) + } + if got, want := v.Default, map[string]interface{}{"key": "value"}; !reflect.DeepEqual(got, want) { + t.Errorf("wrong Variables[2].Default %#v; want %#v", got, want) + } + if got, want := v.Description, ""; got != want { + t.Errorf("wrong Variables[2].Description %#v; want %#v", got, want) + } + if got, want := v.DeclaredType, "map"; got != want { + t.Errorf("wrong Variables[2].DeclaredType %#v; want %#v", got, want) + } + } + } + + // "output" blocks + if got, want := len(cfg.Outputs), 2; got != want { + t.Errorf("Outputs slice has wrong length %#v; want %#v", got, want) + } else { + { + o := cfg.Outputs[0] + + if got, want := o.Name, "web_ip"; got != want { + t.Errorf("wrong Outputs[0].Name %#v; want %#v", got, want) + } + if got, want := o.DependsOn, []string(nil); !reflect.DeepEqual(got, want) { + t.Errorf("wrong Outputs[0].DependsOn %#v; want %#v", got, want) + } + if got, want := o.Description, ""; got != want { + t.Errorf("wrong Outputs[0].Description %#v; want %#v", got, want) + } + if got, want := o.Sensitive, true; got != want { + t.Errorf("wrong Outputs[0].Sensitive %#v; want %#v", got, want) + } + + wantConfig := map[string]string{ + "value": "312.213.645.123", + } + var gotConfig map[string]string + ctx := &hcl2.EvalContext{ + Variables: map[string]cty.Value{ + "aws_instance": cty.ObjectVal(map[string]cty.Value{ + "web": cty.ObjectVal(map[string]cty.Value{ + "private_ip": cty.StringVal("312.213.645.123"), + }), + }), + }, + } + gohcl2.DecodeBody(o.RawConfig.Body, ctx, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Outputs[0].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + } + { + o := cfg.Outputs[1] + + if got, want := o.Name, "web_id"; got != want { + t.Errorf("wrong Outputs[1].Name %#v; want %#v", got, want) + } + if got, want := o.DependsOn, []string{"aws_instance.db"}; !reflect.DeepEqual(got, want) { + t.Errorf("wrong Outputs[1].DependsOn %#v; want %#v", got, want) + } + if got, want := o.Description, "The ID"; got != want { + t.Errorf("wrong Outputs[1].Description %#v; want %#v", got, want) + } + if got, want := o.Sensitive, false; got != want { + t.Errorf("wrong Outputs[1].Sensitive %#v; want %#v", got, want) + } + } + } + + // "provider" blocks + if got, want := len(cfg.ProviderConfigs), 2; got != want { + t.Errorf("ProviderConfigs slice has wrong length %#v; want %#v", got, want) + } else { + { + p := cfg.ProviderConfigs[0] + + if got, want := p.Name, "aws"; got != want { + t.Errorf("wrong ProviderConfigs[0].Name %#v; want %#v", got, want) + } + if got, want := p.Alias, ""; got != want { + t.Errorf("wrong ProviderConfigs[0].Alias %#v; want %#v", got, want) + } + if got, want := p.Version, "1.0.0"; got != want { + t.Errorf("wrong ProviderConfigs[0].Version %#v; want %#v", got, want) + } + + wantConfig := map[string]string{ + "access_key": "foo", + "secret_key": "bar", + } + var gotConfig map[string]string + gohcl2.DecodeBody(p.RawConfig.Body, nil, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong ProviderConfigs[0].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + + } + { + p := cfg.ProviderConfigs[1] + + if got, want := p.Name, "do"; got != want { + t.Errorf("wrong ProviderConfigs[1].Name %#v; want %#v", got, want) + } + if got, want := p.Alias, "fum"; got != want { + t.Errorf("wrong ProviderConfigs[1].Alias %#v; want %#v", got, want) + } + if got, want := p.Version, ""; got != want { + t.Errorf("wrong ProviderConfigs[1].Version %#v; want %#v", got, want) + } + + } + } + + // "locals" definitions + if got, want := len(cfg.Locals), 5; got != want { + t.Errorf("Locals slice has wrong length %#v; want %#v", got, want) + } else { + { + l := cfg.Locals[0] + + if got, want := l.Name, "security_group_ids"; got != want { + t.Errorf("wrong Locals[0].Name %#v; want %#v", got, want) + } + + wantConfig := map[string][]string{ + "value": []string{"sg-abc123"}, + } + var gotConfig map[string][]string + ctx := &hcl2.EvalContext{ + Variables: map[string]cty.Value{ + "aws_security_group": cty.ObjectVal(map[string]cty.Value{ + "firewall": cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("sg-abc123"), + }), + }), + }, + } + gohcl2.DecodeBody(l.RawConfig.Body, ctx, &gotConfig) + if !reflect.DeepEqual(gotConfig, wantConfig) { + t.Errorf("wrong Locals[0].RawConfig.Body %#v; want %#v", gotConfig, wantConfig) + } + } + { + l := cfg.Locals[1] + + if got, want := l.Name, "web_ip"; got != want { + t.Errorf("wrong Locals[1].Name %#v; want %#v", got, want) + } + } + { + l := cfg.Locals[2] + + if got, want := l.Name, "literal"; got != want { + t.Errorf("wrong Locals[2].Name %#v; want %#v", got, want) + } + } + { + l := cfg.Locals[3] + + if got, want := l.Name, "literal_list"; got != want { + t.Errorf("wrong Locals[3].Name %#v; want %#v", got, want) + } + } + { + l := cfg.Locals[4] + + if got, want := l.Name, "literal_map"; got != want { + t.Errorf("wrong Locals[4].Name %#v; want %#v", got, want) + } + } + } +} diff --git a/config/test-fixtures/basic-hcl2.tf b/config/test-fixtures/basic-hcl2.tf new file mode 100644 index 000000000..0bd8f5334 --- /dev/null +++ b/config/test-fixtures/basic-hcl2.tf @@ -0,0 +1,125 @@ +#terraform:hcl2 + +terraform { + required_version = "foo" + + backend "baz" { + something = "nothing" + } +} + +variable "foo" { + default = "bar" + description = "barbar" +} + +variable "bar" { + type = "string" +} + +variable "baz" { + type = "map" + + default = { + key = "value" + } +} + +provider "aws" { + access_key = "foo" + secret_key = "bar" + version = "1.0.0" +} + +provider "do" { + api_key = var.foo + alias = "fum" +} + +data "do" "simple" { + foo = "baz" + provider = "do.foo" +} + +data "do" "depends" { + depends_on = ["data.do.simple"] +} + +resource "aws_security_group" "firewall" { + count = 5 + provider = "another" +} + +resource "aws_instance" "web" { + ami = "${var.foo}" + security_groups = [ + "foo", + aws_security_group.firewall.foo, + ] + + network_interface { + device_index = 0 + description = "Main network interface" + } + + connection { + default = true + } + + provisioner "file" { + source = "foo" + destination = "bar" + } +} + +locals { + security_group_ids = aws_security_group.firewall.*.id + web_ip = aws_instance.web.private_ip +} + +locals { + literal = 2 + literal_list = ["foo"] + literal_map = {"foo" = "bar"} +} + +resource "aws_instance" "db" { + security_groups = aws_security_group.firewall.*.id + VPC = "foo" + + tags = { + Name = "${var.bar}-database" + } + + depends_on = ["aws_instance.web"] + + provisioner "file" { + source = "here" + destination = "there" + + connection { + default = false + } + } +} + +output "web_ip" { + value = aws_instance.web.private_ip + sensitive = true +} + +output "web_id" { + description = "The ID" + value = aws_instance.web.id + depends_on = ["aws_instance.db"] +} + +atlas { + name = "example/foo" +} + +module "child" { + source = "./baz" + + toasty = true +}