diff --git a/plans/objchange/normalize_obj.go b/plans/objchange/normalize_obj.go new file mode 100644 index 0000000000..3e1eee8152 --- /dev/null +++ b/plans/objchange/normalize_obj.go @@ -0,0 +1,118 @@ +package objchange + +import ( + "github.com/hashicorp/terraform/configs/configschema" + "github.com/zclconf/go-cty/cty" +) + +// NormalizeObjectFromLegacySDK takes an object that may have been generated +// by the legacy Terraform SDK (i.e. returned from a provider with the +// LegacyTypeSystem opt-out set) and does its best to normalize it for the +// assumptions we would normally enforce if the provider had not opted out. +// +// In particular, this function guarantees that a value representing a nested +// block will never itself be unknown or null, instead representing that as +// a non-null value that may contain null/unknown values. +// +// The input value must still conform to the implied type of the given schema, +// or else this function may produce garbage results or panic. This is usually +// okay because type consistency is enforced when deserializing the value +// returned from the provider over the RPC wire protocol anyway. +func NormalizeObjectFromLegacySDK(val cty.Value, schema *configschema.Block) cty.Value { + if val == cty.NilVal || val.IsNull() { + // This should never happen in reasonable use, but we'll allow it + // and normalize to a null of the expected type rather than panicking + // below. + return cty.NullVal(schema.ImpliedType()) + } + + vals := make(map[string]cty.Value) + for name := range schema.Attributes { + // No normalization for attributes, since them being type-conformant + // is all that we require. + vals[name] = val.GetAttr(name) + } + for name, blockS := range schema.BlockTypes { + lv := val.GetAttr(name) + switch blockS.Nesting { + case configschema.NestingSingle: + if lv.IsKnown() { + vals[name] = NormalizeObjectFromLegacySDK(lv, &blockS.Block) + } else { + vals[name] = unknownBlockStub(&blockS.Block) + } + case configschema.NestingList: + switch { + case !lv.IsKnown(): + vals[name] = cty.ListVal([]cty.Value{unknownBlockStub(&blockS.Block)}) + case lv.IsNull() || lv.LengthInt() == 0: + vals[name] = cty.ListValEmpty(blockS.Block.ImpliedType()) + default: + subVals := make([]cty.Value, 0, lv.LengthInt()) + for it := lv.ElementIterator(); it.Next(); { + _, subVal := it.Element() + subVals = append(subVals, NormalizeObjectFromLegacySDK(subVal, &blockS.Block)) + } + vals[name] = cty.ListVal(subVals) + } + case configschema.NestingSet: + switch { + case !lv.IsKnown(): + vals[name] = cty.SetVal([]cty.Value{unknownBlockStub(&blockS.Block)}) + case lv.IsNull() || lv.LengthInt() == 0: + vals[name] = cty.SetValEmpty(blockS.Block.ImpliedType()) + default: + subVals := make([]cty.Value, 0, lv.LengthInt()) + for it := lv.ElementIterator(); it.Next(); { + _, subVal := it.Element() + subVals = append(subVals, NormalizeObjectFromLegacySDK(subVal, &blockS.Block)) + } + vals[name] = cty.SetVal(subVals) + } + default: + // The legacy SDK doesn't support NestingMap, so we just assume + // maps are always okay. (If not, we would've detected and returned + // an error to the user before we got here.) + vals[name] = lv + } + } + return cty.ObjectVal(vals) +} + +// unknownBlockStub constructs an object value that approximates an unknown +// block by producing a known block object with all of its leaf attribute +// values set to unknown. +// +// Blocks themselves cannot be unknown, so if the legacy SDK tries to return +// such a thing, we'll use this result instead. This convention mimics how +// the dynamic block feature deals with being asked to iterate over an unknown +// value, because our value-checking functions already accept this convention +// as a special case. +func unknownBlockStub(schema *configschema.Block) cty.Value { + vals := make(map[string]cty.Value) + for name, attrS := range schema.Attributes { + vals[name] = cty.UnknownVal(attrS.Type) + } + for name, blockS := range schema.BlockTypes { + switch blockS.Nesting { + case configschema.NestingSingle: + vals[name] = unknownBlockStub(&blockS.Block) + case configschema.NestingList: + // In principle we may be expected to produce a tuple value here, + // if there are any dynamically-typed attributes in our nested block, + // but the legacy SDK doesn't support that, so we just assume it'll + // never be necessary to normalize those. (Incorrect usage in any + // other SDK would be caught and returned as an error before we + // get here.) + vals[name] = cty.ListVal([]cty.Value{unknownBlockStub(&blockS.Block)}) + case configschema.NestingSet: + vals[name] = cty.SetVal([]cty.Value{unknownBlockStub(&blockS.Block)}) + case configschema.NestingMap: + // A nesting map can never be unknown since we then wouldn't know + // what the keys are. (Legacy SDK doesn't support NestingMap anyway, + // so this should never arise.) + vals[name] = cty.MapValEmpty(blockS.Block.ImpliedType()) + } + } + return cty.ObjectVal(vals) +} diff --git a/plans/objchange/normalize_obj_test.go b/plans/objchange/normalize_obj_test.go new file mode 100644 index 0000000000..f95a0b4601 --- /dev/null +++ b/plans/objchange/normalize_obj_test.go @@ -0,0 +1,240 @@ +package objchange + +import ( + "testing" + + "github.com/apparentlymart/go-dump/dump" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/zclconf/go-cty/cty" +) + +func TestNormalizeObjectFromLegacySDK(t *testing.T) { + tests := map[string]struct { + Schema *configschema.Block + Input cty.Value + Want cty.Value + }{ + "empty": { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }, + "attributes only": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "a": {Type: cty.String, Required: true}, + "b": {Type: cty.String, Optional: true}, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("a value"), + "b": cty.StringVal("b value"), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.StringVal("a value"), + "b": cty.StringVal("b value"), + }), + }, + "null block single": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.NullVal(cty.Object(map[string]cty.Type{ + "b": cty.String, + })), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.NullVal(cty.Object(map[string]cty.Type{ + "b": cty.String, + })), + }), + }, + "unknown block single": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "c": {Nesting: configschema.NestingSingle}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.UnknownVal(cty.Object(map[string]cty.Type{ + "b": cty.String, + "c": cty.EmptyObject, + })), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "b": cty.UnknownVal(cty.String), + "c": cty.EmptyObjectVal, + }), + }), + }, + "null block list": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "c": {Nesting: configschema.NestingSingle}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.NullVal(cty.List(cty.Object(map[string]cty.Type{ + "b": cty.String, + "c": cty.EmptyObject, + }))), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.ListValEmpty(cty.Object(map[string]cty.Type{ + "b": cty.String, + "c": cty.EmptyObject, + })), + }), + }, + "unknown block list": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.UnknownVal(cty.List(cty.Object(map[string]cty.Type{ + "b": cty.String, + }))), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "b": cty.UnknownVal(cty.String), + }), + }), + }), + }, + "null block set": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.NullVal(cty.Set(cty.Object(map[string]cty.Type{ + "b": cty.String, + }))), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.SetValEmpty(cty.Object(map[string]cty.Type{ + "b": cty.String, + })), + }), + }, + "unknown block set": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.UnknownVal(cty.Set(cty.Object(map[string]cty.Type{ + "b": cty.String, + }))), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "b": cty.UnknownVal(cty.String), + }), + }), + }), + }, + "map block passes through": { + // Legacy SDK doesn't use NestingMap, so we don't do any transforms + // related to it but we still need to verify that map blocks pass + // through unscathed. + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "a": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "b": {Type: cty.String, Optional: true}, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "a": cty.MapVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "b": cty.StringVal("b value"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "a": cty.MapVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "b": cty.StringVal("b value"), + }), + }), + }), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + got := NormalizeObjectFromLegacySDK(test.Input, test.Schema) + if !got.RawEquals(test.Want) { + t.Errorf( + "wrong result\ngot: %s\nwant: %s", + dump.Value(got), dump.Value(test.Want), + ) + } + }) + } +}