From 1eebcf875fadbc0d55ae17b2689350999b29da93 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 9 Jan 2023 12:15:38 +0100 Subject: [PATCH] Add support for object attributes in the structured renderer (#32391) * prep for processing the structured run output * undo unwanted change to a json key * Add skeleton functions and API for refactored renderer * goimports * Fix documentation of the RenderOpts struct * Add rendering functionality for primitives to the structured renderer * add test case for override * Add support for parsing and rendering sensitive values in the renderer * Add support for unknown/computed values in the structured renderer * delete missing unit tests * Add support for object attributes in the structured renderer * goimports --- internal/command/jsonformat/change/change.go | 22 + internal/command/jsonformat/change/object.go | 88 ++++ .../command/jsonformat/change/renderer.go | 8 + .../jsonformat/change/renderer_test.go | 249 ++++++++++- internal/command/jsonformat/change/testing.go | 75 +++- .../command/jsonformat/differ/attribute.go | 33 +- .../command/jsonformat/differ/computed.go | 6 +- internal/command/jsonformat/differ/object.go | 61 +++ internal/command/jsonformat/differ/value.go | 55 ++- .../command/jsonformat/differ/value_map.go | 69 +++ .../command/jsonformat/differ/value_test.go | 417 ++++++++++++++++-- 11 files changed, 1018 insertions(+), 65 deletions(-) create mode 100644 internal/command/jsonformat/change/object.go create mode 100644 internal/command/jsonformat/differ/object.go create mode 100644 internal/command/jsonformat/differ/value_map.go diff --git a/internal/command/jsonformat/change/change.go b/internal/command/jsonformat/change/change.go index b44af59e15..eab85b0321 100644 --- a/internal/command/jsonformat/change/change.go +++ b/internal/command/jsonformat/change/change.go @@ -1,6 +1,7 @@ package change import ( + "fmt" "strings" "github.com/hashicorp/terraform/internal/plans" @@ -60,6 +61,11 @@ func (change Change) Warnings(indent int) []string { return change.renderer.Warnings(change, indent) } +// GetAction returns the plans.Action that this change describes. +func (change Change) GetAction() plans.Action { + return change.action +} + // nullSuffix returns the `-> null` suffix if the change is a delete action, and // it has not been overridden. func (change Change) nullSuffix(override bool) string { @@ -83,3 +89,19 @@ func (change Change) forcesReplacement() string { func (change Change) indent(indent int) string { return strings.Repeat(" ", indent) } + +// emptySymbol returns an empty string that is the same length as an action +// symbol (eg. ' +', '+/-', ...). It is used to offset additional lines in +// change renderer outputs alongside the indent function. +func (change Change) emptySymbol() string { + return " " +} + +// unchanged prints out a description saying how many of 'keyword' have been +// hidden because they are unchanged or noop actions. +func (change Change) unchanged(keyword string, count int) string { + if count == 1 { + return fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", count, keyword) + } + return fmt.Sprintf("[dark_gray]# (%d unchanged %ss hidden)[reset]", count, keyword) +} diff --git a/internal/command/jsonformat/change/object.go b/internal/command/jsonformat/change/object.go new file mode 100644 index 0000000000..294ca5ed74 --- /dev/null +++ b/internal/command/jsonformat/change/object.go @@ -0,0 +1,88 @@ +package change + +import ( + "bytes" + "fmt" + "sort" + + "github.com/hashicorp/terraform/internal/command/format" + "github.com/hashicorp/terraform/internal/plans" +) + +func Object(attributes map[string]Change) Renderer { + maximumKeyLen := 0 + for key := range attributes { + if maximumKeyLen < len(key) { + maximumKeyLen = len(key) + } + } + + return &objectRenderer{ + attributes: attributes, + maximumKeyLen: maximumKeyLen, + overrideNullSuffix: true, + } +} + +func NestedObject(attributes map[string]Change) Renderer { + maximumKeyLen := 0 + for key := range attributes { + if maximumKeyLen < len(key) { + maximumKeyLen = len(key) + } + } + + return &objectRenderer{ + attributes: attributes, + maximumKeyLen: maximumKeyLen, + overrideNullSuffix: false, + } +} + +type objectRenderer struct { + NoWarningsRenderer + + attributes map[string]Change + maximumKeyLen int + overrideNullSuffix bool +} + +func (renderer objectRenderer) Render(change Change, indent int, opts RenderOpts) string { + if len(renderer.attributes) == 0 { + return fmt.Sprintf("{}%s%s", change.nullSuffix(opts.overrideNullSuffix), change.forcesReplacement()) + } + + attributeOpts := opts.Clone() + attributeOpts.overrideNullSuffix = renderer.overrideNullSuffix + + var keys []string + for key := range renderer.attributes { + keys = append(keys, key) + } + sort.Strings(keys) + + unchangedAttributes := 0 + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("{%s\n", change.forcesReplacement())) + for _, key := range keys { + attribute := renderer.attributes[key] + + if attribute.action == plans.NoOp && !opts.showUnchangedChildren { + // Don't render NoOp operations when we are compact display. + unchangedAttributes++ + continue + } + + for _, warning := range attribute.Warnings(indent + 1) { + buf.WriteString(fmt.Sprintf("%s%s\n", change.indent(indent+1), warning)) + } + buf.WriteString(fmt.Sprintf("%s%s %-*s = %s\n", change.indent(indent+1), format.DiffActionSymbol(attribute.action), renderer.maximumKeyLen, key, attribute.Render(indent+1, attributeOpts))) + } + + if unchangedAttributes > 0 { + buf.WriteString(fmt.Sprintf("%s%s %s\n", change.indent(indent+1), change.emptySymbol(), change.unchanged("attribute", unchangedAttributes))) + } + + buf.WriteString(fmt.Sprintf("%s%s }%s", change.indent(indent), change.emptySymbol(), change.nullSuffix(opts.overrideNullSuffix))) + return buf.String() +} diff --git a/internal/command/jsonformat/change/renderer.go b/internal/command/jsonformat/change/renderer.go index f722bd198a..3e19ab212a 100644 --- a/internal/command/jsonformat/change/renderer.go +++ b/internal/command/jsonformat/change/renderer.go @@ -32,6 +32,14 @@ type RenderOpts struct { // change, as such we provide this as an option instead of trying to // calculate it inside a specific renderer. overrideNullSuffix bool + + // showUnchangedChildren instructs the Renderer to render all children of a + // given complex change, instead of hiding unchanged items and compressing + // them into a single line. + // + // This is generally decided by the parent change (mainly lists) and so is + // passed in as a private option. + showUnchangedChildren bool } // Clone returns a new RenderOpts object, that matches the original but can be diff --git a/internal/command/jsonformat/change/renderer_test.go b/internal/command/jsonformat/change/renderer_test.go index 62dbd26cef..6275bd242f 100644 --- a/internal/command/jsonformat/change/renderer_test.go +++ b/internal/command/jsonformat/change/renderer_test.go @@ -1,6 +1,7 @@ package change import ( + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -107,12 +108,256 @@ func TestRenderers(t *testing.T) { }, expected: "0 -> (known after apply)", }, + "object_created": { + change: Change{ + renderer: Object(map[string]Change{}), + action: plans.Create, + }, + expected: "{}", + }, + "object_created_with_attributes": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(nil, strptr("0")), + action: plans.Create, + }, + }), + action: plans.Create, + }, + expected: ` +{ + + attribute_one = 0 + } +`, + }, + "object_deleted": { + change: Change{ + renderer: Object(map[string]Change{}), + action: plans.Delete, + }, + expected: "{} -> null", + }, + "object_deleted_with_attributes": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(strptr("0"), nil), + action: plans.Delete, + }, + }), + action: plans.Delete, + }, + expected: ` +{ + - attribute_one = 0 + } -> null +`, + }, + "nested_object_deleted": { + change: Change{ + renderer: NestedObject(map[string]Change{}), + action: plans.Delete, + }, + expected: "{} -> null", + }, + "nested_object_deleted_with_attributes": { + change: Change{ + renderer: NestedObject(map[string]Change{ + "attribute_one": { + renderer: Primitive(strptr("0"), nil), + action: plans.Delete, + }, + }), + action: plans.Delete, + }, + expected: ` +{ + - attribute_one = 0 -> null + } -> null +`, + }, + "object_create_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(nil, strptr("0")), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +{ + + attribute_one = 0 + } +`, + }, + "object_update_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(strptr("0"), strptr("1")), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +{ + ~ attribute_one = 0 -> 1 + } +`, + }, + "object_update_attribute_forces_replacement": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(strptr("0"), strptr("1")), + action: plans.Update, + }, + }), + action: plans.Update, + replace: true, + }, + expected: ` +{ # forces replacement + ~ attribute_one = 0 -> 1 + } +`, + }, + "object_delete_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(strptr("0"), nil), + action: plans.Delete, + }, + }), + action: plans.Update, + }, + expected: ` +{ + - attribute_one = 0 + } +`, + }, + "object_ignore_unchanged_attributes": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Primitive(strptr("0"), strptr("1")), + action: plans.Update, + }, + "attribute_two": { + renderer: Primitive(strptr("0"), strptr("0")), + action: plans.NoOp, + }, + "attribute_three": { + renderer: Primitive(nil, strptr("1")), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +{ + ~ attribute_one = 0 -> 1 + + attribute_three = 1 + # (1 unchanged attribute hidden) + } +`, + }, + "object_create_sensitive_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Sensitive(nil, 1, false, true), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +{ + + attribute_one = (sensitive) + } +`, + }, + "object_update_sensitive_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Sensitive(nil, 1, false, true), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +{ + ~ attribute_one = (sensitive) + } +`, + }, + "object_delete_sensitive_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Sensitive(nil, 1, false, true), + action: plans.Delete, + }, + }), + action: plans.Update, + }, + expected: ` +{ + - attribute_one = (sensitive) + } +`, + }, + "object_create_computed_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Computed(Change{renderer: nil}), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +{ + + attribute_one = (known after apply) + } +`, + }, + "object_update_computed_attribute": { + change: Change{ + renderer: Object(map[string]Change{ + "attribute_one": { + renderer: Computed(Change{ + renderer: Primitive(strptr("1"), nil), + action: plans.Delete, + }), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +{ + ~ attribute_one = 1 -> (known after apply) + } +`, + }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { + expected := strings.TrimSpace(tc.expected) actual := colorize.Color(tc.change.Render(0, tc.opts)) - if diff := cmp.Diff(tc.expected, actual); len(diff) > 0 { - t.Fatalf("\nexpected:\n%s\nactual:\n%s\ndiff:\n%s\n", tc.expected, actual, diff) + if diff := cmp.Diff(expected, actual); len(diff) > 0 { + t.Fatalf("\nexpected:\n%s\nactual:\n%s\ndiff:\n%s\n", expected, actual, diff) } }) } diff --git a/internal/command/jsonformat/change/testing.go b/internal/command/jsonformat/change/testing.go index 4f87843880..dee74edaa8 100644 --- a/internal/command/jsonformat/change/testing.go +++ b/internal/command/jsonformat/change/testing.go @@ -1,6 +1,7 @@ package change import ( + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -10,16 +11,16 @@ import ( type ValidateChangeFunc func(t *testing.T, change Change) -func ValidateChange(t *testing.T, f ValidateChangeFunc, change Change, expectedAction plans.Action, expectedReplace bool) { +func validateChange(t *testing.T, change Change, expectedAction plans.Action, expectedReplace bool) { if change.replace != expectedReplace || change.action != expectedAction { t.Fatalf("\nreplace:\n\texpected:%t\n\tactual:%t\naction:\n\texpected:%s\n\tactual:%s", expectedReplace, change.replace, expectedAction, change.action) } - - f(t, change) } -func ValidatePrimitive(before, after *string) ValidateChangeFunc { +func ValidatePrimitive(before, after *string, action plans.Action, replace bool) ValidateChangeFunc { return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + primitive, ok := change.renderer.(*primitiveRenderer) if !ok { t.Fatalf("invalid renderer type: %T", change.renderer) @@ -34,8 +35,68 @@ func ValidatePrimitive(before, after *string) ValidateChangeFunc { } } -func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitive bool) ValidateChangeFunc { +func ValidateObject(attributes map[string]ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc { return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + + object, ok := change.renderer.(*objectRenderer) + if !ok { + t.Fatalf("invalid renderer type: %T", change.renderer) + } + + if !object.overrideNullSuffix { + t.Fatalf("created the wrong type of object renderer") + } + + validateObject(t, object, attributes) + } +} + +func ValidateNestedObject(attributes map[string]ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc { + return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + + object, ok := change.renderer.(*objectRenderer) + if !ok { + t.Fatalf("invalid renderer type: %T", change.renderer) + } + + if object.overrideNullSuffix { + t.Fatalf("created the wrong type of object renderer") + } + + validateObject(t, object, attributes) + } +} + +func validateObject(t *testing.T, object *objectRenderer, attributes map[string]ValidateChangeFunc) { + if len(object.attributes) != len(attributes) { + t.Fatalf("expected %d attributes but found %d attributes", len(attributes), len(object.attributes)) + } + + var missing []string + for key, expected := range attributes { + actual, ok := object.attributes[key] + if !ok { + missing = append(missing, key) + } + + if len(missing) > 0 { + continue + } + + expected(t, actual) + } + + if len(missing) > 0 { + t.Fatalf("missing the following attributes: %s", strings.Join(missing, ", ")) + } +} + +func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitive bool, action plans.Action, replace bool) ValidateChangeFunc { + return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + sensitive, ok := change.renderer.(*sensitiveRenderer) if !ok { t.Fatalf("invalid renderer type: %T", change.renderer) @@ -54,8 +115,10 @@ func ValidateSensitive(before, after interface{}, beforeSensitive, afterSensitiv } } -func ValidateComputed(before ValidateChangeFunc) ValidateChangeFunc { +func ValidateComputed(before ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc { return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + computed, ok := change.renderer.(*computedRenderer) if !ok { t.Fatalf("invalid renderer type: %T", change.renderer) diff --git a/internal/command/jsonformat/differ/attribute.go b/internal/command/jsonformat/differ/attribute.go index ba0efcb627..729037cf57 100644 --- a/internal/command/jsonformat/differ/attribute.go +++ b/internal/command/jsonformat/differ/attribute.go @@ -8,37 +8,34 @@ import ( "github.com/hashicorp/terraform/internal/command/jsonprovider" ) -func (v Value) ComputeChangeForAttribute(attribute *jsonprovider.Attribute) change.Change { - return v.ComputeChangeForType(unmarshalAttribute(attribute)) +func (v Value) computeChangeForAttribute(attribute *jsonprovider.Attribute) change.Change { + if attribute.AttributeNestedType != nil { + return v.computeChangeForNestedAttribute(attribute.AttributeNestedType) + } + return v.computeChangeForType(unmarshalAttribute(attribute)) } -func (v Value) ComputeChangeForType(ctyType cty.Type) change.Change { - - if sensitive, ok := v.checkForSensitive(); ok { - return sensitive - } - - if computed, ok := v.checkForComputed(ctyType); ok { - return computed +func (v Value) computeChangeForNestedAttribute(attribute *jsonprovider.NestedType) change.Change { + switch attribute.NestingMode { + case "single", "group": + return v.computeAttributeChangeAsNestedObject(attribute.Attributes) + default: + panic("unrecognized nesting mode: " + attribute.NestingMode) } +} +func (v Value) computeChangeForType(ctyType cty.Type) change.Change { switch { case ctyType.IsPrimitiveType(): return v.computeAttributeChangeAsPrimitive(ctyType) + case ctyType.IsObjectType(): + return v.computeAttributeChangeAsObject(ctyType.AttributeTypes()) default: panic("not implemented") } } func unmarshalAttribute(attribute *jsonprovider.Attribute) cty.Type { - if attribute.AttributeNestedType != nil { - children := make(map[string]cty.Type) - for key, child := range attribute.AttributeNestedType.Attributes { - children[key] = unmarshalAttribute(child) - } - return cty.Object(children) - } - ctyType, err := ctyjson.UnmarshalType(attribute.AttributeType) if err != nil { panic("could not unmarshal attribute type: " + err.Error()) diff --git a/internal/command/jsonformat/differ/computed.go b/internal/command/jsonformat/differ/computed.go index 227860f7c7..4f2e85081e 100644 --- a/internal/command/jsonformat/differ/computed.go +++ b/internal/command/jsonformat/differ/computed.go @@ -1,12 +1,10 @@ package differ import ( - "github.com/zclconf/go-cty/cty" - "github.com/hashicorp/terraform/internal/command/jsonformat/change" ) -func (v Value) checkForComputed(ctyType cty.Type) (change.Change, bool) { +func (v Value) checkForComputed(changeType interface{}) (change.Change, bool) { unknown := v.isUnknown() if !unknown { @@ -30,7 +28,7 @@ func (v Value) checkForComputed(ctyType cty.Type) (change.Change, bool) { Before: v.Before, BeforeSensitive: v.BeforeSensitive, } - return v.AsChange(change.Computed(beforeValue.ComputeChangeForType(ctyType))), true + return v.AsChange(change.Computed(beforeValue.ComputeChange(changeType))), true } func (v Value) isUnknown() bool { diff --git a/internal/command/jsonformat/differ/object.go b/internal/command/jsonformat/differ/object.go new file mode 100644 index 0000000000..90346cba88 --- /dev/null +++ b/internal/command/jsonformat/differ/object.go @@ -0,0 +1,61 @@ +package differ + +import ( + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/command/jsonformat/change" + "github.com/hashicorp/terraform/internal/command/jsonprovider" + "github.com/hashicorp/terraform/internal/plans" +) + +func (v Value) computeAttributeChangeAsObject(attributes map[string]cty.Type) change.Change { + var keys []string + for key := range attributes { + keys = append(keys, key) + } + + attributeChanges, changeType := v.processObject(keys, func(key string) interface{} { + return attributes[key] + }) + return change.New(change.Object(attributeChanges), changeType, v.replacePath()) +} + +func (v Value) computeAttributeChangeAsNestedObject(attributes map[string]*jsonprovider.Attribute) change.Change { + var keys []string + for key := range attributes { + keys = append(keys, key) + } + + attributeChanges, changeType := v.processObject(keys, func(key string) interface{} { + return attributes[key] + }) + return change.New(change.NestedObject(attributeChanges), changeType, v.replacePath()) +} + +func (v Value) processObject(keys []string, getAttribute func(string) interface{}) (map[string]change.Change, plans.Action) { + attributeChanges := make(map[string]change.Change) + mapValue := v.asMap() + + currentAction := v.getDefaultActionForIteration() + for _, key := range keys { + attribute := getAttribute(key) + attributeValue := mapValue.getChild(key) + + // We always assume changes to object are implicit. + attributeValue.BeforeExplicit = false + attributeValue.AfterExplicit = false + + // We use the generic ComputeChange here, as we don't know whether this + // is from a nested object or a `normal` object. + attributeChange := attributeValue.ComputeChange(attribute) + if attributeChange.GetAction() == plans.NoOp && attributeValue.Before == nil && attributeValue.After == nil { + // We skip attributes of objects that are null both before and + // after. We don't even count these as unchanged attributes. + continue + } + attributeChanges[key] = attributeChange + currentAction = compareActions(currentAction, attributeChange.GetAction()) + } + + return attributeChanges, currentAction +} diff --git a/internal/command/jsonformat/differ/value.go b/internal/command/jsonformat/differ/value.go index b24681b32b..215cb69a94 100644 --- a/internal/command/jsonformat/differ/value.go +++ b/internal/command/jsonformat/differ/value.go @@ -18,7 +18,7 @@ import ( // jsonprovider). // // A Value can be converted into a change.Change, ready for rendering, with the -// ComputeChangeForAttribute, ComputeChangeForOutput, and ComputeChangeForBlock +// computeChangeForAttribute, ComputeChangeForOutput, and ComputeChangeForBlock // functions. // // The Before and After fields are actually go-cty values, but we cannot convert @@ -99,16 +99,26 @@ func ValueFromJsonChange(change jsonplan.Change) Value { } // ComputeChange is a generic function that lets callers no worry about what -// type of change they are processing. +// type of change they are processing. In general, this is the function external +// users should call as it has some generic preprocessing applicable to all +// types. // // It can accept blocks, attributes, go-cty types, and outputs, and will route // the request to the appropriate function. func (v Value) ComputeChange(changeType interface{}) change.Change { + if sensitive, ok := v.checkForSensitive(); ok { + return sensitive + } + + if computed, ok := v.checkForComputed(changeType); ok { + return computed + } + switch concrete := changeType.(type) { case *jsonprovider.Attribute: - return v.ComputeChangeForAttribute(concrete) + return v.computeChangeForAttribute(concrete) case cty.Type: - return v.ComputeChangeForType(concrete) + return v.computeChangeForType(concrete) default: panic(fmt.Sprintf("unrecognized change type: %T", changeType)) } @@ -140,6 +150,43 @@ func (v Value) calculateChange() plans.Action { return plans.Update } +// getDefaultActionForIteration is used to guess what the change could be for +// complex attributes (collections and objects) and blocks. +// +// You can't really tell the difference between a NoOp and an Update just by +// looking at the attribute itself as you need to inspect the children. +// +// This function returns a Delete or a Create action if the before or after +// values were null, and returns a NoOp for all other cases. It should be used +// in conjunction with compareActions to calculate the actual action based on +// the actions of the children. +func (v Value) getDefaultActionForIteration() plans.Action { + if v.Before == nil && v.After == nil { + return plans.NoOp + } + + if v.Before == nil { + return plans.Create + } + if v.After == nil { + return plans.Delete + } + return plans.NoOp +} + +// compareActions will compare current and next, and return plans.Update if they +// are different, and current if they are the same. +// +// This function should be used in conjunction with getDefaultActionForIteration +// to convert a NoOp default action into an Update based on the actions of a +// values children. +func compareActions(current, next plans.Action) plans.Action { + if current != next { + return plans.Update + } + return current +} + func unmarshalGeneric(raw json.RawMessage) interface{} { if raw == nil { return nil diff --git a/internal/command/jsonformat/differ/value_map.go b/internal/command/jsonformat/differ/value_map.go new file mode 100644 index 0000000000..3cb803023c --- /dev/null +++ b/internal/command/jsonformat/differ/value_map.go @@ -0,0 +1,69 @@ +package differ + +// ValueMap is a Value that represents a Map or an Object type, and has +// converted the relevant interfaces into maps for easier access. +type ValueMap struct { + // Before contains the value before the proposed change. + Before map[string]interface{} + + // After contains the value after the proposed change. + After map[string]interface{} + + // Unknown contains the unknown status of any elements/attributes of this + // map/object. + Unknown map[string]interface{} + + // BeforeSensitive contains the before sensitive status of any + // elements/attributes of this map/object. + BeforeSensitive map[string]interface{} + + // AfterSensitive contains the after sensitive status of any + // elements/attributes of this map/object. + AfterSensitive map[string]interface{} +} + +func (v Value) asMap() ValueMap { + return ValueMap{ + Before: genericToMap(v.Before), + After: genericToMap(v.After), + Unknown: genericToMap(v.Unknown), + BeforeSensitive: genericToMap(v.BeforeSensitive), + AfterSensitive: genericToMap(v.AfterSensitive), + } +} + +func (m ValueMap) getChild(key string) Value { + before, beforeExplicit := getFromGenericMap(m.Before, key) + after, afterExplicit := getFromGenericMap(m.After, key) + unknown, _ := getFromGenericMap(m.Unknown, key) + beforeSensitive, _ := getFromGenericMap(m.BeforeSensitive, key) + afterSensitive, _ := getFromGenericMap(m.AfterSensitive, key) + + return Value{ + BeforeExplicit: beforeExplicit, + AfterExplicit: afterExplicit, + Before: before, + After: after, + Unknown: unknown, + BeforeSensitive: beforeSensitive, + AfterSensitive: afterSensitive, + } +} + +func getFromGenericMap(generic map[string]interface{}, key string) (interface{}, bool) { + if generic == nil { + return nil, false + } + + if child, ok := generic[key]; ok { + return child, ok + } + return nil, false +} + +func genericToMap(generic interface{}) map[string]interface{} { + if concrete, ok := generic.(map[string]interface{}); ok { + return concrete + } + return nil +} diff --git a/internal/command/jsonformat/differ/value_test.go b/internal/command/jsonformat/differ/value_test.go index 1c556fd727..1d36bae014 100644 --- a/internal/command/jsonformat/differ/value_test.go +++ b/internal/command/jsonformat/differ/value_test.go @@ -1,20 +1,382 @@ package differ import ( + "encoding/json" + "fmt" "testing" + "github.com/zclconf/go-cty/cty" + ctyjson "github.com/zclconf/go-cty/cty/json" + "github.com/hashicorp/terraform/internal/command/jsonformat/change" "github.com/hashicorp/terraform/internal/command/jsonprovider" "github.com/hashicorp/terraform/internal/plans" ) +func TestValue_ObjectAttributes(t *testing.T) { + // We break these tests out into their own function, so we can automatically + // test both objects and nested objects together. + + tcs := map[string]struct { + input Value + attributes map[string]cty.Type + validateSingleChange change.ValidateChangeFunc + validateObject change.ValidateChangeFunc + validateNestedObject change.ValidateChangeFunc + validateChanges map[string]change.ValidateChangeFunc + validateReplace bool + validateAction plans.Action + }{ + "object_create": { + input: Value{ + Before: nil, + After: map[string]interface{}{ + "attribute_one": "new", + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), + }, + validateAction: plans.Create, + validateReplace: false, + }, + "object_delete": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + After: nil, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + }, + validateAction: plans.Delete, + validateReplace: false, + }, + "object_create_sensitive": { + input: Value{ + Before: nil, + After: map[string]interface{}{ + "attribute_one": "new", + }, + AfterSensitive: true, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateSingleChange: change.ValidateSensitive(nil, map[string]interface{}{ + "attribute_one": "new", + }, false, true, plans.Create, false), + }, + "object_delete_sensitive": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + BeforeSensitive: true, + After: nil, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateSingleChange: change.ValidateSensitive(map[string]interface{}{ + "attribute_one": "old", + }, nil, true, false, plans.Delete, false), + }, + "object_create_unknown": { + input: Value{ + Before: nil, + After: nil, + Unknown: true, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateSingleChange: change.ValidateComputed(nil, plans.Create, false), + }, + "object_update_unknown": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + After: nil, + Unknown: true, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateObject: change.ValidateComputed(change.ValidateObject(map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + }, plans.Delete, false), plans.Update, false), + validateNestedObject: change.ValidateComputed(change.ValidateNestedObject(map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + }, plans.Delete, false), plans.Update, false), + }, + "object_create_attribute": { + input: Value{ + Before: map[string]interface{}{}, + After: map[string]interface{}{ + "attribute_one": "new", + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_create_attribute_from_explicit_null": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": nil, + }, + After: map[string]interface{}{ + "attribute_one": "new", + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_delete_attribute": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + After: map[string]interface{}{}, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_delete_attribute_to_explicit_null": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + After: map[string]interface{}{ + "attribute_one": nil, + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_update_attribute": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + After: map[string]interface{}{ + "attribute_one": "new", + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_create_sensitive_attribute": { + input: Value{ + Before: map[string]interface{}{}, + After: map[string]interface{}{ + "attribute_one": "new", + }, + AfterSensitive: map[string]interface{}{ + "attribute_one": true, + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidateSensitive(nil, "new", false, true, plans.Create, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_delete_sensitive_attribute": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + BeforeSensitive: map[string]interface{}{ + "attribute_one": true, + }, + After: map[string]interface{}{}, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidateSensitive("old", nil, true, false, plans.Delete, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_update_sensitive_attribute": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + BeforeSensitive: map[string]interface{}{ + "attribute_one": true, + }, + After: map[string]interface{}{ + "attribute_one": "new", + }, + AfterSensitive: map[string]interface{}{ + "attribute_one": true, + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidateSensitive("old", "new", true, true, plans.Update, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_create_computed_attribute": { + input: Value{ + Before: map[string]interface{}{}, + After: map[string]interface{}{}, + Unknown: map[string]interface{}{ + "attribute_one": true, + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidateComputed(nil, plans.Create, false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_update_computed_attribute": { + input: Value{ + Before: map[string]interface{}{ + "attribute_one": "old", + }, + After: map[string]interface{}{}, + Unknown: map[string]interface{}{ + "attribute_one": true, + }, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{ + "attribute_one": change.ValidateComputed( + change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + plans.Update, + false), + }, + validateAction: plans.Update, + validateReplace: false, + }, + "object_ignores_unset_fields": { + input: Value{ + Before: map[string]interface{}{}, + After: map[string]interface{}{}, + }, + attributes: map[string]cty.Type{ + "attribute_one": cty.String, + }, + validateChanges: map[string]change.ValidateChangeFunc{}, + validateAction: plans.NoOp, + validateReplace: false, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + + attribute := &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.Object(tc.attributes)), + } + + if tc.validateObject != nil { + tc.validateObject(t, tc.input.ComputeChange(attribute)) + return + } + + if tc.validateSingleChange != nil { + tc.validateSingleChange(t, tc.input.ComputeChange(attribute)) + return + } + + validate := change.ValidateObject(tc.validateChanges, tc.validateAction, tc.validateReplace) + validate(t, tc.input.ComputeChange(attribute)) + }) + + t.Run(fmt.Sprintf("nested_%s", name), func(t *testing.T) { + attribute := &jsonprovider.Attribute{ + AttributeNestedType: &jsonprovider.NestedType{ + Attributes: func() map[string]*jsonprovider.Attribute { + attributes := make(map[string]*jsonprovider.Attribute) + for key, attribute := range tc.attributes { + attributes[key] = &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, attribute), + } + } + return attributes + }(), + NestingMode: "single", + }, + } + + if tc.validateNestedObject != nil { + tc.validateNestedObject(t, tc.input.ComputeChange(attribute)) + return + } + + if tc.validateSingleChange != nil { + tc.validateSingleChange(t, tc.input.ComputeChange(attribute)) + return + } + + validate := change.ValidateNestedObject(tc.validateChanges, tc.validateAction, tc.validateReplace) + validate(t, tc.input.ComputeChange(attribute)) + }) + } +} + func TestValue_Attribute(t *testing.T) { tcs := map[string]struct { - input Value - attribute *jsonprovider.Attribute - expectedAction plans.Action - expectedReplace bool - validateChange change.ValidateChangeFunc + input Value + attribute *jsonprovider.Attribute + validateChange change.ValidateChangeFunc }{ "primitive_create": { input: Value{ @@ -23,8 +385,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Create, - validateChange: change.ValidatePrimitive(nil, strptr("\"new\"")), + validateChange: change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), }, "primitive_delete": { input: Value{ @@ -33,8 +394,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Delete, - validateChange: change.ValidatePrimitive(strptr("\"old\""), nil), + validateChange: change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), }, "primitive_update": { input: Value{ @@ -44,8 +404,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Update, - validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\"")), + validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, false), }, "primitive_set_explicit_null": { input: Value{ @@ -56,8 +415,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Update, - validateChange: change.ValidatePrimitive(strptr("\"old\""), nil), + validateChange: change.ValidatePrimitive(strptr("\"old\""), nil, plans.Update, false), }, "primitive_unset_explicit_null": { input: Value{ @@ -68,8 +426,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Update, - validateChange: change.ValidatePrimitive(nil, strptr("\"new\"")), + validateChange: change.ValidatePrimitive(nil, strptr("\"new\""), plans.Update, false), }, "primitive_create_sensitive": { input: Value{ @@ -80,8 +437,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Create, - validateChange: change.ValidateSensitive(nil, "new", false, true), + validateChange: change.ValidateSensitive(nil, "new", false, true, plans.Create, false), }, "primitive_delete_sensitive": { input: Value{ @@ -92,8 +448,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Delete, - validateChange: change.ValidateSensitive("old", nil, true, false), + validateChange: change.ValidateSensitive("old", nil, true, false, plans.Delete, false), }, "primitive_update_sensitive": { input: Value{ @@ -105,8 +460,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Update, - validateChange: change.ValidateSensitive("old", "new", true, true), + validateChange: change.ValidateSensitive("old", "new", true, true, plans.Update, false), }, "primitive_create_computed": { input: Value{ @@ -117,8 +471,7 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Create, - validateChange: change.ValidateComputed(nil), + validateChange: change.ValidateComputed(nil, plans.Create, false), }, "primitive_update_computed": { input: Value{ @@ -129,18 +482,20 @@ func TestValue_Attribute(t *testing.T) { attribute: &jsonprovider.Attribute{ AttributeType: []byte("\"string\""), }, - expectedAction: plans.Update, - validateChange: change.ValidateComputed(change.ValidatePrimitive(strptr("\"old\""), nil)), + validateChange: change.ValidateComputed(change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), plans.Update, false), }, } for name, tc := range tcs { t.Run(name, func(t *testing.T) { - change.ValidateChange( - t, - tc.validateChange, - tc.input.ComputeChangeForAttribute(tc.attribute), - tc.expectedAction, - tc.expectedReplace) + tc.validateChange(t, tc.input.ComputeChange(tc.attribute)) }) } } + +func unmarshalType(t *testing.T, ctyType cty.Type) json.RawMessage { + msg, err := ctyjson.MarshalType(ctyType) + if err != nil { + t.Fatalf("invalid type: %s", ctyType.FriendlyName()) + } + return msg +}