From aff94591c1383bb9cb412858a8f4d26275110453 Mon Sep 17 00:00:00 2001 From: Liam Cervante Date: Mon, 9 Jan 2023 14:06:38 +0100 Subject: [PATCH] Add support for lists in the structured renderer (#32401) * 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 * Add support for the replace paths data in the structured renderer * Add support for maps in the structured renderer * Add support for lists in the structured renderer * goimports * add additional comments explaining --- internal/command/jsonformat/change/list.go | 120 +++++++ .../jsonformat/change/renderer_test.go | 320 ++++++++++++++++++ internal/command/jsonformat/change/testing.go | 44 +++ .../command/jsonformat/differ/attribute.go | 6 +- internal/command/jsonformat/differ/list.go | 165 +++++++++ internal/command/jsonformat/differ/map.go | 3 +- .../command/jsonformat/differ/value_map.go | 3 + .../command/jsonformat/differ/value_slice.go | 96 ++++++ .../command/jsonformat/differ/value_test.go | 254 +++++++++++++- 9 files changed, 998 insertions(+), 13 deletions(-) create mode 100644 internal/command/jsonformat/change/list.go create mode 100644 internal/command/jsonformat/differ/list.go create mode 100644 internal/command/jsonformat/differ/value_slice.go diff --git a/internal/command/jsonformat/change/list.go b/internal/command/jsonformat/change/list.go new file mode 100644 index 0000000000..b169c4c6df --- /dev/null +++ b/internal/command/jsonformat/change/list.go @@ -0,0 +1,120 @@ +package change + +import ( + "bytes" + "fmt" + + "github.com/hashicorp/terraform/internal/command/format" + "github.com/hashicorp/terraform/internal/plans" +) + +func List(elements []Change) Renderer { + return &listRenderer{ + displayContext: true, + elements: elements, + } +} + +func NestedList(elements []Change) Renderer { + return &listRenderer{ + elements: elements, + } +} + +type listRenderer struct { + NoWarningsRenderer + + // displayContext tells the renderer to display additional information about + // the before and after index values within a given list. For example, index + // + displayContext bool + elements []Change +} + +func (renderer listRenderer) Render(change Change, indent int, opts RenderOpts) string { + if len(renderer.elements) == 0 { + return fmt.Sprintf("[]%s%s", change.nullSuffix(opts.overrideNullSuffix), change.forcesReplacement()) + } + + elementOpts := opts.Clone() + elementOpts.overrideNullSuffix = true + + unchangedElementOpts := opts.Clone() + unchangedElementOpts.showUnchangedChildren = true + + var unchangedElements []Change + + // renderNext tells the renderer to print out the next element in the list + // whatever state it is in. So, even if a change is a NoOp we will still + // print it out if the last change we processed wants us to. + renderNext := false + + var buf bytes.Buffer + buf.WriteString(fmt.Sprintf("[%s\n", change.forcesReplacement())) + for _, element := range renderer.elements { + if element.action == plans.NoOp && !renderNext && !opts.showUnchangedChildren { + unchangedElements = append(unchangedElements, element) + continue + } + renderNext = false + + // If we want to display the context around this change, we want to + // render the change immediately before this change in the list, and the + // change immediately after in the list, even if both these changes are + // NoOps. This will give the user reading the diff some context as to + // where in the list these changes are being made, as order matters. + if renderer.displayContext { + // If our list of unchanged elements contains more than one entry + // we'll print out a count of the number of unchanged elements that + // we skipped. Note, this is the length of the unchanged elements + // minus 1 as the most recent unchanged element will be printed out + // in full. + if len(unchangedElements) > 1 { + buf.WriteString(fmt.Sprintf("%s%s %s\n", change.indent(indent+1), change.emptySymbol(), change.unchanged("element", len(unchangedElements)-1))) + } + // If our list of unchanged elements contains at least one entry, + // we're going to print out the most recent change in full. That's + // what happens here. + if len(unchangedElements) > 0 { + lastElement := unchangedElements[len(unchangedElements)-1] + buf.WriteString(fmt.Sprintf("%s %s,\n", change.indent(indent+1), lastElement.Render(indent+1, unchangedElementOpts))) + } + // We now reset the unchanged elements list, we've printed out a + // count of all the elements we skipped so we start counting from + // scratch again. This means that if we process a run of changed + // elements, they won't all start printing out summaries of every + // change that happened previously. + unchangedElements = nil + + // As we also want to render the element immediately after any + // changes, we make a note here to say we should render the next + // change whatever it is. But, we only want to render the next + // change if the current change isn't a NoOp. If the current change + // is a NoOp then it was told to print by the last change and we + // don't want to cascade and print all changes from now on. + renderNext = element.action != plans.NoOp + } + + for _, warning := range element.Warnings(indent + 1) { + buf.WriteString(fmt.Sprintf("%s%s\n", change.indent(indent+1), warning)) + } + if element.action == plans.NoOp { + buf.WriteString(fmt.Sprintf("%s %s,\n", change.indent(indent+1), element.Render(indent+1, unchangedElementOpts))) + } else { + buf.WriteString(fmt.Sprintf("%s%s %s,\n", change.indent(indent+1), format.DiffActionSymbol(element.action), element.Render(indent+1, elementOpts))) + } + } + + // If we were not displaying any context alongside our changes then the + // unchangedElements list will contain every unchanged element, and we'll + // print that out as we do with every other collection. + // + // If we were displaying context, then this will contain any unchanged + // elements since our last change, so we should also print it out. + if len(unchangedElements) > 0 { + buf.WriteString(fmt.Sprintf("%s%s %s\n", change.indent(indent+1), change.emptySymbol(), change.unchanged("element", len(unchangedElements)))) + } + + 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_test.go b/internal/command/jsonformat/change/renderer_test.go index ffef22f37c..29368fda75 100644 --- a/internal/command/jsonformat/change/renderer_test.go +++ b/internal/command/jsonformat/change/renderer_test.go @@ -518,6 +518,24 @@ func TestRenderers(t *testing.T) { { ~ "element_one" = (sensitive) } +`, + }, + "map_update_sensitive_element_status": { + change: Change{ + renderer: Map(map[string]Change{ + "element_one": { + renderer: Sensitive(0, 0, true, false), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +{ + # Warning: this attribute value will no longer be marked as sensitive + # after applying this change. The value is unchanged. + ~ "element_one" = (sensitive) + } `, }, "map_delete_sensitive_element": { @@ -569,6 +587,308 @@ func TestRenderers(t *testing.T) { { ~ "element_one" = 1 -> (known after apply) } +`, + }, + "list_create_empty": { + change: Change{ + renderer: List([]Change{}), + action: plans.Create, + }, + expected: "[]", + }, + "list_create": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(nil, strptr("1")), + action: plans.Create, + }, + }), + action: plans.Create, + }, + expected: ` +[ + + 1, + ] +`, + }, + "list_delete_empty": { + change: Change{ + renderer: List([]Change{}), + action: plans.Delete, + }, + expected: "[] -> null", + }, + "list_delete": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(strptr("1"), nil), + action: plans.Delete, + }, + }), + action: plans.Delete, + }, + expected: ` +[ + - 1, + ] -> null +`, + }, + "list_create_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(nil, strptr("1")), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +[ + + 1, + ] +`, + }, + "list_update_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(strptr("0"), strptr("1")), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +[ + ~ 0 -> 1, + ] +`, + }, + "list_replace_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(strptr("0"), nil), + action: plans.Delete, + }, + { + renderer: Primitive(nil, strptr("1")), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +[ + - 0, + + 1, + ] +`, + }, + "list_delete_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(strptr("0"), nil), + action: plans.Delete, + }, + }), + action: plans.Update, + }, + expected: ` +[ + - 0, + ] +`, + }, + "list_update_forces_replacement": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(strptr("0"), strptr("1")), + action: plans.Update, + }, + }), + action: plans.Update, + replace: true, + }, + expected: ` +[ # forces replacement + ~ 0 -> 1, + ] +`, + }, + "list_update_ignores_unchanged": { + change: Change{ + renderer: NestedList([]Change{ + { + renderer: Primitive(strptr("0"), strptr("0")), + action: plans.NoOp, + }, + { + renderer: Primitive(strptr("1"), strptr("1")), + action: plans.NoOp, + }, + { + renderer: Primitive(strptr("2"), strptr("5")), + action: plans.Update, + }, + { + renderer: Primitive(strptr("3"), strptr("3")), + action: plans.NoOp, + }, + { + renderer: Primitive(strptr("4"), strptr("4")), + action: plans.NoOp, + }, + }), + action: plans.Update, + }, + expected: ` +[ + ~ 2 -> 5, + # (4 unchanged elements hidden) + ] +`, + }, + "list_update_ignored_unchanged_with_context": { + change: Change{ + renderer: List([]Change{ + { + renderer: Primitive(strptr("0"), strptr("0")), + action: plans.NoOp, + }, + { + renderer: Primitive(strptr("1"), strptr("1")), + action: plans.NoOp, + }, + { + renderer: Primitive(strptr("2"), strptr("5")), + action: plans.Update, + }, + { + renderer: Primitive(strptr("3"), strptr("3")), + action: plans.NoOp, + }, + { + renderer: Primitive(strptr("4"), strptr("4")), + action: plans.NoOp, + }, + }), + action: plans.Update, + }, + expected: ` +[ + # (1 unchanged element hidden) + 1, + ~ 2 -> 5, + 3, + # (1 unchanged element hidden) + ] +`, + }, + "list_create_sensitive_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Sensitive(nil, 1, false, true), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +[ + + (sensitive), + ] +`, + }, + "list_delete_sensitive_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Sensitive(1, nil, true, false), + action: plans.Delete, + }, + }), + action: plans.Update, + }, + expected: ` +[ + - (sensitive), + ] +`, + }, + "list_update_sensitive_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Sensitive(nil, 1, false, true), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +[ + ~ (sensitive), + ] +`, + }, + "list_update_sensitive_element_status": { + change: Change{ + renderer: List([]Change{ + { + renderer: Sensitive(1, 1, false, true), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +[ + # Warning: this attribute value will be marked as sensitive and will not + # display in UI output after applying this change. The value is unchanged. + ~ (sensitive), + ] +`, + }, + "list_create_computed_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Computed(Change{}), + action: plans.Create, + }, + }), + action: plans.Update, + }, + expected: ` +[ + + (known after apply), + ] +`, + }, + "list_update_computed_element": { + change: Change{ + renderer: List([]Change{ + { + renderer: Computed(Change{ + renderer: Primitive(strptr("0"), nil), + action: plans.Delete, + }), + action: plans.Update, + }, + }), + action: plans.Update, + }, + expected: ` +[ + ~ 0 -> (known after apply), + ] `, }, } diff --git a/internal/command/jsonformat/change/testing.go b/internal/command/jsonformat/change/testing.go index e8b368518b..f7e752d915 100644 --- a/internal/command/jsonformat/change/testing.go +++ b/internal/command/jsonformat/change/testing.go @@ -126,6 +126,50 @@ func ValidateMap(elements map[string]ValidateChangeFunc, action plans.Action, re } } +func ValidateList(elements []ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc { + return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + + list, ok := change.renderer.(*listRenderer) + if !ok { + t.Fatalf("invalid renderer type: %T", change.renderer) + } + + if !list.displayContext { + t.Fatalf("created the wrong type of list renderer") + } + + validateList(t, list, elements) + } +} + +func ValidateNestedList(elements []ValidateChangeFunc, action plans.Action, replace bool) ValidateChangeFunc { + return func(t *testing.T, change Change) { + validateChange(t, change, action, replace) + + list, ok := change.renderer.(*listRenderer) + if !ok { + t.Fatalf("invalid renderer type: %T", change.renderer) + } + + if list.displayContext { + t.Fatalf("created the wrong type of list renderer") + } + + validateList(t, list, elements) + } +} + +func validateList(t *testing.T, list *listRenderer, elements []ValidateChangeFunc) { + if len(list.elements) != len(elements) { + t.Fatalf("expected %d elements but found %d elements", len(elements), len(list.elements)) + } + + for ix := 0; ix < len(elements); ix++ { + elements[ix](t, list.elements[ix]) + } +} + 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) diff --git a/internal/command/jsonformat/differ/attribute.go b/internal/command/jsonformat/differ/attribute.go index 8f62bab77a..cd6e506b79 100644 --- a/internal/command/jsonformat/differ/attribute.go +++ b/internal/command/jsonformat/differ/attribute.go @@ -21,6 +21,8 @@ func (v Value) computeChangeForNestedAttribute(attribute *jsonprovider.NestedTyp return v.computeAttributeChangeAsNestedObject(attribute.Attributes) case "map": return v.computeAttributeChangeAsNestedMap(attribute.Attributes) + case "list": + return v.computeAttributeChangeAsNestedList(attribute.Attributes) default: panic("unrecognized nesting mode: " + attribute.NestingMode) } @@ -34,8 +36,10 @@ func (v Value) computeChangeForType(ctyType cty.Type) change.Change { return v.computeAttributeChangeAsObject(ctyType.AttributeTypes()) case ctyType.IsMapType(): return v.computeAttributeChangeAsMap(ctyType.ElementType()) + case ctyType.IsListType(): + return v.computeAttributeChangeAsList(ctyType.ElementType()) default: - panic("not implemented") + panic("unrecognized type: " + ctyType.FriendlyName()) } } diff --git a/internal/command/jsonformat/differ/list.go b/internal/command/jsonformat/differ/list.go new file mode 100644 index 0000000000..93bc5ee61e --- /dev/null +++ b/internal/command/jsonformat/differ/list.go @@ -0,0 +1,165 @@ +package differ + +import ( + "reflect" + + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/internal/command/jsonformat/change" + "github.com/hashicorp/terraform/internal/command/jsonprovider" +) + +func (v Value) computeAttributeChangeAsList(elementType cty.Type) change.Change { + var elements []change.Change + current := v.getDefaultActionForIteration() + v.processList(elementType, func(value Value) { + element := value.ComputeChange(elementType) + elements = append(elements, element) + current = compareActions(current, element.GetAction()) + }) + return change.New(change.List(elements), current, v.replacePath()) +} + +func (v Value) computeAttributeChangeAsNestedList(attributes map[string]*jsonprovider.Attribute) change.Change { + var elements []change.Change + current := v.getDefaultActionForIteration() + v.processNestedList(func(value Value) { + element := value.ComputeChange(attributes) + elements = append(elements, element) + current = compareActions(current, element.GetAction()) + }) + return change.New(change.NestedList(elements), current, v.replacePath()) +} + +func (v Value) processNestedList(process func(value Value)) { + sliceValue := v.asSlice() + for ix := 0; ix < len(sliceValue.Before) || ix < len(sliceValue.After); ix++ { + process(sliceValue.getChild(ix, ix, false)) + } +} + +func (v Value) processList(elementType cty.Type, process func(value Value)) { + sliceValue := v.asSlice() + + lcs := lcs(sliceValue.Before, sliceValue.After) + var beforeIx, afterIx, lcsIx int + for beforeIx < len(sliceValue.Before) || afterIx < len(sliceValue.After) || lcsIx < len(lcs) { + // Step through all the before values until we hit the next item in the + // longest common subsequence. We are going to just say that all of + // these have been deleted. + for beforeIx < len(sliceValue.Before) && (lcsIx >= len(lcs) || !reflect.DeepEqual(sliceValue.Before[beforeIx], lcs[lcsIx])) { + isObjectDiff := elementType.IsObjectType() && afterIx < len(sliceValue.After) && (lcsIx >= len(lcs) || !reflect.DeepEqual(sliceValue.After[afterIx], lcs[lcsIx])) + if isObjectDiff { + process(sliceValue.getChild(beforeIx, afterIx, false)) + beforeIx++ + afterIx++ + continue + } + + process(sliceValue.getChild(beforeIx, len(sliceValue.After), false)) + beforeIx++ + } + + // Now, step through all the after values until hit the next item in the + // LCS. We are going to say that all of these have been created. + for afterIx < len(sliceValue.After) && (lcsIx >= len(lcs) || !reflect.DeepEqual(sliceValue.After[afterIx], lcs[lcsIx])) { + process(sliceValue.getChild(len(sliceValue.Before), afterIx, false)) + afterIx++ + } + + // Finally, add the item in common as unchanged. + if lcsIx < len(lcs) { + process(sliceValue.getChild(beforeIx, afterIx, false)) + beforeIx++ + afterIx++ + lcsIx++ + } + } +} + +func lcs(xs, ys []interface{}) []interface{} { + if len(xs) == 0 || len(ys) == 0 { + return make([]interface{}, 0) + } + + c := make([]int, len(xs)*len(ys)) + eqs := make([]bool, len(xs)*len(ys)) + w := len(xs) + + for y := 0; y < len(ys); y++ { + for x := 0; x < len(xs); x++ { + eq := false + if reflect.DeepEqual(xs[x], ys[y]) { + eq = true + eqs[(w*y)+x] = true // equality tests can be expensive, so cache it + } + if eq { + // Sequence gets one longer than for the cell at top left, + // since we'd append a new item to the sequence here. + if x == 0 || y == 0 { + c[(w*y)+x] = 1 + } else { + c[(w*y)+x] = c[(w*(y-1))+(x-1)] + 1 + } + } else { + // We follow the longest of the sequence above and the sequence + // to the left of us in the matrix. + l := 0 + u := 0 + if x > 0 { + l = c[(w*y)+(x-1)] + } + if y > 0 { + u = c[(w*(y-1))+x] + } + if l > u { + c[(w*y)+x] = l + } else { + c[(w*y)+x] = u + } + } + } + } + + // The bottom right cell tells us how long our longest sequence will be + seq := make([]interface{}, c[len(c)-1]) + + // Now we will walk back from the bottom right cell, finding again all + // of the equal pairs to construct our sequence. + x := len(xs) - 1 + y := len(ys) - 1 + i := len(seq) - 1 + + for x > -1 && y > -1 { + if eqs[(w*y)+x] { + // Add the value to our result list and then walk diagonally + // up and to the left. + seq[i] = xs[x] + x-- + y-- + i-- + } else { + // Take the path with the greatest sequence length in the matrix. + l := 0 + u := 0 + if x > 0 { + l = c[(w*y)+(x-1)] + } + if y > 0 { + u = c[(w*(y-1))+x] + } + if l > u { + x-- + } else { + y-- + } + } + } + + if i > -1 { + // should never happen if the matrix was constructed properly + panic("not enough elements in sequence") + } + + return seq +} diff --git a/internal/command/jsonformat/differ/map.go b/internal/command/jsonformat/differ/map.go index 41236bb6b7..5f4884b86c 100644 --- a/internal/command/jsonformat/differ/map.go +++ b/internal/command/jsonformat/differ/map.go @@ -1,9 +1,10 @@ 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/zclconf/go-cty/cty" ) func (v Value) computeAttributeChangeAsMap(elementType cty.Type) change.Change { diff --git a/internal/command/jsonformat/differ/value_map.go b/internal/command/jsonformat/differ/value_map.go index 252d006b46..9cb3424722 100644 --- a/internal/command/jsonformat/differ/value_map.go +++ b/internal/command/jsonformat/differ/value_map.go @@ -61,6 +61,9 @@ func (m ValueMap) processReplacePaths(key string) []interface{} { path := p.([]interface{}) if len(path) == 0 { + // This means that the current value is causing a replacement but + // not its children, so we skip as we are returning the child's + // value. continue } diff --git a/internal/command/jsonformat/differ/value_slice.go b/internal/command/jsonformat/differ/value_slice.go new file mode 100644 index 0000000000..edef4d4047 --- /dev/null +++ b/internal/command/jsonformat/differ/value_slice.go @@ -0,0 +1,96 @@ +package differ + +type ValueSlice struct { + // Before contains the value before the proposed change. + Before []interface{} + + // After contains the value after the proposed change. + After []interface{} + + // Unknown contains the unknown status of any elements of this list/set. + Unknown []interface{} + + // BeforeSensitive contains the before sensitive status of any elements of + //this list/set. + BeforeSensitive []interface{} + + // AfterSensitive contains the after sensitive status of any elements of + //this list/set. + AfterSensitive []interface{} + + // ReplacePaths matches the same attributes in Value exactly. + ReplacePaths []interface{} +} + +func (v Value) asSlice() ValueSlice { + return ValueSlice{ + Before: genericToSlice(v.Before), + After: genericToSlice(v.After), + Unknown: genericToSlice(v.Unknown), + BeforeSensitive: genericToSlice(v.BeforeSensitive), + AfterSensitive: genericToSlice(v.AfterSensitive), + ReplacePaths: v.ReplacePaths, + } +} + +func (s ValueSlice) getChild(beforeIx, afterIx int, propagateReplace bool) Value { + before, beforeExplicit := getFromGenericSlice(s.Before, beforeIx) + after, afterExplicit := getFromGenericSlice(s.After, afterIx) + unknown, _ := getFromGenericSlice(s.Unknown, afterIx) + beforeSensitive, _ := getFromGenericSlice(s.BeforeSensitive, beforeIx) + afterSensitive, _ := getFromGenericSlice(s.AfterSensitive, afterIx) + + return Value{ + BeforeExplicit: beforeExplicit, + AfterExplicit: afterExplicit, + Before: before, + After: after, + Unknown: unknown, + BeforeSensitive: beforeSensitive, + AfterSensitive: afterSensitive, + ReplacePaths: s.processReplacePaths(beforeIx, propagateReplace), + } +} + +func (s ValueSlice) processReplacePaths(ix int, propagateReplace bool) []interface{} { + var ret []interface{} + for _, p := range s.ReplacePaths { + path := p.([]interface{}) + + if len(path) == 0 { + // This means that the current value is causing a replacement but + // not its children. Normally, we'd skip this as we do with maps + // but sets display the replace suffix on all their children even + // if they themselves are specified, so we want to pass this on. + if propagateReplace { + ret = append(ret, path) + } + // If we don't want to propagate the replace we just skip over this + // entry. If we do, we've added it to the returned set of paths + // already, so we still want to skip over the rest of this. + continue + } + + if int(path[0].(float64)) == ix { + ret = append(ret, path[1:]) + } + } + return ret +} + +func getFromGenericSlice(generic []interface{}, ix int) (interface{}, bool) { + if generic == nil { + return nil, false + } + if ix < 0 || ix >= len(generic) { + return nil, false + } + return generic[ix], true +} + +func genericToSlice(generic interface{}) []interface{} { + if concrete, ok := generic.([]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 5f725fee7f..cbd0ca5541 100644 --- a/internal/command/jsonformat/differ/value_test.go +++ b/internal/command/jsonformat/differ/value_test.go @@ -422,6 +422,34 @@ func TestValue_ObjectAttributes(t *testing.T) { validate(t, input.ComputeChange(attribute)) }) + t.Run("list", func(t *testing.T) { + attribute := &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.Object(tc.attributes))), + } + + input := wrapValueInSlice(tc.input) + + if tc.validateObject != nil { + validate := change.ValidateList([]change.ValidateChangeFunc{ + tc.validateObject, + }, collectionDefaultAction, false) + validate(t, input.ComputeChange(attribute)) + return + } + + if tc.validateSingleChange != nil { + validate := change.ValidateList([]change.ValidateChangeFunc{ + tc.validateSingleChange, + }, collectionDefaultAction, false) + validate(t, input.ComputeChange(attribute)) + return + } + + validate := change.ValidateList([]change.ValidateChangeFunc{ + change.ValidateObject(tc.validateChanges, tc.validateAction, tc.validateReplace), + }, collectionDefaultAction, false) + validate(t, input.ComputeChange(attribute)) + }) }) t.Run(fmt.Sprintf("nested_%s", name), func(t *testing.T) { @@ -494,6 +522,46 @@ func TestValue_ObjectAttributes(t *testing.T) { }, collectionDefaultAction, false) validate(t, input.ComputeChange(attribute)) }) + + t.Run("list", 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: "list", + }, + } + + input := wrapValueInSlice(tc.input) + + if tc.validateNestedObject != nil { + validate := change.ValidateNestedList([]change.ValidateChangeFunc{ + tc.validateNestedObject, + }, collectionDefaultAction, false) + validate(t, input.ComputeChange(attribute)) + return + } + + if tc.validateSingleChange != nil { + validate := change.ValidateNestedList([]change.ValidateChangeFunc{ + tc.validateSingleChange, + }, collectionDefaultAction, false) + validate(t, input.ComputeChange(attribute)) + return + } + + validate := change.ValidateNestedList([]change.ValidateChangeFunc{ + change.ValidateNestedObject(tc.validateChanges, tc.validateAction, tc.validateReplace), + }, collectionDefaultAction, false) + validate(t, input.ComputeChange(attribute)) + }) }) } } @@ -504,9 +572,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { // contexts of collections. tcs := map[string]struct { - input Value - attribute cty.Type - validateChange change.ValidateChangeFunc + input Value + attribute cty.Type + validateChange change.ValidateChangeFunc + validateListChanges []change.ValidateChangeFunc // Lists are special in some cases. }{ "primitive_create": { input: Value{ @@ -529,6 +598,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, attribute: cty.String, validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, false), + validateListChanges: []change.ValidateChangeFunc{ + change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), + }, }, "primitive_set_explicit_null": { input: Value{ @@ -538,6 +611,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, attribute: cty.String, validateChange: change.ValidatePrimitive(strptr("\"old\""), nil, plans.Update, false), + validateListChanges: []change.ValidateChangeFunc{ + change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + change.ValidatePrimitive(nil, nil, plans.Create, false), + }, }, "primitive_unset_explicit_null": { input: Value{ @@ -547,6 +624,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, attribute: cty.String, validateChange: change.ValidatePrimitive(nil, strptr("\"new\""), plans.Update, false), + validateListChanges: []change.ValidateChangeFunc{ + change.ValidatePrimitive(nil, nil, plans.Delete, false), + change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), + }, }, "primitive_create_sensitive": { input: Value{ @@ -575,6 +656,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, attribute: cty.String, validateChange: change.ValidateSensitive("old", "new", true, true, plans.Update, false), + validateListChanges: []change.ValidateChangeFunc{ + change.ValidateSensitive("old", nil, true, false, plans.Delete, false), + change.ValidateSensitive(nil, "new", false, true, plans.Create, false), + }, }, "primitive_create_computed": { input: Value{ @@ -593,6 +678,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, attribute: cty.String, validateChange: change.ValidateComputed(change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), plans.Update, false), + validateListChanges: []change.ValidateChangeFunc{ + change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, false), + change.ValidateComputed(nil, plans.Create, false), + }, }, "primitive_update_replace": { input: Value{ @@ -604,6 +693,10 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, attribute: cty.String, validateChange: change.ValidatePrimitive(strptr("\"old\""), strptr("\"new\""), plans.Update, true), + validateListChanges: []change.ValidateChangeFunc{ + change.ValidatePrimitive(strptr("\"old\""), nil, plans.Delete, true), + change.ValidatePrimitive(nil, strptr("\"new\""), plans.Create, false), + }, }, "noop": { input: Value{ @@ -640,6 +733,24 @@ func TestValue_PrimitiveAttributes(t *testing.T) { }, defaultCollectionsAction, false) validate(t, input.ComputeChange(attribute)) }) + + t.Run("list", func(t *testing.T) { + input := wrapValueInSlice(tc.input) + attribute := &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(tc.attribute)), + } + + if tc.validateListChanges != nil { + validate := change.ValidateList(tc.validateListChanges, defaultCollectionsAction, false) + validate(t, input.ComputeChange(attribute)) + return + } + + validate := change.ValidateList([]change.ValidateChangeFunc{ + tc.validateChange, + }, defaultCollectionsAction, false) + validate(t, input.ComputeChange(attribute)) + }) }) } } @@ -765,6 +876,108 @@ func TestValue_CollectionAttributes(t *testing.T) { }, validateChange: change.ValidateComputed(change.ValidateMap(nil, plans.Delete, false), plans.Update, false), }, + "list_create_empty": { + input: Value{ + Before: nil, + After: []interface{}{}, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateList(nil, plans.Create, false), + }, + "list_create_populated": { + input: Value{ + Before: nil, + After: []interface{}{"one", "two"}, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateList([]change.ValidateChangeFunc{ + change.ValidatePrimitive(nil, strptr("\"one\""), plans.Create, false), + change.ValidatePrimitive(nil, strptr("\"two\""), plans.Create, false), + }, plans.Create, false), + }, + "list_delete_empty": { + input: Value{ + Before: []interface{}{}, + After: nil, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateList(nil, plans.Delete, false), + }, + "list_delete_populated": { + input: Value{ + Before: []interface{}{"one", "two"}, + After: nil, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateList([]change.ValidateChangeFunc{ + change.ValidatePrimitive(strptr("\"one\""), nil, plans.Delete, false), + change.ValidatePrimitive(strptr("\"two\""), nil, plans.Delete, false), + }, plans.Delete, false), + }, + "list_create_sensitive": { + input: Value{ + Before: nil, + After: []interface{}{}, + AfterSensitive: true, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateSensitive(nil, []interface{}{}, false, true, plans.Create, false), + }, + "list_update_sensitive": { + input: Value{ + Before: []interface{}{"one"}, + BeforeSensitive: true, + After: []interface{}{}, + AfterSensitive: true, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateSensitive([]interface{}{"one"}, []interface{}{}, true, true, plans.Update, false), + }, + "list_delete_sensitive": { + input: Value{ + Before: []interface{}{}, + BeforeSensitive: true, + After: nil, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateSensitive([]interface{}{}, nil, true, false, plans.Delete, false), + }, + "list_create_unknown": { + input: Value{ + Before: nil, + After: []interface{}{}, + Unknown: true, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateComputed(nil, plans.Create, false), + }, + "list_update_unknown": { + input: Value{ + Before: []interface{}{}, + After: []interface{}{"one"}, + Unknown: true, + }, + attribute: &jsonprovider.Attribute{ + AttributeType: unmarshalType(t, cty.List(cty.String)), + }, + validateChange: change.ValidateComputed(change.ValidateList(nil, plans.Delete, false), plans.Update, false), + }, } for name, tc := range tcs { @@ -785,10 +998,27 @@ func unmarshalType(t *testing.T, ctyType cty.Type) json.RawMessage { return msg } +// wrapValueInSlice does the same as wrapValueInMap, except it wraps it into a +// slice internally. +func wrapValueInSlice(input Value) Value { + return wrapValue(input, float64(0), func(value interface{}, unknown interface{}, explicit bool) interface{} { + switch value.(type) { + case nil: + if set, ok := unknown.(bool); (set && ok) || explicit { + return []interface{}{nil} + + } + return []interface{}{} + default: + return []interface{}{value} + } + }) +} + // wrapValueInMap access a single Value and returns a new Value that represents // a map with a single element. That single element is the input value. func wrapValueInMap(input Value) Value { - tomap := func(value interface{}, unknown interface{}, explicit bool) interface{} { + return wrapValue(input, "element", func(value interface{}, unknown interface{}, explicit bool) interface{} { switch value.(type) { case nil: if set, ok := unknown.(bool); (set && ok) || explicit { @@ -802,20 +1032,22 @@ func wrapValueInMap(input Value) Value { "element": value, } } - } + }) +} +func wrapValue(input Value, step interface{}, wrap func(interface{}, interface{}, bool) interface{}) Value { return Value{ - Before: tomap(input.Before, nil, input.BeforeExplicit), - After: tomap(input.After, input.Unknown, input.AfterExplicit), - Unknown: tomap(input.Unknown, nil, false), - BeforeSensitive: tomap(input.BeforeSensitive, nil, false), - AfterSensitive: tomap(input.AfterSensitive, nil, false), + Before: wrap(input.Before, nil, input.BeforeExplicit), + After: wrap(input.After, input.Unknown, input.AfterExplicit), + Unknown: wrap(input.Unknown, nil, false), + BeforeSensitive: wrap(input.BeforeSensitive, nil, false), + AfterSensitive: wrap(input.AfterSensitive, nil, false), ReplacePaths: func() []interface{} { var ret []interface{} for _, path := range input.ReplacePaths { old := path.([]interface{}) var updated []interface{} - updated = append(updated, "element") + updated = append(updated, step) updated = append(updated, old...) ret = append(ret, updated) }