From b0215fcd0facaf4c14656f150f210c5a245a73f1 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 27 Sep 2017 18:47:08 -0700 Subject: [PATCH] config: HCL2 config loader This loader uses the HCL2 parser and decoder to process a config file, and then transforms the result into the same shape as would be produced by the HCL config loader. To avoid making changes to the existing config structures (which are depended on across much of the codebase) we first decode into a set of HCL2-tailored structs and then process them into the public-facing structs that a loader is expected to return. This is a compromise to keep the config package API broadly unchanged for now. Once we're ready to remove the old HCL loader (which implies that we're ready to support HCL2 natively elsewhere in the codebase) we will be able to simplify this quite considerably. Due to some mismatches of abstraction between HCL/HIL and HCL2, some shimming is required to get the required result. --- config/hcl2_shim_util.go | 163 +++++++++ config/hcl2_shim_util_test.go | 96 ++++++ config/loader_hcl2.go | 461 ++++++++++++++++++++++++++ config/loader_hcl2_test.go | 510 +++++++++++++++++++++++++++++ config/test-fixtures/basic-hcl2.tf | 125 +++++++ 5 files changed, 1355 insertions(+) create mode 100644 config/hcl2_shim_util.go create mode 100644 config/hcl2_shim_util_test.go create mode 100644 config/loader_hcl2.go create mode 100644 config/loader_hcl2_test.go create mode 100644 config/test-fixtures/basic-hcl2.tf 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 +}