From fac60ab124b96e184973114dd36806510b4a5ab8 Mon Sep 17 00:00:00 2001 From: Kristin Laemmert Date: Mon, 15 Mar 2021 09:31:23 -0400 Subject: [PATCH] Add formatting for attributes with nested types (#28069) * format/diff: extract attributes-writing logic to a function This is a stepping-stone commit (for easier reviewability, and to prove that tests did not change) as part of writing a NestedType-specific diff printer. * command/format: add support for formatting attributes with NestedTypes This commit adds custom formatting for NestedType attributes. THe logic was mostly copied from the block diff printer, with minor tweaks here and there. I used the (excellent) existing test coverage and added a NestedType attribute to every test. Since the (nested-block specific) test schemas were nearly identical, I added a function that returns the schema with the requested NestingMode. --- command/format/diff.go | 344 ++++++++-- command/format/diff_test.go | 1275 +++++++++++++++++------------------ 2 files changed, 895 insertions(+), 724 deletions(-) diff --git a/command/format/diff.go b/command/format/diff.go index ed1b44403..dac787af6 100644 --- a/command/format/diff.go +++ b/command/format/diff.go @@ -213,56 +213,10 @@ const forcesNewResourceCaption = " [red]# forces replacement[reset]" // and returns true if any differences were found and written func (p *blockBodyDiffPrinter) writeBlockBodyDiff(schema *configschema.Block, old, new cty.Value, indent int, path cty.Path) blockBodyDiffResult { path = ctyEnsurePathCapacity(path, 1) - result := blockBodyDiffResult{} - blankBeforeBlocks := false - { - attrNames := make([]string, 0, len(schema.Attributes)) - attrNameLen := 0 - for name := range schema.Attributes { - oldVal := ctyGetAttrMaybeNull(old, name) - newVal := ctyGetAttrMaybeNull(new, name) - if oldVal.IsNull() && newVal.IsNull() { - // Skip attributes where both old and new values are null - // (we do this early here so that we'll do our value alignment - // based on the longest attribute name that has a change, rather - // than the longest attribute name in the full set.) - continue - } - - attrNames = append(attrNames, name) - if len(name) > attrNameLen { - attrNameLen = len(name) - } - } - sort.Strings(attrNames) - if len(attrNames) > 0 { - blankBeforeBlocks = true - } - - for _, name := range attrNames { - attrS := schema.Attributes[name] - oldVal := ctyGetAttrMaybeNull(old, name) - newVal := ctyGetAttrMaybeNull(new, name) - - result.bodyWritten = true - skipped := p.writeAttrDiff(name, attrS, oldVal, newVal, attrNameLen, indent, path) - if skipped { - result.skippedAttributes++ - } - } - - if result.skippedAttributes > 0 { - noun := "attributes" - if result.skippedAttributes == 1 { - noun = "attribute" - } - p.buf.WriteString("\n") - p.buf.WriteString(strings.Repeat(" ", indent+2)) - p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", result.skippedAttributes, noun))) - } - } + // write the attributes diff + blankBeforeBlocks := p.writeAttrsDiff(schema.Attributes, old, new, indent, path, &result) { blockTypeNames := make([]string, 0, len(schema.BlockTypes)) @@ -299,6 +253,63 @@ func (p *blockBodyDiffPrinter) writeBlockBodyDiff(schema *configschema.Block, ol return result } +func (p *blockBodyDiffPrinter) writeAttrsDiff( + attrsS map[string]*configschema.Attribute, + old, new cty.Value, + indent int, + path cty.Path, + result *blockBodyDiffResult) bool { + + blankBeforeBlocks := false + + attrNames := make([]string, 0, len(attrsS)) + attrNameLen := 0 + for name := range attrsS { + oldVal := ctyGetAttrMaybeNull(old, name) + newVal := ctyGetAttrMaybeNull(new, name) + if oldVal.IsNull() && newVal.IsNull() { + // Skip attributes where both old and new values are null + // (we do this early here so that we'll do our value alignment + // based on the longest attribute name that has a change, rather + // than the longest attribute name in the full set.) + continue + } + + attrNames = append(attrNames, name) + if len(name) > attrNameLen { + attrNameLen = len(name) + } + } + sort.Strings(attrNames) + if len(attrNames) > 0 { + blankBeforeBlocks = true + } + + for _, name := range attrNames { + attrS := attrsS[name] + oldVal := ctyGetAttrMaybeNull(old, name) + newVal := ctyGetAttrMaybeNull(new, name) + + result.bodyWritten = true + skipped := p.writeAttrDiff(name, attrS, oldVal, newVal, attrNameLen, indent, path) + if skipped { + result.skippedAttributes++ + } + } + + if result.skippedAttributes > 0 { + noun := "attributes" + if result.skippedAttributes == 1 { + noun = "attribute" + } + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+2)) + p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", result.skippedAttributes, noun))) + } + + return blankBeforeBlocks +} + // getPlanActionAndShow returns the action value // and a boolean for showJustNew. In this function we // modify the old and new values to remove any possible marks @@ -328,17 +339,9 @@ func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.At return true } - // TODO: There will need to be an object-specific diff printer that handles - // things like individual attribute sensitivity and pathing into - // requiredReplace, but for now we will let writeAttrDiff handle attributes - // with NestedTypes like regular (object) attributes. - // - // To avoid printing any sensitive nested fields inside attributes (until - // the above is implemented) we will treat the entire attribute as - // sensitive. - var sensitive bool - if attrS.NestedType != nil && attrS.NestedType.ContainsSensitive() { - sensitive = true + if attrS.NestedType != nil { + p.writeNestedAttrDiff(name, attrS.NestedType, old, new, nameLen, indent, path, action, showJustNew) + return false } p.buf.WriteString("\n") @@ -354,7 +357,7 @@ func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.At p.buf.WriteString(strings.Repeat(" ", nameLen-len(name))) p.buf.WriteString(" = ") - if attrS.Sensitive || sensitive { + if attrS.Sensitive { p.buf.WriteString("(sensitive value)") } else { switch { @@ -374,19 +377,214 @@ func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.At return false } -// TODO: writeNestedAttrDiff will be responsible for properly formatting -// Attributes with NestedTypes in the diff. This function will be called from -// writeAttrDiff when it recieves attribute with a NestedType. Right now, we are -// letting the existing formatter "just" print these attributes like regular, -// object-type attributes. Unlike the regular attribute printer, this function -// will need to descend into the NestedType to ensure that we are properly -// handling items such as: -// - nested sensitive fields -// - which nested field specifically requires replacement -// -// Examples of both can be seen in diff_test.go with FIXME comments. -func (p *blockBodyDiffPrinter) writeNestedAttrDiff(name string, attrS *configschema.Attribute, old, new cty.Value, nameLen, indent int, path cty.Path) bool { - panic("not implemented") +// writeNestedAttrDiff is responsible for formatting Attributes with NestedTypes +// in the diff. +func (p *blockBodyDiffPrinter) writeNestedAttrDiff( + name string, objS *configschema.Object, old, new cty.Value, + nameLen, indent int, path cty.Path, action plans.Action, showJustNew bool) { + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent)) + p.writeActionSymbol(action) + + p.buf.WriteString(p.color.Color("[bold]")) + p.buf.WriteString(name) + p.buf.WriteString(p.color.Color("[reset]")) + p.buf.WriteString(strings.Repeat(" ", nameLen-len(name))) + + result := &blockBodyDiffResult{} + switch objS.Nesting { + case configschema.NestingSingle: + p.buf.WriteString(" = {") + if action != plans.NoOp && (p.pathForcesNewResource(path) || p.pathForcesNewResource(path[:len(path)-1])) { + p.buf.WriteString(p.color.Color(forcesNewResourceCaption)) + } + p.writeAttrsDiff(objS.Attributes, old, new, indent+2, path, result) + + if result.skippedAttributes > 0 { + noun := "attributes" + if result.skippedAttributes == 1 { + noun = "attribute" + } + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+2)) + p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", result.skippedAttributes, noun))) + } + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent)) + p.buf.WriteString("}") + + case configschema.NestingList: + p.buf.WriteString(" = [") + if action != plans.NoOp && (p.pathForcesNewResource(path) || p.pathForcesNewResource(path[:len(path)-1])) { + p.buf.WriteString(p.color.Color(forcesNewResourceCaption)) + } + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+4)) + p.writeActionSymbol(action) + p.buf.WriteString("{") + + oldItems := ctyCollectionValues(old) + newItems := ctyCollectionValues(new) + // Here we intentionally preserve the index-based correspondance + // between old and new, rather than trying to detect insertions + // and removals in the list, because this more accurately reflects + // how Terraform Core and providers will understand the change, + // particularly when the nested block contains computed attributes + // that will themselves maintain correspondance by index. + + // commonLen is number of elements that exist in both lists, which + // will be presented as updates (~). Any additional items in one + // of the lists will be presented as either creates (+) or deletes (-) + // depending on which list they belong to. + var commonLen int + switch { + case len(oldItems) < len(newItems): + commonLen = len(oldItems) + default: + commonLen = len(newItems) + } + for i := 0; i < commonLen; i++ { + path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))}) + oldItem := oldItems[i] + newItem := newItems[i] + if oldItem.RawEquals(newItem) { + action = plans.NoOp + } + p.writeAttrsDiff(objS.Attributes, oldItem, newItem, indent+6, path, result) + } + for i := commonLen; i < len(oldItems); i++ { + path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))}) + oldItem := oldItems[i] + newItem := cty.NullVal(oldItem.Type()) + p.writeAttrsDiff(objS.Attributes, oldItem, newItem, indent+6, path, result) + } + for i := commonLen; i < len(newItems); i++ { + path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))}) + newItem := newItems[i] + oldItem := cty.NullVal(newItem.Type()) + p.writeAttrsDiff(objS.Attributes, oldItem, newItem, indent+6, path, result) + } + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+4)) + p.buf.WriteString("},\n") + p.buf.WriteString(strings.Repeat(" ", indent+2)) + p.buf.WriteString("]") + + case configschema.NestingSet: + oldItems := ctyCollectionValues(old) + newItems := ctyCollectionValues(new) + + allItems := make([]cty.Value, 0, len(oldItems)+len(newItems)) + allItems = append(allItems, oldItems...) + allItems = append(allItems, newItems...) + all := cty.SetVal(allItems) + + p.buf.WriteString(" = [") + + for it := all.ElementIterator(); it.Next(); { + _, val := it.Element() + var action plans.Action + var oldValue, newValue cty.Value + switch { + case !val.IsKnown(): + action = plans.Update + newValue = val + case !old.HasElement(val).True(): + action = plans.Create + oldValue = cty.NullVal(val.Type()) + newValue = val + case !new.HasElement(val).True(): + action = plans.Delete + oldValue = val + newValue = cty.NullVal(val.Type()) + default: + action = plans.NoOp + oldValue = val + newValue = val + } + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+4)) + p.writeActionSymbol(action) + p.buf.WriteString("{") + + if action != plans.NoOp && (p.pathForcesNewResource(path) || p.pathForcesNewResource(path[:len(path)-1])) { + p.buf.WriteString(p.color.Color(forcesNewResourceCaption)) + } + + path := append(path, cty.IndexStep{Key: val}) + p.writeAttrsDiff(objS.Attributes, oldValue, newValue, indent+6, path, result) + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+4)) + p.buf.WriteString("},") + } + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+2)) + p.buf.WriteString("]") + + case configschema.NestingMap: + oldItems := old.AsValueMap() + newItems := new.AsValueMap() + + allKeys := make(map[string]bool) + for k := range oldItems { + allKeys[k] = true + } + for k := range newItems { + allKeys[k] = true + } + allKeysOrder := make([]string, 0, len(allKeys)) + for k := range allKeys { + allKeysOrder = append(allKeysOrder, k) + } + sort.Strings(allKeysOrder) + + p.buf.WriteString(" = {") + + for _, k := range allKeysOrder { + var action plans.Action + oldValue := oldItems[k] + newValue := newItems[k] + switch { + case oldValue == cty.NilVal: + oldValue = cty.NullVal(newValue.Type()) + action = plans.Create + case newValue == cty.NilVal: + newValue = cty.NullVal(oldValue.Type()) + action = plans.Delete + case !newValue.RawEquals(oldValue): + action = plans.Update + default: + action = plans.NoOp + } + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+4)) + p.writeActionSymbol(action) + + fmt.Fprintf(p.buf, "%q = {", k) + if action != plans.NoOp && (p.pathForcesNewResource(path) || p.pathForcesNewResource(path[:len(path)-1])) { + p.buf.WriteString(p.color.Color(forcesNewResourceCaption)) + } + + path := append(path, cty.IndexStep{Key: cty.StringVal(k)}) + + p.writeAttrsDiff(objS.Attributes, oldValue, newValue, indent+6, path, result) + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+4)) + p.buf.WriteString("},") + } + + p.buf.WriteString("\n") + p.buf.WriteString(strings.Repeat(" ", indent+2)) + p.buf.WriteString("}") + } + + return } func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *configschema.NestedBlock, old, new cty.Value, blankBefore bool, indent int, path cty.Path) int { diff --git a/command/format/diff_test.go b/command/format/diff_test.go index 8c32f4593..a04030509 100644 --- a/command/format/diff_test.go +++ b/command/format/diff_test.go @@ -326,10 +326,6 @@ new line Attributes: map[string]*configschema.Attribute{ "id": {Type: cty.String, Computed: true}, "password": {Type: cty.String, Optional: true, Sensitive: true}, - // FIXME: This is a temporary situation; once the NestedType - // specific printer is implemented this will need to be - // updated so that only the sensitive nested attribute is - // hidden. "conn_info": { NestedType: &configschema.Object{ Nesting: configschema.NestingSingle, @@ -345,7 +341,10 @@ new line Tainted: false, ExpectedOutput: ` # test_instance.example will be created + resource "test_instance" "example" { - + conn_info = (sensitive value) + + conn_info = { + + password = (sensitive value) + + user = "not-secret" + } + id = (known after apply) + password = (sensitive value) } @@ -2251,7 +2250,7 @@ func TestResourceChange_nestedList(t *testing.T) { }), RequiredReplace: cty.NewPathSet(), Tainted: false, - Schema: testSchemaNestingList, + Schema: testSchema(configschema.NestingList), ExpectedOutput: ` # test_instance.example will be updated in-place ~ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" @@ -2280,8 +2279,8 @@ func TestResourceChange_nestedList(t *testing.T) { "id": cty.StringVal("i-02ae66f368e8518a9"), "ami": cty.StringVal("ami-AFTER"), "disks": cty.ListVal([]cty.Value{cty.ObjectVal(map[string]cty.Value{ - "mount_point": cty.NullVal(cty.String), - "size": cty.NullVal(cty.String), + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), })}), "root_block_device": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ @@ -2291,15 +2290,15 @@ func TestResourceChange_nestedList(t *testing.T) { }), RequiredReplace: cty.NewPathSet(), Tainted: false, - Schema: testSchemaNestingList, + Schema: testSchema(configschema.NestingList), ExpectedOutput: ` # test_instance.example will be updated in-place ~ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" - ~ disks = [] -> [ - + { - + mount_point = null - + size = null - }, + ~ disks = [ + ~ { + + mount_point = "/var/diska" + + size = "50GB" + }, ] id = "i-02ae66f368e8518a9" @@ -2338,15 +2337,14 @@ func TestResourceChange_nestedList(t *testing.T) { }), RequiredReplace: cty.NewPathSet(), Tainted: false, - Schema: testSchemaNestingList, + Schema: testSchema(configschema.NestingList), ExpectedOutput: ` # test_instance.example will be updated in-place ~ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" - ~ disks = [] -> [ - + { - + mount_point = "/var/diska" - + size = null - }, + ~ disks = [ + ~ { + + mount_point = "/var/diska" + }, ] id = "i-02ae66f368e8518a9" @@ -2393,48 +2391,15 @@ func TestResourceChange_nestedList(t *testing.T) { }), RequiredReplace: cty.NewPathSet(), Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - "disks": { - NestedType: &configschema.Object{ - Attributes: map[string]*configschema.Attribute{ - "mount_point": {Type: cty.String, Optional: true}, - "size": {Type: cty.String, Optional: true}, - }, - Nesting: configschema.NestingList, - }, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "new_field": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingList, - }, - }, - }, + Schema: testSchemaPlus(configschema.NestingList), ExpectedOutput: ` # test_instance.example will be updated in-place ~ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" ~ disks = [ ~ { - ~ size = null -> "50GB" - # (1 unchanged element hidden) - }, + + size = "50GB" + # (1 unchanged attribute hidden) + }, ] id = "i-02ae66f368e8518a9" @@ -2484,11 +2449,6 @@ func TestResourceChange_nestedList(t *testing.T) { cty.IndexStep{Key: cty.NumberIntVal(0)}, cty.GetAttrStep{Name: "volume_type"}, }, - // FIXME: This is not currently used; when the diff printer is - // updated to fully handle NestedTypes this test should fail, - // and the expected output should look like this: - // - // ~ mount_point = "/var/diska" -> "/var/diskb" # forces replacement cty.Path{ cty.GetAttrStep{Name: "disks"}, cty.IndexStep{Key: cty.NumberIntVal(0)}, @@ -2496,15 +2456,15 @@ func TestResourceChange_nestedList(t *testing.T) { }, ), Tainted: false, - Schema: testSchemaNestingList, + Schema: testSchema(configschema.NestingList), ExpectedOutput: ` # test_instance.example must be replaced -/+ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" ~ disks = [ ~ { - ~ mount_point = "/var/diska" -> "/var/diskb" - # (1 unchanged element hidden) - }, + ~ mount_point = "/var/diska" -> "/var/diskb" # forces replacement + # (1 unchanged attribute hidden) + }, ] id = "i-02ae66f368e8518a9" @@ -2552,15 +2512,15 @@ func TestResourceChange_nestedList(t *testing.T) { cty.Path{cty.GetAttrStep{Name: "disks"}}, ), Tainted: false, - Schema: testSchemaNestingList, + Schema: testSchema(configschema.NestingList), ExpectedOutput: ` # test_instance.example must be replaced -/+ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" ~ disks = [ # forces replacement ~ { - ~ mount_point = "/var/diska" -> "/var/diskb" - # (1 unchanged element hidden) - } # forces replacement, + ~ mount_point = "/var/diska" -> "/var/diskb" + # (1 unchanged attribute hidden) + }, ] id = "i-02ae66f368e8518a9" @@ -2601,16 +2561,16 @@ func TestResourceChange_nestedList(t *testing.T) { }), RequiredReplace: cty.NewPathSet(), Tainted: false, - Schema: testSchemaNestingList, + Schema: testSchema(configschema.NestingList), ExpectedOutput: ` # test_instance.example will be updated in-place ~ resource "test_instance" "example" { ~ ami = "ami-BEFORE" -> "ami-AFTER" ~ disks = [ - - { - - mount_point = "/var/diska" - - size = "50GB" - }, - ] -> [] + ~ { + - mount_point = "/var/diska" -> null + - size = "50GB" -> null + }, + ] id = "i-02ae66f368e8518a9" - root_block_device { @@ -2658,559 +2618,6 @@ func TestResourceChange_nestedList(t *testing.T) { + attr = true } } -`, - }, - } - runTestCases(t, testCases) -} - -func TestResourceChange_nestedSet(t *testing.T) { - testCases := map[string]testCase{ - "in-place update - creation": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.SetValEmpty(cty.Object(map[string]cty.Type{ - "volume_type": cty.String, - })), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingSet, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - + root_block_device { - + volume_type = "gp2" - } - } -`, - }, - "in-place update - insertion": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.NullVal(cty.String), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.StringVal("new_value"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "new_field": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingSet, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - + root_block_device { - + new_field = "new_value" - + volume_type = "gp2" - } - - root_block_device { - - volume_type = "gp2" -> null - } - } -`, - }, - "force-new update (whole block)": { - Action: plans.DeleteThenCreate, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("different"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(cty.Path{ - cty.GetAttrStep{Name: "root_block_device"}, - }), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingSet, - }, - }, - }, - ExpectedOutput: ` # test_instance.example must be replaced --/+ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - + root_block_device { # forces replacement - + volume_type = "different" - } - - root_block_device { # forces replacement - - volume_type = "gp2" -> null - } - } -`, - }, - "in-place update - deletion": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.StringVal("new_value"), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.SetValEmpty(cty.Object(map[string]cty.Type{ - "volume_type": cty.String, - "new_field": cty.String, - })), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "new_field": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingSet, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - - root_block_device { - - new_field = "new_value" -> null - - volume_type = "gp2" -> null - } - } -`, - }, - } - runTestCases(t, testCases) -} - -func TestResourceChange_nestedMap(t *testing.T) { - testCases := map[string]testCase{ - "in-place update - creation": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.MapValEmpty(cty.Object(map[string]cty.Type{ - "volume_type": cty.String, - })), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingMap, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - + root_block_device "a" { - + volume_type = "gp2" - } - } -`, - }, - "in-place update - change attr": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.NullVal(cty.String), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.StringVal("new_value"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "new_field": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingMap, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - ~ root_block_device "a" { - + new_field = "new_value" - # (1 unchanged attribute hidden) - } - } -`, - }, - "in-place update - insertion": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.NullVal(cty.String), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.StringVal("new_value"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "new_field": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingMap, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - + root_block_device "b" { - + new_field = "new_value" - + volume_type = "gp2" - } - # (1 unchanged block hidden) - } -`, - }, - "force-new update (whole block)": { - Action: plans.DeleteThenCreate, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("standard"), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("different"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("standard"), - }), - }), - }), - RequiredReplace: cty.NewPathSet(cty.Path{ - cty.GetAttrStep{Name: "root_block_device"}, - cty.IndexStep{Key: cty.StringVal("a")}, - }), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingMap, - }, - }, - }, - ExpectedOutput: ` # test_instance.example must be replaced --/+ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - ~ root_block_device "a" { # forces replacement - ~ volume_type = "gp2" -> "different" - } - # (1 unchanged block hidden) - } -`, - }, - "in-place update - deletion": { - Action: plans.Update, - Mode: addrs.ManagedResourceMode, - Before: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-BEFORE"), - "root_block_device": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "volume_type": cty.StringVal("gp2"), - "new_field": cty.StringVal("new_value"), - }), - }), - }), - After: cty.ObjectVal(map[string]cty.Value{ - "id": cty.StringVal("i-02ae66f368e8518a9"), - "ami": cty.StringVal("ami-AFTER"), - "root_block_device": cty.MapValEmpty(cty.Object(map[string]cty.Type{ - "volume_type": cty.String, - "new_field": cty.String, - })), - }), - RequiredReplace: cty.NewPathSet(), - Tainted: false, - Schema: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "new_field": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - Nesting: configschema.NestingMap, - }, - }, - }, - ExpectedOutput: ` # test_instance.example will be updated in-place - ~ resource "test_instance" "example" { - ~ ami = "ami-BEFORE" -> "ami-AFTER" - id = "i-02ae66f368e8518a9" - - - root_block_device "a" { - - new_field = "new_value" -> null - - volume_type = "gp2" -> null - } - } `, }, "in-place sequence update - deletion": { @@ -3260,6 +2667,532 @@ func TestResourceChange_nestedMap(t *testing.T) { runTestCases(t, testCases) } +func TestResourceChange_nestedSet(t *testing.T) { + testCases := map[string]testCase{ + "in-place update - creation": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.SetValEmpty(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.String, + })), + "root_block_device": cty.SetValEmpty(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.NullVal(cty.String), + }), + }), + "root_block_device": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + }), + }), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchema(configschema.NestingSet), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = [ + + { + + mount_point = "/var/diska" + }, + ] + id = "i-02ae66f368e8518a9" + + + root_block_device { + + volume_type = "gp2" + } + } +`, + }, + "in-place update - insertion": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.NullVal(cty.String), + }), + }), + "root_block_device": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.NullVal(cty.String), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + "root_block_device": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.StringVal("new_value"), + }), + }), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchemaPlus(configschema.NestingSet), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = [ + + { + + mount_point = "/var/diska" + + size = "50GB" + }, + - { + - mount_point = "/var/diska" -> null + }, + ] + id = "i-02ae66f368e8518a9" + + + root_block_device { + + new_field = "new_value" + + volume_type = "gp2" + } + - root_block_device { + - volume_type = "gp2" -> null + } + } +`, + }, + "force-new update (whole block)": { + Action: plans.DeleteThenCreate, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "root_block_device": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + }), + }), + "disks": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "root_block_device": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("different"), + }), + }), + "disks": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diskb"), + "size": cty.StringVal("50GB"), + }), + }), + }), + RequiredReplace: cty.NewPathSet( + cty.Path{cty.GetAttrStep{Name: "root_block_device"}}, + cty.Path{cty.GetAttrStep{Name: "disks"}}, + ), + Tainted: false, + Schema: testSchema(configschema.NestingSet), + ExpectedOutput: ` # test_instance.example must be replaced +-/+ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = [ + - { # forces replacement + - mount_point = "/var/diska" -> null + - size = "50GB" -> null + }, + + { # forces replacement + + mount_point = "/var/diskb" + + size = "50GB" + }, + ] + id = "i-02ae66f368e8518a9" + + + root_block_device { # forces replacement + + volume_type = "different" + } + - root_block_device { # forces replacement + - volume_type = "gp2" -> null + } + } +`, + }, + "in-place update - deletion": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "root_block_device": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.StringVal("new_value"), + }), + }), + "disks": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "root_block_device": cty.SetValEmpty(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + "new_field": cty.String, + })), + "disks": cty.SetValEmpty(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.String, + })), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchemaPlus(configschema.NestingSet), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = [ + - { + - mount_point = "/var/diska" -> null + - size = "50GB" -> null + }, + ] + id = "i-02ae66f368e8518a9" + + - root_block_device { + - new_field = "new_value" -> null + - volume_type = "gp2" -> null + } + } +`, + }, + } + runTestCases(t, testCases) +} + +func TestResourceChange_nestedMap(t *testing.T) { + testCases := map[string]testCase{ + "in-place update - creation": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.String, + })), + "root_block_device": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + })), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.NullVal(cty.String), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + }), + }), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchema(configschema.NestingMap), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = { + + "disk_a" = { + + mount_point = "/var/diska" + }, + } + id = "i-02ae66f368e8518a9" + + + root_block_device "a" { + + volume_type = "gp2" + } + } +`, + }, + "in-place update - change attr": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.NullVal(cty.String), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.NullVal(cty.String), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.StringVal("new_value"), + }), + }), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchemaPlus(configschema.NestingMap), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = { + ~ "disk_a" = { + + size = "50GB" + # (1 unchanged attribute hidden) + }, + } + id = "i-02ae66f368e8518a9" + + ~ root_block_device "a" { + + new_field = "new_value" + # (1 unchanged attribute hidden) + } + } +`, + }, + "in-place update - insertion": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.NullVal(cty.String), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + "disk_2": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/disk2"), + "size": cty.StringVal("50GB"), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.StringVal("new_value"), + }), + }), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchemaPlus(configschema.NestingMap), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = { + + "disk_2" = { + + mount_point = "/var/disk2" + + size = "50GB" + }, + "disk_a" = { + # (2 unchanged attributes hidden) + }, + } + id = "i-02ae66f368e8518a9" + + + root_block_device "b" { + + new_field = "new_value" + + volume_type = "gp2" + } + # (1 unchanged block hidden) + } +`, + }, + "force-new update (whole block)": { + Action: plans.DeleteThenCreate, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("standard"), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("100GB"), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("different"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("standard"), + }), + }), + }), + RequiredReplace: cty.NewPathSet(cty.Path{ + cty.GetAttrStep{Name: "root_block_device"}, + cty.IndexStep{Key: cty.StringVal("a")}, + }, + cty.Path{cty.GetAttrStep{Name: "disks"}}, + ), + Tainted: false, + Schema: testSchema(configschema.NestingMap), + ExpectedOutput: ` # test_instance.example must be replaced +-/+ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = { + ~ "disk_a" = { # forces replacement + ~ size = "50GB" -> "100GB" + # (1 unchanged attribute hidden) + }, + } + id = "i-02ae66f368e8518a9" + + ~ root_block_device "a" { # forces replacement + ~ volume_type = "gp2" -> "different" + } + # (1 unchanged block hidden) + } +`, + }, + "in-place update - deletion": { + Action: plans.Update, + Mode: addrs.ManagedResourceMode, + Before: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-BEFORE"), + "disks": cty.MapVal(map[string]cty.Value{ + "disk_a": cty.ObjectVal(map[string]cty.Value{ + "mount_point": cty.StringVal("/var/diska"), + "size": cty.StringVal("50GB"), + }), + }), + "root_block_device": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "volume_type": cty.StringVal("gp2"), + "new_field": cty.StringVal("new_value"), + }), + }), + }), + After: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("i-02ae66f368e8518a9"), + "ami": cty.StringVal("ami-AFTER"), + "disks": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "mount_point": cty.String, + "size": cty.String, + })), + "root_block_device": cty.MapValEmpty(cty.Object(map[string]cty.Type{ + "volume_type": cty.String, + "new_field": cty.String, + })), + }), + RequiredReplace: cty.NewPathSet(), + Tainted: false, + Schema: testSchemaPlus(configschema.NestingMap), + ExpectedOutput: ` # test_instance.example will be updated in-place + ~ resource "test_instance" "example" { + ~ ami = "ami-BEFORE" -> "ami-AFTER" + ~ disks = { + - "disk_a" = { + - mount_point = "/var/diska" -> null + - size = "50GB" -> null + }, + } + id = "i-02ae66f368e8518a9" + + - root_block_device "a" { + - new_field = "new_value" -> null + - volume_type = "gp2" -> null + } + } +`, + }, + } + runTestCases(t, testCases) +} + func TestResourceChange_sensitiveVariable(t *testing.T) { testCases := map[string]testCase{ "creation": { @@ -4343,33 +4276,73 @@ func outputChange(name string, before, after cty.Value, sensitive bool) *plans.O return changeSrc } -// A basic test schema using NestingList for one attribute and one block -var testSchemaNestingList = &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "id": {Type: cty.String, Optional: true, Computed: true}, - "ami": {Type: cty.String, Optional: true}, - "disks": { - NestedType: &configschema.Object{ - Attributes: map[string]*configschema.Attribute{ - "mount_point": {Type: cty.String, Optional: true}, - "size": {Type: cty.String, Optional: true}, +// A basic test schema using a configurable NestingMode for one (NestedType) attribute and one block +func testSchema(nesting configschema.NestingMode) *configschema.Block { + return &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "ami": {Type: cty.String, Optional: true}, + "disks": { + NestedType: &configschema.Object{ + Attributes: map[string]*configschema.Attribute{ + "mount_point": {Type: cty.String, Optional: true}, + "size": {Type: cty.String, Optional: true}, + }, + Nesting: nesting, }, - Nesting: configschema.NestingList, }, }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "root_block_device": { - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "volume_type": { - Type: cty.String, - Optional: true, - Computed: true, + BlockTypes: map[string]*configschema.NestedBlock{ + "root_block_device": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "volume_type": { + Type: cty.String, + Optional: true, + Computed: true, + }, }, }, + Nesting: nesting, }, - Nesting: configschema.NestingList, }, - }, + } +} + +// similar to testSchema with the addition of a "new_field" block +func testSchemaPlus(nesting configschema.NestingMode) *configschema.Block { + return &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "id": {Type: cty.String, Optional: true, Computed: true}, + "ami": {Type: cty.String, Optional: true}, + "disks": { + NestedType: &configschema.Object{ + Attributes: map[string]*configschema.Attribute{ + "mount_point": {Type: cty.String, Optional: true}, + "size": {Type: cty.String, Optional: true}, + }, + Nesting: nesting, + }, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "root_block_device": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "volume_type": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "new_field": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + Nesting: nesting, + }, + }, + } }