package integrationtest import ( "reflect" "sort" "testing" "github.com/davecgh/go-spew/spew" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/ext/dynblock" "github.com/hashicorp/hcl/v2/gohcl" "github.com/hashicorp/hcl/v2/hcldec" "github.com/hashicorp/hcl/v2/hclsyntax" "github.com/hashicorp/hcl/v2/json" "github.com/zclconf/go-cty/cty" ) // TestTerraformLike parses both a native syntax and a JSON representation // of the same HashiCorp Terraform-like configuration structure and then makes // assertions against the result of each. // // Terraform exercises a lot of different HCL codepaths, so this is not // exhaustive but tries to cover a variety of different relevant scenarios. func TestTerraformLike(t *testing.T) { tests := map[string]func() (*hcl.File, hcl.Diagnostics){ "native syntax": func() (*hcl.File, hcl.Diagnostics) { return hclsyntax.ParseConfig( []byte(terraformLikeNativeSyntax), "config.tf", hcl.Pos{Line: 1, Column: 1}, ) }, "JSON": func() (*hcl.File, hcl.Diagnostics) { return json.Parse( []byte(terraformLikeJSON), "config.tf.json", ) }, } type Variable struct { Name string `hcl:"name,label"` } type Resource struct { Type string `hcl:"type,label"` Name string `hcl:"name,label"` Config hcl.Body `hcl:",remain"` DependsOn hcl.Expression `hcl:"depends_on,attr"` } type Module struct { Name string `hcl:"name,label"` Providers hcl.Expression `hcl:"providers"` } type Root struct { Variables []*Variable `hcl:"variable,block"` Resources []*Resource `hcl:"resource,block"` Modules []*Module `hcl:"module,block"` } instanceDecode := &hcldec.ObjectSpec{ "image_id": &hcldec.AttrSpec{ Name: "image_id", Required: true, Type: cty.String, }, "instance_type": &hcldec.AttrSpec{ Name: "instance_type", Required: true, Type: cty.String, }, "tags": &hcldec.AttrSpec{ Name: "tags", Required: false, Type: cty.Map(cty.String), }, } securityGroupDecode := &hcldec.ObjectSpec{ "ingress": &hcldec.BlockListSpec{ TypeName: "ingress", Nested: &hcldec.ObjectSpec{ "cidr_block": &hcldec.AttrSpec{ Name: "cidr_block", Required: true, Type: cty.String, }, }, }, } for name, loadFunc := range tests { t.Run(name, func(t *testing.T) { file, diags := loadFunc() if len(diags) != 0 { t.Errorf("unexpected diagnostics during parse") for _, diag := range diags { t.Logf("- %s", diag) } return } body := file.Body var root Root diags = gohcl.DecodeBody(body, nil, &root) if len(diags) != 0 { t.Errorf("unexpected diagnostics during root eval") for _, diag := range diags { t.Logf("- %s", diag) } return } wantVars := []*Variable{ { Name: "image_id", }, } if gotVars := root.Variables; !reflect.DeepEqual(gotVars, wantVars) { t.Errorf("wrong Variables\ngot: %swant: %s", spew.Sdump(gotVars), spew.Sdump(wantVars)) } if got, want := len(root.Resources), 3; got != want { t.Fatalf("wrong number of Resources %d; want %d", got, want) } sort.Slice(root.Resources, func(i, j int) bool { return root.Resources[i].Name < root.Resources[j].Name }) t.Run("resource 0", func(t *testing.T) { r := root.Resources[0] if got, want := r.Type, "happycloud_security_group"; got != want { t.Errorf("wrong type %q; want %q", got, want) } if got, want := r.Name, "private"; got != want { t.Errorf("wrong type %q; want %q", got, want) } // For this one we're including support for the dynamic block // extension, since Terraform uses this to allow dynamic // generation of blocks within resource configuration. forEachCtx := &hcl.EvalContext{ Variables: map[string]cty.Value{ "var": cty.ObjectVal(map[string]cty.Value{ "extra_private_cidr_blocks": cty.ListVal([]cty.Value{ cty.StringVal("172.16.0.0/12"), cty.StringVal("169.254.0.0/16"), }), }), }, } dynBody := dynblock.Expand(r.Config, forEachCtx) cfg, diags := hcldec.Decode(dynBody, securityGroupDecode, nil) if len(diags) != 0 { t.Errorf("unexpected diagnostics decoding Config") for _, diag := range diags { t.Logf("- %s", diag) } return } wantCfg := cty.ObjectVal(map[string]cty.Value{ "ingress": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ "cidr_block": cty.StringVal("10.0.0.0/8"), }), cty.ObjectVal(map[string]cty.Value{ "cidr_block": cty.StringVal("192.168.0.0/16"), }), cty.ObjectVal(map[string]cty.Value{ "cidr_block": cty.StringVal("172.16.0.0/12"), }), cty.ObjectVal(map[string]cty.Value{ "cidr_block": cty.StringVal("169.254.0.0/16"), }), }), }) if !cfg.RawEquals(wantCfg) { t.Errorf("wrong config\ngot: %#v\nwant: %#v", cfg, wantCfg) } }) t.Run("resource 1", func(t *testing.T) { r := root.Resources[1] if got, want := r.Type, "happycloud_security_group"; got != want { t.Errorf("wrong type %q; want %q", got, want) } if got, want := r.Name, "public"; got != want { t.Errorf("wrong type %q; want %q", got, want) } cfg, diags := hcldec.Decode(r.Config, securityGroupDecode, nil) if len(diags) != 0 { t.Errorf("unexpected diagnostics decoding Config") for _, diag := range diags { t.Logf("- %s", diag) } return } wantCfg := cty.ObjectVal(map[string]cty.Value{ "ingress": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ "cidr_block": cty.StringVal("0.0.0.0/0"), }), }), }) if !cfg.RawEquals(wantCfg) { t.Errorf("wrong config\ngot: %#v\nwant: %#v", cfg, wantCfg) } }) t.Run("resource 2", func(t *testing.T) { r := root.Resources[2] if got, want := r.Type, "happycloud_instance"; got != want { t.Errorf("wrong type %q; want %q", got, want) } if got, want := r.Name, "test"; got != want { t.Errorf("wrong type %q; want %q", got, want) } vars := hcldec.Variables(r.Config, &hcldec.AttrSpec{ Name: "image_id", Type: cty.String, }) if got, want := len(vars), 1; got != want { t.Errorf("wrong number of variables in image_id %#v; want %#v", got, want) } if got, want := vars[0].RootName(), "var"; got != want { t.Errorf("wrong image_id variable RootName %#v; want %#v", got, want) } ctx := &hcl.EvalContext{ Variables: map[string]cty.Value{ "var": cty.ObjectVal(map[string]cty.Value{ "image_id": cty.StringVal("image-1234"), }), }, } cfg, diags := hcldec.Decode(r.Config, instanceDecode, ctx) if len(diags) != 0 { t.Errorf("unexpected diagnostics decoding Config") for _, diag := range diags { t.Logf("- %s", diag) } return } wantCfg := cty.ObjectVal(map[string]cty.Value{ "instance_type": cty.StringVal("z3.weedy"), "image_id": cty.StringVal("image-1234"), "tags": cty.MapVal(map[string]cty.Value{ "Name": cty.StringVal("foo"), "Environment": cty.StringVal("prod"), }), }) if !cfg.RawEquals(wantCfg) { t.Errorf("wrong config\ngot: %#v\nwant: %#v", cfg, wantCfg) } exprs, diags := hcl.ExprList(r.DependsOn) if len(diags) != 0 { t.Errorf("unexpected diagnostics extracting depends_on") for _, diag := range diags { t.Logf("- %s", diag) } return } if got, want := len(exprs), 1; got != want { t.Errorf("wrong number of depends_on exprs %#v; want %#v", got, want) } traversal, diags := hcl.AbsTraversalForExpr(exprs[0]) if len(diags) != 0 { t.Errorf("unexpected diagnostics decoding depends_on[0]") for _, diag := range diags { t.Logf("- %s", diag) } return } if got, want := len(traversal), 2; got != want { t.Errorf("wrong number of depends_on traversal steps %#v; want %#v", got, want) } if got, want := traversal.RootName(), "happycloud_security_group"; got != want { t.Errorf("wrong depends_on traversal RootName %#v; want %#v", got, want) } }) t.Run("module", func(t *testing.T) { if got, want := len(root.Modules), 1; got != want { t.Fatalf("wrong number of Modules %d; want %d", got, want) } mod := root.Modules[0] if got, want := mod.Name, "foo"; got != want { t.Errorf("wrong module name %q; want %q", got, want) } pExpr := mod.Providers pairs, diags := hcl.ExprMap(pExpr) if len(diags) != 0 { t.Errorf("unexpected diagnostics extracting providers") for _, diag := range diags { t.Logf("- %s", diag) } } if got, want := len(pairs), 1; got != want { t.Fatalf("wrong number of key/value pairs in providers %d; want %d", got, want) } pair := pairs[0] kt, diags := hcl.AbsTraversalForExpr(pair.Key) if len(diags) != 0 { t.Errorf("unexpected diagnostics extracting providers key %#v", pair.Key) for _, diag := range diags { t.Logf("- %s", diag) } } vt, diags := hcl.AbsTraversalForExpr(pair.Value) if len(diags) != 0 { t.Errorf("unexpected diagnostics extracting providers value %#v", pair.Value) for _, diag := range diags { t.Logf("- %s", diag) } } if got, want := len(kt), 1; got != want { t.Fatalf("wrong number of key traversal steps %d; want %d", got, want) } if got, want := len(vt), 2; got != want { t.Fatalf("wrong number of value traversal steps %d; want %d", got, want) } if got, want := kt.RootName(), "null"; got != want { t.Errorf("wrong number key traversal root %s; want %s", got, want) } if got, want := vt.RootName(), "null"; got != want { t.Errorf("wrong number value traversal root %s; want %s", got, want) } if at, ok := vt[1].(hcl.TraverseAttr); ok { if got, want := at.Name, "foo"; got != want { t.Errorf("wrong number value traversal attribute name %s; want %s", got, want) } } else { t.Errorf("wrong value traversal [1] type %T; want hcl.TraverseAttr", vt[1]) } }) }) } } const terraformLikeNativeSyntax = ` variable "image_id" { } resource "happycloud_instance" "test" { instance_type = "z3.weedy" image_id = var.image_id tags = { "Name" = "foo" "${"Environment"}" = "prod" } depends_on = [ happycloud_security_group.public, ] } resource "happycloud_security_group" "public" { ingress { cidr_block = "0.0.0.0/0" } } resource "happycloud_security_group" "private" { ingress { cidr_block = "10.0.0.0/8" } ingress { cidr_block = "192.168.0.0/16" } dynamic "ingress" { for_each = var.extra_private_cidr_blocks content { cidr_block = ingress.value } } } module "foo" { providers = { null = null.foo } } ` const terraformLikeJSON = ` { "variable": { "image_id": {} }, "resource": { "happycloud_instance": { "test": { "instance_type": "z3.weedy", "image_id": "${var.image_id}", "tags": { "Name": "foo", "${\"Environment\"}": "prod" }, "depends_on": [ "happycloud_security_group.public" ] } }, "happycloud_security_group": { "public": { "ingress": { "cidr_block": "0.0.0.0/0" } }, "private": { "ingress": [ { "cidr_block": "10.0.0.0/8" }, { "cidr_block": "192.168.0.0/16" } ], "dynamic": { "ingress": { "for_each": "${var.extra_private_cidr_blocks}", "iterator": "block", "content": { "cidr_block": "${block.value}" } } } } } }, "module": { "foo": { "providers": { "null": "null.foo" } } } } `