From 93630cf95ff37d4f5a2cdafaaed50432c65a13be Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Tue, 16 Oct 2018 12:02:32 -0700 Subject: [PATCH] config/hcl2shim: ConfigValueFromHCL2Block function This is a more specialized version of ConfigValueFromHCL2 which is specifically for config values that represent the content of a block body in the configuration. By using the schema of that block we can more precisely emulate the old HCL1/HIL behaviors by distinguishing attributes from blocks and applying some slightly different behaviors for the handling of null values and of empty collections that are representing the absense of blocks of a particular type. --- config/hcl2shim/values.go | 104 +++++++++++++++ config/hcl2shim/values_test.go | 236 +++++++++++++++++++++++++++++++++ 2 files changed, 340 insertions(+) diff --git a/config/hcl2shim/values.go b/config/hcl2shim/values.go index 956c15deef..2c5b2907e6 100644 --- a/config/hcl2shim/values.go +++ b/config/hcl2shim/values.go @@ -6,6 +6,8 @@ import ( "github.com/hashicorp/hil/ast" "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/configs/configschema" ) // UnknownVariableValue is a sentinel value that can be used @@ -14,6 +16,108 @@ import ( // unknown keys. const UnknownVariableValue = "74D93920-ED26-11E3-AC10-0800200C9A66" +// ConfigValueFromHCL2Block is like ConfigValueFromHCL2 but it works only for +// known object values and uses the provided block schema to perform some +// additional normalization to better mimic the shape of value that the old +// HCL1/HIL-based codepaths would've produced. +// +// In particular, it discards the collections that we use to represent nested +// blocks (other than NestingSingle) if they are empty, which better mimics +// the HCL1 behavior because HCL1 had no knowledge of the schema and so didn't +// know that an unspecified block _could_ exist. +// +// The given object value must conform to the schema's implied type or this +// function will panic or produce incorrect results. +// +// This is primarily useful for the final transition from new-style values to +// terraform.ResourceConfig before calling to a legacy provider, since +// helper/schema (the old provider SDK) is particularly sensitive to these +// subtle differences within its validation code. +func ConfigValueFromHCL2Block(v cty.Value, schema *configschema.Block) map[string]interface{} { + if v.IsNull() { + return nil + } + if !v.IsKnown() { + panic("ConfigValueFromHCL2Block used with unknown value") + } + if !v.Type().IsObjectType() { + panic(fmt.Sprintf("ConfigValueFromHCL2Block used with non-object value %#v", v)) + } + + atys := v.Type().AttributeTypes() + ret := make(map[string]interface{}) + + for name := range schema.Attributes { + if _, exists := atys[name]; !exists { + continue + } + + av := v.GetAttr(name) + if av.IsNull() { + // Skip nulls altogether, to better mimic how HCL1 would behave + continue + } + ret[name] = ConfigValueFromHCL2(av) + } + + for name, blockS := range schema.BlockTypes { + if _, exists := atys[name]; !exists { + continue + } + bv := v.GetAttr(name) + if !bv.IsKnown() { + ret[name] = UnknownVariableValue + continue + } + if bv.IsNull() { + continue + } + + switch blockS.Nesting { + + case configschema.NestingSingle: + ret[name] = ConfigValueFromHCL2Block(bv, &blockS.Block) + + case configschema.NestingList, configschema.NestingSet: + l := bv.LengthInt() + if l == 0 { + // skip empty collections to better mimic how HCL1 would behave + continue + } + + elems := make([]interface{}, 0, l) + for it := bv.ElementIterator(); it.Next(); { + _, ev := it.Element() + if !ev.IsKnown() { + elems = append(elems, UnknownVariableValue) + continue + } + elems = append(elems, ConfigValueFromHCL2Block(ev, &blockS.Block)) + } + ret[name] = elems + + case configschema.NestingMap: + if bv.LengthInt() == 0 { + // skip empty collections to better mimic how HCL1 would behave + continue + } + + elems := make(map[string]interface{}) + for it := bv.ElementIterator(); it.Next(); { + ek, ev := it.Element() + if !ev.IsKnown() { + elems[ek.AsString()] = UnknownVariableValue + continue + } + elems[ek.AsString()] = ConfigValueFromHCL2Block(ev, &blockS.Block) + } + ret[name] = elems + } + } + + return ret +} + // 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. diff --git a/config/hcl2shim/values_test.go b/config/hcl2shim/values_test.go index 7f335a3fd5..7c3011da05 100644 --- a/config/hcl2shim/values_test.go +++ b/config/hcl2shim/values_test.go @@ -5,9 +5,245 @@ import ( "reflect" "testing" + "github.com/hashicorp/terraform/configs/configschema" "github.com/zclconf/go-cty/cty" ) +func TestConfigValueFromHCL2Block(t *testing.T) { + tests := []struct { + Input cty.Value + Schema *configschema.Block + Want map[string]interface{} + }{ + { + 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"), + }), + }), + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, + "age": {Type: cty.Number, Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "street": {Type: cty.List(cty.String), Optional: true}, + "city": {Type: cty.String, Optional: true}, + "state": {Type: cty.String, Optional: true}, + "zip": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + map[string]interface{}{ + "name": "Ermintrude", + "age": int(19), + "address": map[string]interface{}{ + "street": []interface{}{"421 Shoreham Loop"}, + "city": "Fridgewater", + "state": "MA", + "zip": "91037", + }, + }, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("Ermintrude"), + "age": cty.NumberIntVal(19), + "address": cty.NullVal(cty.Object(map[string]cty.Type{ + "street": cty.List(cty.String), + "city": cty.String, + "state": cty.String, + "zip": cty.String, + })), + }), + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, + "age": {Type: cty.Number, Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "street": {Type: cty.List(cty.String), Optional: true}, + "city": {Type: cty.String, Optional: true}, + "state": {Type: cty.String, Optional: true}, + "zip": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + map[string]interface{}{ + "name": "Ermintrude", + "age": int(19), + }, + }, + { + 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.NullVal(cty.String), // should be omitted altogether in result + }), + }), + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "name": {Type: cty.String, Optional: true}, + "age": {Type: cty.Number, Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "street": {Type: cty.List(cty.String), Optional: true}, + "city": {Type: cty.String, Optional: true}, + "state": {Type: cty.String, Optional: true}, + "zip": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + map[string]interface{}{ + "name": "Ermintrude", + "age": int(19), + "address": map[string]interface{}{ + "street": []interface{}{"421 Shoreham Loop"}, + "city": "Fridgewater", + "state": "MA", + }, + }, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "address": cty.ListVal([]cty.Value{cty.EmptyObjectVal}), + }), + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingList, + Block: configschema.Block{}, + }, + }, + }, + map[string]interface{}{ + "address": []interface{}{ + map[string]interface{}{}, + }, + }, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "address": cty.ListValEmpty(cty.EmptyObject), // should be omitted altogether in result + }), + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingList, + Block: configschema.Block{}, + }, + }, + }, + map[string]interface{}{}, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "address": cty.SetVal([]cty.Value{cty.EmptyObjectVal}), + }), + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingSet, + Block: configschema.Block{}, + }, + }, + }, + map[string]interface{}{ + "address": []interface{}{ + map[string]interface{}{}, + }, + }, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "address": cty.SetValEmpty(cty.EmptyObject), + }), + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingSet, + Block: configschema.Block{}, + }, + }, + }, + map[string]interface{}{}, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "address": cty.MapVal(map[string]cty.Value{"foo": cty.EmptyObjectVal}), + }), + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingMap, + Block: configschema.Block{}, + }, + }, + }, + map[string]interface{}{ + "address": map[string]interface{}{ + "foo": map[string]interface{}{}, + }, + }, + }, + { + cty.ObjectVal(map[string]cty.Value{ + "address": cty.MapValEmpty(cty.EmptyObject), + }), + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "address": { + Nesting: configschema.NestingMap, + Block: configschema.Block{}, + }, + }, + }, + map[string]interface{}{}, + }, + { + cty.NullVal(cty.EmptyObject), + &configschema.Block{}, + nil, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("%#v", test.Input), func(t *testing.T) { + got := ConfigValueFromHCL2Block(test.Input, test.Schema) + if !reflect.DeepEqual(got, test.Want) { + t.Errorf("wrong result\ninput: %#v\ngot: %#v\nwant: %#v", test.Input, got, test.Want) + } + }) + } +} + func TestConfigValueFromHCL2(t *testing.T) { tests := []struct { Input cty.Value