command: Add experimental concise diff renderer

When rendering a diff between current state and projected state, we only
show resources and outputs which have changes. However, we show a full
structural diff for these values, which includes all attributes and
blocks for a changed resource or output. The result can be a very long
diff, which makes it difficult to verify what the changed fields are.

This commit adds an experimental concise diff renderer, which suppresses
most unchanged fields, only displaying the most relevant changes and
some identifying context. This means:

- Always show all identifying attributes, initially defined as `id`,
  `name`, and `tags`, even if unchanged;
- Only show changed, added, or removed primitive values: `string`,
  `number`, or `bool`;
- Only show added or removed elements in unordered collections and
  structural types: `map`, `set`, and `object`;
- Show added or removed elements with any surrounding unchanged elements
  for sequence types: `list` and `tuple`;
- Only show added or removed nested blocks, or blocks with changed
  attributes.

If any attributes, collection elements, or blocks are hidden, a count
is kept and displayed at the end of the parent scope. This ensures that
it is clear that the diff is only displaying a subset of the resource.

The experiment is currently enabled by default, but can be disabled by
setting the TF_X_CONCISE_DIFF environment variable to 0.
This commit is contained in:
Alisdair McDiarmid 2020-08-19 16:47:56 -04:00
parent ee544aa4e8
commit 09d8355f43
5 changed files with 783 additions and 57 deletions

View File

@ -289,12 +289,9 @@ Terraform will perform the following actions:
# test_instance.foo is tainted, so must be replaced # test_instance.foo is tainted, so must be replaced
-/+ resource "test_instance" "foo" { -/+ resource "test_instance" "foo" {
ami = "bar" # (1 unchanged attribute hidden)
network_interface { # (1 unchanged block hidden)
description = "Main network interface"
device_index = 0
}
} }
Plan: 1 to add, 0 to change, 1 to destroy.` Plan: 1 to add, 0 to change, 1 to destroy.`
@ -468,12 +465,9 @@ Terraform will perform the following actions:
# test_instance.foo is tainted, so must be replaced # test_instance.foo is tainted, so must be replaced
+/- resource "test_instance" "foo" { +/- resource "test_instance" "foo" {
ami = "bar" # (1 unchanged attribute hidden)
network_interface { # (1 unchanged block hidden)
description = "Main network interface"
device_index = 0
}
} }
Plan: 1 to add, 0 to change, 1 to destroy.` Plan: 1 to add, 0 to change, 1 to destroy.`

View File

@ -14,6 +14,7 @@ import (
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/helper/experiment"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/plans/objchange" "github.com/hashicorp/terraform/plans/objchange"
"github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states"
@ -98,6 +99,7 @@ func ResourceChange(
color: color, color: color,
action: change.Action, action: change.Action,
requiredReplace: change.RequiredReplace, requiredReplace: change.RequiredReplace,
concise: experiment.Enabled(experiment.X_concise_diff),
} }
// Most commonly-used resources have nested blocks that result in us // Most commonly-used resources have nested blocks that result in us
@ -123,10 +125,10 @@ func ResourceChange(
changeV.Change.Before = objchange.NormalizeObjectFromLegacySDK(changeV.Change.Before, schema) changeV.Change.Before = objchange.NormalizeObjectFromLegacySDK(changeV.Change.Before, schema)
changeV.Change.After = objchange.NormalizeObjectFromLegacySDK(changeV.Change.After, schema) changeV.Change.After = objchange.NormalizeObjectFromLegacySDK(changeV.Change.After, schema)
bodyWritten := p.writeBlockBodyDiff(schema, changeV.Before, changeV.After, 6, path) result := p.writeBlockBodyDiff(schema, changeV.Before, changeV.After, 6, path)
if bodyWritten { if result.bodyWritten {
buf.WriteString("\n") p.buf.WriteString("\n")
buf.WriteString(strings.Repeat(" ", 4)) p.buf.WriteString(strings.Repeat(" ", 4))
} }
buf.WriteString("}\n") buf.WriteString("}\n")
@ -144,9 +146,10 @@ func OutputChanges(
) string { ) string {
var buf bytes.Buffer var buf bytes.Buffer
p := blockBodyDiffPrinter{ p := blockBodyDiffPrinter{
buf: &buf, buf: &buf,
color: color, color: color,
action: plans.Update, // not actually used in this case, because we're not printing a containing block action: plans.Update, // not actually used in this case, because we're not printing a containing block
concise: experiment.Enabled(experiment.X_concise_diff),
} }
// We're going to reuse the codepath we used for printing resource block // We're going to reuse the codepath we used for printing resource block
@ -189,16 +192,24 @@ type blockBodyDiffPrinter struct {
color *colorstring.Colorize color *colorstring.Colorize
action plans.Action action plans.Action
requiredReplace cty.PathSet requiredReplace cty.PathSet
concise bool
}
type blockBodyDiffResult struct {
bodyWritten bool
skippedAttributes int
skippedBlocks int
} }
const forcesNewResourceCaption = " [red]# forces replacement[reset]" const forcesNewResourceCaption = " [red]# forces replacement[reset]"
// writeBlockBodyDiff writes attribute or block differences // writeBlockBodyDiff writes attribute or block differences
// and returns true if any differences were found and written // 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) bool { func (p *blockBodyDiffPrinter) writeBlockBodyDiff(schema *configschema.Block, old, new cty.Value, indent int, path cty.Path) blockBodyDiffResult {
path = ctyEnsurePathCapacity(path, 1) path = ctyEnsurePathCapacity(path, 1)
bodyWritten := false result := blockBodyDiffResult{}
blankBeforeBlocks := false blankBeforeBlocks := false
{ {
attrNames := make([]string, 0, len(schema.Attributes)) attrNames := make([]string, 0, len(schema.Attributes))
@ -229,8 +240,21 @@ func (p *blockBodyDiffPrinter) writeBlockBodyDiff(schema *configschema.Block, ol
oldVal := ctyGetAttrMaybeNull(old, name) oldVal := ctyGetAttrMaybeNull(old, name)
newVal := ctyGetAttrMaybeNull(new, name) newVal := ctyGetAttrMaybeNull(new, name)
bodyWritten = true result.bodyWritten = true
p.writeAttrDiff(name, attrS, oldVal, newVal, attrNameLen, indent, path) 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)))
} }
} }
@ -246,21 +270,31 @@ func (p *blockBodyDiffPrinter) writeBlockBodyDiff(schema *configschema.Block, ol
oldVal := ctyGetAttrMaybeNull(old, name) oldVal := ctyGetAttrMaybeNull(old, name)
newVal := ctyGetAttrMaybeNull(new, name) newVal := ctyGetAttrMaybeNull(new, name)
bodyWritten = true result.bodyWritten = true
p.writeNestedBlockDiffs(name, blockS, oldVal, newVal, blankBeforeBlocks, indent, path) skippedBlocks := p.writeNestedBlockDiffs(name, blockS, oldVal, newVal, blankBeforeBlocks, indent, path)
if skippedBlocks > 0 {
result.skippedBlocks += skippedBlocks
}
// Always include a blank for any subsequent block types. // Always include a blank for any subsequent block types.
blankBeforeBlocks = true blankBeforeBlocks = true
} }
if result.skippedBlocks > 0 {
noun := "blocks"
if result.skippedBlocks == 1 {
noun = "block"
}
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.skippedBlocks, noun)))
}
} }
return bodyWritten return result
} }
func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.Attribute, old, new cty.Value, nameLen, indent int, path cty.Path) { func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.Attribute, old, new cty.Value, nameLen, indent int, path cty.Path) bool {
path = append(path, cty.GetAttrStep{Name: name}) path = append(path, cty.GetAttrStep{Name: name})
p.buf.WriteString("\n")
p.buf.WriteString(strings.Repeat(" ", indent))
showJustNew := false showJustNew := false
var action plans.Action var action plans.Action
switch { switch {
@ -276,6 +310,12 @@ func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.At
action = plans.Update action = plans.Update
} }
if action == plans.NoOp && p.concise && !identifyingAttribute(name, attrS) {
return true
}
p.buf.WriteString("\n")
p.buf.WriteString(strings.Repeat(" ", indent))
p.writeActionSymbol(action) p.writeActionSymbol(action)
p.buf.WriteString(p.color.Color("[bold]")) p.buf.WriteString(p.color.Color("[bold]"))
@ -300,13 +340,16 @@ func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.At
p.writeValueDiff(old, new, indent+2, path) p.writeValueDiff(old, new, indent+2, path)
} }
} }
return false
} }
func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *configschema.NestedBlock, old, new cty.Value, blankBefore bool, indent int, path cty.Path) { func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *configschema.NestedBlock, old, new cty.Value, blankBefore bool, indent int, path cty.Path) int {
skippedBlocks := 0
path = append(path, cty.GetAttrStep{Name: name}) path = append(path, cty.GetAttrStep{Name: name})
if old.IsNull() && new.IsNull() { if old.IsNull() && new.IsNull() {
// Nothing to do if both old and new is null // Nothing to do if both old and new is null
return return skippedBlocks
} }
// Where old/new are collections representing a nesting mode other than // Where old/new are collections representing a nesting mode other than
@ -335,7 +378,10 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *config
if blankBefore { if blankBefore {
p.buf.WriteRune('\n') p.buf.WriteRune('\n')
} }
p.writeNestedBlockDiff(name, nil, &blockS.Block, action, old, new, indent, path) skipped := p.writeNestedBlockDiff(name, nil, &blockS.Block, action, old, new, indent, path)
if skipped {
return 1
}
case configschema.NestingList: case configschema.NestingList:
// For the sake of handling nested blocks, we'll treat a null list // For the sake of handling nested blocks, we'll treat a null list
// the same as an empty list since the config language doesn't // the same as an empty list since the config language doesn't
@ -377,19 +423,28 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *config
if oldItem.RawEquals(newItem) { if oldItem.RawEquals(newItem) {
action = plans.NoOp action = plans.NoOp
} }
p.writeNestedBlockDiff(name, nil, &blockS.Block, action, oldItem, newItem, indent, path) skipped := p.writeNestedBlockDiff(name, nil, &blockS.Block, action, oldItem, newItem, indent, path)
if skipped {
skippedBlocks++
}
} }
for i := commonLen; i < len(oldItems); i++ { for i := commonLen; i < len(oldItems); i++ {
path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))}) path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))})
oldItem := oldItems[i] oldItem := oldItems[i]
newItem := cty.NullVal(oldItem.Type()) newItem := cty.NullVal(oldItem.Type())
p.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Delete, oldItem, newItem, indent, path) skipped := p.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Delete, oldItem, newItem, indent, path)
if skipped {
skippedBlocks++
}
} }
for i := commonLen; i < len(newItems); i++ { for i := commonLen; i < len(newItems); i++ {
path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))}) path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))})
newItem := newItems[i] newItem := newItems[i]
oldItem := cty.NullVal(newItem.Type()) oldItem := cty.NullVal(newItem.Type())
p.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Create, oldItem, newItem, indent, path) skipped := p.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Create, oldItem, newItem, indent, path)
if skipped {
skippedBlocks++
}
} }
case configschema.NestingSet: case configschema.NestingSet:
// For the sake of handling nested blocks, we'll treat a null set // For the sake of handling nested blocks, we'll treat a null set
@ -403,7 +458,7 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *config
if (len(oldItems) + len(newItems)) == 0 { if (len(oldItems) + len(newItems)) == 0 {
// Nothing to do if both sets are empty // Nothing to do if both sets are empty
return return 0
} }
allItems := make([]cty.Value, 0, len(oldItems)+len(newItems)) allItems := make([]cty.Value, 0, len(oldItems)+len(newItems))
@ -437,7 +492,10 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *config
newValue = val newValue = val
} }
path := append(path, cty.IndexStep{Key: val}) path := append(path, cty.IndexStep{Key: val})
p.writeNestedBlockDiff(name, nil, &blockS.Block, action, oldValue, newValue, indent, path) skipped := p.writeNestedBlockDiff(name, nil, &blockS.Block, action, oldValue, newValue, indent, path)
if skipped {
skippedBlocks++
}
} }
case configschema.NestingMap: case configschema.NestingMap:
@ -451,7 +509,7 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *config
newItems := new.AsValueMap() newItems := new.AsValueMap()
if (len(oldItems) + len(newItems)) == 0 { if (len(oldItems) + len(newItems)) == 0 {
// Nothing to do if both maps are empty // Nothing to do if both maps are empty
return return 0
} }
allKeys := make(map[string]bool) allKeys := make(map[string]bool)
@ -489,12 +547,20 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *config
} }
path := append(path, cty.IndexStep{Key: cty.StringVal(k)}) path := append(path, cty.IndexStep{Key: cty.StringVal(k)})
p.writeNestedBlockDiff(name, &k, &blockS.Block, action, oldValue, newValue, indent, path) skipped := p.writeNestedBlockDiff(name, &k, &blockS.Block, action, oldValue, newValue, indent, path)
if skipped {
skippedBlocks++
}
} }
} }
return skippedBlocks
} }
func (p *blockBodyDiffPrinter) writeNestedBlockDiff(name string, label *string, blockS *configschema.Block, action plans.Action, old, new cty.Value, indent int, path cty.Path) { func (p *blockBodyDiffPrinter) writeNestedBlockDiff(name string, label *string, blockS *configschema.Block, action plans.Action, old, new cty.Value, indent int, path cty.Path) bool {
if action == plans.NoOp && p.concise {
return true
}
p.buf.WriteString("\n") p.buf.WriteString("\n")
p.buf.WriteString(strings.Repeat(" ", indent)) p.buf.WriteString(strings.Repeat(" ", indent))
p.writeActionSymbol(action) p.writeActionSymbol(action)
@ -509,12 +575,14 @@ func (p *blockBodyDiffPrinter) writeNestedBlockDiff(name string, label *string,
p.buf.WriteString(p.color.Color(forcesNewResourceCaption)) p.buf.WriteString(p.color.Color(forcesNewResourceCaption))
} }
bodyWritten := p.writeBlockBodyDiff(blockS, old, new, indent+4, path) result := p.writeBlockBodyDiff(blockS, old, new, indent+4, path)
if bodyWritten { if result.bodyWritten {
p.buf.WriteString("\n") p.buf.WriteString("\n")
p.buf.WriteString(strings.Repeat(" ", indent+2)) p.buf.WriteString(strings.Repeat(" ", indent+2))
} }
p.buf.WriteString("}") p.buf.WriteString("}")
return false
} }
func (p *blockBodyDiffPrinter) writeValue(val cty.Value, action plans.Action, indent int) { func (p *blockBodyDiffPrinter) writeValue(val cty.Value, action plans.Action, indent int) {
@ -819,11 +887,10 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
removed = cty.SetValEmpty(ty.ElementType()) removed = cty.SetValEmpty(ty.ElementType())
} }
suppressedElements := 0
for it := all.ElementIterator(); it.Next(); { for it := all.ElementIterator(); it.Next(); {
_, val := it.Element() _, val := it.Element()
p.buf.WriteString(strings.Repeat(" ", indent+2))
var action plans.Action var action plans.Action
switch { switch {
case !val.IsKnown(): case !val.IsKnown():
@ -836,11 +903,28 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
action = plans.NoOp action = plans.NoOp
} }
if action == plans.NoOp && p.concise {
suppressedElements++
continue
}
p.buf.WriteString(strings.Repeat(" ", indent+2))
p.writeActionSymbol(action) p.writeActionSymbol(action)
p.writeValue(val, action, indent+4) p.writeValue(val, action, indent+4)
p.buf.WriteString(",\n") p.buf.WriteString(",\n")
} }
if suppressedElements > 0 {
p.writeActionSymbol(plans.NoOp)
p.buf.WriteString(strings.Repeat(" ", indent+2))
noun := "elements"
if suppressedElements == 1 {
noun = "element"
}
p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", suppressedElements, noun)))
p.buf.WriteString("\n")
}
p.buf.WriteString(strings.Repeat(" ", indent)) p.buf.WriteString(strings.Repeat(" ", indent))
p.buf.WriteString("]") p.buf.WriteString("]")
return return
@ -852,7 +936,74 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
p.buf.WriteString("\n") p.buf.WriteString("\n")
elemDiffs := ctySequenceDiff(old.AsValueSlice(), new.AsValueSlice()) elemDiffs := ctySequenceDiff(old.AsValueSlice(), new.AsValueSlice())
for _, elemDiff := range elemDiffs {
// Maintain a stack of suppressed lines in the diff for later
// display or elision
var suppressedElements []*plans.Change
var changeShown bool
for i := 0; i < len(elemDiffs); i++ {
// In concise mode, push any no-op diff elements onto the stack
if p.concise {
for i < len(elemDiffs) && elemDiffs[i].Action == plans.NoOp {
suppressedElements = append(suppressedElements, elemDiffs[i])
i++
}
}
// If we have some suppressed elements on the stack…
if len(suppressedElements) > 0 {
// If we've just rendered a change, display the first
// element in the stack as context
if changeShown {
elemDiff := suppressedElements[0]
p.buf.WriteString(strings.Repeat(" ", indent+4))
p.writeValue(elemDiff.After, elemDiff.Action, indent+4)
p.buf.WriteString(",\n")
suppressedElements = suppressedElements[1:]
}
hidden := len(suppressedElements)
// If we're not yet at the end of the list, capture the
// last element on the stack as context for the upcoming
// change to be rendered
var nextContextDiff *plans.Change
if hidden > 0 && i < len(elemDiffs) {
hidden--
nextContextDiff = suppressedElements[hidden]
suppressedElements = suppressedElements[:hidden]
}
// If there are still hidden elements, show an elision
// statement counting them
if hidden > 0 {
p.writeActionSymbol(plans.NoOp)
p.buf.WriteString(strings.Repeat(" ", indent+2))
noun := "elements"
if hidden == 1 {
noun = "element"
}
p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", hidden, noun)))
p.buf.WriteString("\n")
}
// Display the next context diff if it was captured above
if nextContextDiff != nil {
p.buf.WriteString(strings.Repeat(" ", indent+4))
p.writeValue(nextContextDiff.After, nextContextDiff.Action, indent+4)
p.buf.WriteString(",\n")
}
// Suppressed elements have now been handled so clear them again
suppressedElements = nil
}
if i >= len(elemDiffs) {
break
}
elemDiff := elemDiffs[i]
p.buf.WriteString(strings.Repeat(" ", indent+2)) p.buf.WriteString(strings.Repeat(" ", indent+2))
p.writeActionSymbol(elemDiff.Action) p.writeActionSymbol(elemDiff.Action)
switch elemDiff.Action { switch elemDiff.Action {
@ -869,10 +1020,12 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
} }
p.buf.WriteString(",\n") p.buf.WriteString(",\n")
changeShown = true
} }
p.buf.WriteString(strings.Repeat(" ", indent)) p.buf.WriteString(strings.Repeat(" ", indent))
p.buf.WriteString("]") p.buf.WriteString("]")
return return
case ty.IsMapType(): case ty.IsMapType():
@ -903,6 +1056,7 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
sort.Strings(allKeys) sort.Strings(allKeys)
suppressedElements := 0
lastK := "" lastK := ""
for i, k := range allKeys { for i, k := range allKeys {
if i > 0 && lastK == k { if i > 0 && lastK == k {
@ -910,7 +1064,6 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
} }
lastK = k lastK = k
p.buf.WriteString(strings.Repeat(" ", indent+2))
kV := cty.StringVal(k) kV := cty.StringVal(k)
var action plans.Action var action plans.Action
if old.HasIndex(kV).False() { if old.HasIndex(kV).False() {
@ -923,8 +1076,14 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
action = plans.Update action = plans.Update
} }
if action == plans.NoOp && p.concise {
suppressedElements++
continue
}
path := append(path, cty.IndexStep{Key: kV}) path := append(path, cty.IndexStep{Key: kV})
p.buf.WriteString(strings.Repeat(" ", indent+2))
p.writeActionSymbol(action) p.writeActionSymbol(action)
p.writeValue(kV, action, indent+4) p.writeValue(kV, action, indent+4)
p.buf.WriteString(strings.Repeat(" ", keyLen-len(k))) p.buf.WriteString(strings.Repeat(" ", keyLen-len(k)))
@ -946,8 +1105,20 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
p.buf.WriteByte('\n') p.buf.WriteByte('\n')
} }
if suppressedElements > 0 {
p.writeActionSymbol(plans.NoOp)
p.buf.WriteString(strings.Repeat(" ", indent+2))
noun := "elements"
if suppressedElements == 1 {
noun = "element"
}
p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", suppressedElements, noun)))
p.buf.WriteString("\n")
}
p.buf.WriteString(strings.Repeat(" ", indent)) p.buf.WriteString(strings.Repeat(" ", indent))
p.buf.WriteString("}") p.buf.WriteString("}")
return return
case ty.IsObjectType(): case ty.IsObjectType():
p.buf.WriteString("{") p.buf.WriteString("{")
@ -976,6 +1147,7 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
sort.Strings(allKeys) sort.Strings(allKeys)
suppressedElements := 0
lastK := "" lastK := ""
for i, k := range allKeys { for i, k := range allKeys {
if i > 0 && lastK == k { if i > 0 && lastK == k {
@ -983,7 +1155,6 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
} }
lastK = k lastK = k
p.buf.WriteString(strings.Repeat(" ", indent+2))
kV := k kV := k
var action plans.Action var action plans.Action
if !old.Type().HasAttribute(kV) { if !old.Type().HasAttribute(kV) {
@ -996,8 +1167,14 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
action = plans.Update action = plans.Update
} }
if action == plans.NoOp && p.concise {
suppressedElements++
continue
}
path := append(path, cty.GetAttrStep{Name: kV}) path := append(path, cty.GetAttrStep{Name: kV})
p.buf.WriteString(strings.Repeat(" ", indent+2))
p.writeActionSymbol(action) p.writeActionSymbol(action)
p.buf.WriteString(k) p.buf.WriteString(k)
p.buf.WriteString(strings.Repeat(" ", keyLen-len(k))) p.buf.WriteString(strings.Repeat(" ", keyLen-len(k)))
@ -1020,6 +1197,17 @@ func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, pa
p.buf.WriteString("\n") p.buf.WriteString("\n")
} }
if suppressedElements > 0 {
p.writeActionSymbol(plans.NoOp)
p.buf.WriteString(strings.Repeat(" ", indent+2))
noun := "elements"
if suppressedElements == 1 {
noun = "element"
}
p.buf.WriteString(p.color.Color(fmt.Sprintf("[dark_gray]# (%d unchanged %s hidden)[reset]", suppressedElements, noun)))
p.buf.WriteString("\n")
}
p.buf.WriteString(strings.Repeat(" ", indent)) p.buf.WriteString(strings.Repeat(" ", indent))
p.buf.WriteString("}") p.buf.WriteString("}")
@ -1266,3 +1454,11 @@ func DiffActionSymbol(action plans.Action) string {
return " ?" return " ?"
} }
} }
// Extremely coarse heuristic for determining whether or not a given attribute
// name is important for identifying a resource. In the future, this may be
// replaced by a flag in the schema, but for now this is likely to be good
// enough.
func identifyingAttribute(name string, attrSchema *configschema.Attribute) bool {
return name == "id" || name == "tags" || name == "name"
}

View File

@ -4,8 +4,10 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/terraform/addrs" "github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/helper/experiment"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/mitchellh/colorstring" "github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
@ -204,12 +206,19 @@ func TestResourceChange_primitiveTypes(t *testing.T) {
Before: cty.ObjectVal(map[string]cty.Value{ Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"), "id": cty.StringVal("i-02ae66f368e8518a9"),
"more_lines": cty.StringVal(`original "more_lines": cty.StringVal(`original
long
multi-line
string
field
`), `),
}), }),
After: cty.ObjectVal(map[string]cty.Value{ After: cty.ObjectVal(map[string]cty.Value{
"id": cty.UnknownVal(cty.String), "id": cty.UnknownVal(cty.String),
"more_lines": cty.StringVal(`original "more_lines": cty.StringVal(`original
new line extremely long
multi-line
string
field
`), `),
}), }),
Schema: &configschema.Block{ Schema: &configschema.Block{
@ -225,7 +234,11 @@ new line
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ more_lines = <<~EOT ~ more_lines = <<~EOT
original original
+ new line - long
+ extremely long
multi-line
string
field
EOT EOT
} }
`, `,
@ -344,6 +357,13 @@ new line
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "blah" -> (known after apply)
~ str = "before" -> "after"
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
~ id = "blah" -> (known after apply) ~ id = "blah" -> (known after apply)
password = (sensitive value) password = (sensitive value)
@ -435,6 +455,65 @@ new line
+ forced = "example" # forces replacement + forced = "example" # forces replacement
name = "name" name = "name"
} }
`,
},
"show all identifying attributes even if unchanged": {
Action: plans.Update,
Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"),
"ami": cty.StringVal("ami-BEFORE"),
"bar": cty.StringVal("bar"),
"foo": cty.StringVal("foo"),
"name": cty.StringVal("alice"),
"tags": cty.MapVal(map[string]cty.Value{
"name": cty.StringVal("bob"),
}),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"),
"ami": cty.StringVal("ami-AFTER"),
"bar": cty.StringVal("bar"),
"foo": cty.StringVal("foo"),
"name": cty.StringVal("alice"),
"tags": cty.MapVal(map[string]cty.Value{
"name": cty.StringVal("bob"),
}),
}),
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Optional: true, Computed: true},
"ami": {Type: cty.String, Optional: true},
"bar": {Type: cty.String, Optional: true},
"foo": {Type: cty.String, Optional: true},
"name": {Type: cty.String, Optional: true},
"tags": {Type: cty.Map(cty.String), Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9"
name = "alice"
tags = {
"name" = "bob"
}
# (2 unchanged attributes hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
bar = "bar"
foo = "foo"
id = "i-02ae66f368e8518a9"
name = "alice"
tags = {
"name" = "bob"
}
}
`, `,
}, },
} }
@ -489,7 +568,7 @@ func TestResourceChange_JSON(t *testing.T) {
Mode: addrs.ManagedResourceMode, Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{ Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"), "id": cty.StringVal("i-02ae66f368e8518a9"),
"json_field": cty.StringVal(`{"aaa": "value"}`), "json_field": cty.StringVal(`{"aaa": "value","ccc": 5}`),
}), }),
After: cty.ObjectVal(map[string]cty.Value{ After: cty.ObjectVal(map[string]cty.Value{
"id": cty.UnknownVal(cty.String), "id": cty.UnknownVal(cty.String),
@ -504,12 +583,26 @@ func TestResourceChange_JSON(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode(
~ {
+ bbb = "new_value"
- ccc = 5 -> null
# (1 unchanged element hidden)
}
)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode( ~ json_field = jsonencode(
~ { ~ {
aaa = "value" aaa = "value"
+ bbb = "new_value" + bbb = "new_value"
- ccc = 5 -> null
} }
) )
} }
@ -637,6 +730,17 @@ func TestResourceChange_JSON(t *testing.T) {
}), }),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example must be replaced ExpectedOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode(
~ {
+ bbb = "new_value"
# (1 unchanged element hidden)
} # forces replacement
)
}
`,
VerboseOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" { -/+ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode( ~ json_field = jsonencode(
@ -757,6 +861,18 @@ func TestResourceChange_JSON(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode(
~ [
# (1 unchanged element hidden)
"second",
- "third",
]
)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode( ~ json_field = jsonencode(
@ -789,6 +905,19 @@ func TestResourceChange_JSON(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode(
~ [
# (1 unchanged element hidden)
"second",
+ "third",
]
)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode( ~ json_field = jsonencode(
@ -825,8 +954,8 @@ func TestResourceChange_JSON(t *testing.T) {
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ json_field = jsonencode( ~ json_field = jsonencode(
~ { ~ {
first = "111"
+ second = "222" + second = "222"
# (1 unchanged element hidden)
} }
) )
} }
@ -1087,6 +1216,15 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
+ list_field = [
+ "new-element",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1121,6 +1259,15 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [
+ "new-element",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1138,7 +1285,10 @@ func TestResourceChange_primitiveList(t *testing.T) {
"ami": cty.StringVal("ami-STATIC"), "ami": cty.StringVal("ami-STATIC"),
"list_field": cty.ListVal([]cty.Value{ "list_field": cty.ListVal([]cty.Value{
cty.StringVal("aaaa"), cty.StringVal("aaaa"),
cty.StringVal("cccc"), cty.StringVal("bbbb"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
cty.StringVal("ffff"),
}), }),
}), }),
After: cty.ObjectVal(map[string]cty.Value{ After: cty.ObjectVal(map[string]cty.Value{
@ -1148,6 +1298,9 @@ func TestResourceChange_primitiveList(t *testing.T) {
cty.StringVal("aaaa"), cty.StringVal("aaaa"),
cty.StringVal("bbbb"), cty.StringVal("bbbb"),
cty.StringVal("cccc"), cty.StringVal("cccc"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
cty.StringVal("ffff"),
}), }),
}), }),
Schema: &configschema.Block{ Schema: &configschema.Block{
@ -1160,13 +1313,29 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [
# (1 unchanged element hidden)
"bbbb",
+ "cccc",
"dddd",
# (2 unchanged elements hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [ ~ list_field = [
"aaaa", "aaaa",
+ "bbbb", "bbbb",
"cccc", + "cccc",
"dddd",
"eeee",
"ffff",
] ]
} }
`, `,
@ -1203,6 +1372,17 @@ func TestResourceChange_primitiveList(t *testing.T) {
}), }),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example must be replaced ExpectedOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [ # forces replacement
"aaaa",
+ "bbbb",
"cccc",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" { -/+ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1224,6 +1404,8 @@ func TestResourceChange_primitiveList(t *testing.T) {
cty.StringVal("aaaa"), cty.StringVal("aaaa"),
cty.StringVal("bbbb"), cty.StringVal("bbbb"),
cty.StringVal("cccc"), cty.StringVal("cccc"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
}), }),
}), }),
After: cty.ObjectVal(map[string]cty.Value{ After: cty.ObjectVal(map[string]cty.Value{
@ -1231,6 +1413,8 @@ func TestResourceChange_primitiveList(t *testing.T) {
"ami": cty.StringVal("ami-STATIC"), "ami": cty.StringVal("ami-STATIC"),
"list_field": cty.ListVal([]cty.Value{ "list_field": cty.ListVal([]cty.Value{
cty.StringVal("bbbb"), cty.StringVal("bbbb"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
}), }),
}), }),
Schema: &configschema.Block{ Schema: &configschema.Block{
@ -1243,6 +1427,19 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [
- "aaaa",
"bbbb",
- "cccc",
"dddd",
# (1 unchanged element hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1250,6 +1447,8 @@ func TestResourceChange_primitiveList(t *testing.T) {
- "aaaa", - "aaaa",
"bbbb", "bbbb",
- "cccc", - "cccc",
"dddd",
"eeee",
] ]
} }
`, `,
@ -1307,6 +1506,17 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [
- "aaaa",
- "bbbb",
- "cccc",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1341,6 +1551,13 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
+ list_field = []
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1379,6 +1596,18 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [
"aaaa",
- "bbbb",
+ (known after apply),
"cccc",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1401,6 +1630,8 @@ func TestResourceChange_primitiveList(t *testing.T) {
cty.StringVal("aaaa"), cty.StringVal("aaaa"),
cty.StringVal("bbbb"), cty.StringVal("bbbb"),
cty.StringVal("cccc"), cty.StringVal("cccc"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
}), }),
}), }),
After: cty.ObjectVal(map[string]cty.Value{ After: cty.ObjectVal(map[string]cty.Value{
@ -1411,6 +1642,8 @@ func TestResourceChange_primitiveList(t *testing.T) {
cty.UnknownVal(cty.String), cty.UnknownVal(cty.String),
cty.UnknownVal(cty.String), cty.UnknownVal(cty.String),
cty.StringVal("cccc"), cty.StringVal("cccc"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
}), }),
}), }),
Schema: &configschema.Block{ Schema: &configschema.Block{
@ -1423,6 +1656,20 @@ func TestResourceChange_primitiveList(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ list_field = [
"aaaa",
- "bbbb",
+ (known after apply),
+ (known after apply),
"cccc",
# (2 unchanged elements hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1432,6 +1679,72 @@ func TestResourceChange_primitiveList(t *testing.T) {
+ (known after apply), + (known after apply),
+ (known after apply), + (known after apply),
"cccc", "cccc",
"dddd",
"eeee",
]
}
`,
},
}
runTestCases(t, testCases)
}
func TestResourceChange_primitiveTuple(t *testing.T) {
testCases := map[string]testCase{
"in-place update": {
Action: plans.Update,
Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"),
"tuple_field": cty.TupleVal([]cty.Value{
cty.StringVal("aaaa"),
cty.StringVal("bbbb"),
cty.StringVal("dddd"),
cty.StringVal("eeee"),
cty.StringVal("ffff"),
}),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("i-02ae66f368e8518a9"),
"tuple_field": cty.TupleVal([]cty.Value{
cty.StringVal("aaaa"),
cty.StringVal("bbbb"),
cty.StringVal("cccc"),
cty.StringVal("eeee"),
cty.StringVal("ffff"),
}),
}),
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Required: true},
"tuple_field": {Type: cty.Tuple([]cty.Type{cty.String, cty.String, cty.String, cty.String, cty.String}), Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
id = "i-02ae66f368e8518a9"
~ tuple_field = [
# (1 unchanged element hidden)
"bbbb",
- "dddd",
+ "cccc",
"eeee",
# (1 unchanged element hidden)
]
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
id = "i-02ae66f368e8518a9"
~ tuple_field = [
"aaaa",
"bbbb",
- "dddd",
+ "cccc",
"eeee",
"ffff",
] ]
} }
`, `,
@ -1467,6 +1780,15 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
+ set_field = [
+ "new-element",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1501,6 +1823,15 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [
+ "new-element",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1540,6 +1871,16 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [
+ "bbbb",
# (2 unchanged elements hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1583,6 +1924,16 @@ func TestResourceChange_primitiveSet(t *testing.T) {
}), }),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example must be replaced ExpectedOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [ # forces replacement
+ "bbbb",
# (2 unchanged elements hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" { -/+ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1623,6 +1974,17 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [
- "aaaa",
- "cccc",
# (1 unchanged element hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1685,6 +2047,16 @@ func TestResourceChange_primitiveSet(t *testing.T) {
}, },
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [
- "aaaa",
- "bbbb",
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1718,6 +2090,13 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
+ set_field = []
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1751,6 +2130,16 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [
- "aaaa",
- "bbbb",
] -> (known after apply)
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1790,6 +2179,17 @@ func TestResourceChange_primitiveSet(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ set_field = [
- "bbbb",
~ (known after apply),
# (1 unchanged element hidden)
]
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1832,6 +2232,15 @@ func TestResourceChange_map(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
+ map_field = {
+ "new-key" = "new-element"
}
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1866,6 +2275,15 @@ func TestResourceChange_map(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ map_field = {
+ "new-key" = "new-element"
}
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1905,6 +2323,16 @@ func TestResourceChange_map(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ map_field = {
+ "b" = "bbbb"
# (2 unchanged elements hidden)
}
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1948,6 +2376,16 @@ func TestResourceChange_map(t *testing.T) {
}), }),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example must be replaced ExpectedOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ map_field = { # forces replacement
+ "b" = "bbbb"
# (2 unchanged elements hidden)
}
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" { -/+ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -1988,6 +2426,17 @@ func TestResourceChange_map(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ map_field = {
- "a" = "aaaa" -> null
- "c" = "cccc" -> null
# (1 unchanged element hidden)
}
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -2056,6 +2505,16 @@ func TestResourceChange_map(t *testing.T) {
RequiredReplace: cty.NewPathSet(), RequiredReplace: cty.NewPathSet(),
Tainted: false, Tainted: false,
ExpectedOutput: ` # test_instance.example will be updated in-place ExpectedOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ id = "i-02ae66f368e8518a9" -> (known after apply)
~ map_field = {
~ "b" = "bbbb" -> (known after apply)
# (2 unchanged elements hidden)
}
# (1 unchanged attribute hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" { ~ resource "test_instance" "example" {
ami = "ami-STATIC" ami = "ami-STATIC"
~ id = "i-02ae66f368e8518a9" -> (known after apply) ~ id = "i-02ae66f368e8518a9" -> (known after apply)
@ -2121,6 +2580,14 @@ func TestResourceChange_nestedList(t *testing.T) {
~ ami = "ami-BEFORE" -> "ami-AFTER" ~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9" id = "i-02ae66f368e8518a9"
# (1 unchanged block hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9"
root_block_device { root_block_device {
volume_type = "gp2" volume_type = "gp2"
} }
@ -2284,6 +2751,17 @@ func TestResourceChange_nestedList(t *testing.T) {
~ ami = "ami-BEFORE" -> "ami-AFTER" ~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9" id = "i-02ae66f368e8518a9"
~ root_block_device {
+ new_field = "new_value"
# (1 unchanged attribute hidden)
}
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9"
~ root_block_device { ~ root_block_device {
+ new_field = "new_value" + new_field = "new_value"
volume_type = "gp2" volume_type = "gp2"
@ -2861,6 +3339,17 @@ func TestResourceChange_nestedMap(t *testing.T) {
~ ami = "ami-BEFORE" -> "ami-AFTER" ~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9" id = "i-02ae66f368e8518a9"
~ root_block_device "a" {
+ new_field = "new_value"
# (1 unchanged attribute hidden)
}
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9"
~ root_block_device "a" { ~ root_block_device "a" {
+ new_field = "new_value" + new_field = "new_value"
volume_type = "gp2" volume_type = "gp2"
@ -2927,6 +3416,18 @@ func TestResourceChange_nestedMap(t *testing.T) {
~ ami = "ami-BEFORE" -> "ami-AFTER" ~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9" id = "i-02ae66f368e8518a9"
+ root_block_device "b" {
+ new_field = "new_value"
+ volume_type = "gp2"
}
# (1 unchanged block hidden)
}
`,
VerboseOutput: ` # test_instance.example will be updated in-place
~ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9"
root_block_device "a" { root_block_device "a" {
volume_type = "gp2" volume_type = "gp2"
} }
@ -2994,6 +3495,17 @@ func TestResourceChange_nestedMap(t *testing.T) {
~ ami = "ami-BEFORE" -> "ami-AFTER" ~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9" id = "i-02ae66f368e8518a9"
~ root_block_device "a" { # forces replacement
~ volume_type = "gp2" -> "different"
}
# (1 unchanged block hidden)
}
`,
VerboseOutput: ` # test_instance.example must be replaced
-/+ resource "test_instance" "example" {
~ ami = "ami-BEFORE" -> "ami-AFTER"
id = "i-02ae66f368e8518a9"
~ root_block_device "a" { # forces replacement ~ root_block_device "a" { # forces replacement
~ volume_type = "gp2" -> "different" ~ volume_type = "gp2" -> "different"
} }
@ -3119,6 +3631,10 @@ type testCase struct {
RequiredReplace cty.PathSet RequiredReplace cty.PathSet
Tainted bool Tainted bool
ExpectedOutput string ExpectedOutput string
// This field and all associated values can be removed if the concise diff
// experiment succeeds.
VerboseOutput string
} }
func runTestCases(t *testing.T, testCases map[string]testCase) { func runTestCases(t *testing.T, testCases map[string]testCase) {
@ -3170,9 +3686,24 @@ func runTestCases(t *testing.T, testCases map[string]testCase) {
RequiredReplace: tc.RequiredReplace, RequiredReplace: tc.RequiredReplace,
} }
experiment.SetEnabled(experiment.X_concise_diff, true)
output := ResourceChange(change, tc.Tainted, tc.Schema, color) output := ResourceChange(change, tc.Tainted, tc.Schema, color)
if output != tc.ExpectedOutput { if output != tc.ExpectedOutput {
t.Fatalf("Unexpected diff.\ngot:\n%s\nwant:\n%s\n", output, tc.ExpectedOutput) t.Errorf("Unexpected diff.\ngot:\n%s\nwant:\n%s\n", output, tc.ExpectedOutput)
t.Errorf("%s", cmp.Diff(output, tc.ExpectedOutput))
}
// Temporary coverage for verbose diff behaviour. All lines below
// in this function can be removed if the concise diff experiment
// succeeds.
if tc.VerboseOutput == "" {
return
}
experiment.SetEnabled(experiment.X_concise_diff, false)
output = ResourceChange(change, tc.Tainted, tc.Schema, color)
if output != tc.VerboseOutput {
t.Errorf("Unexpected diff.\ngot:\n%s\nwant:\n%s\n", output, tc.VerboseOutput)
t.Errorf("%s", cmp.Diff(output, tc.VerboseOutput))
} }
}) })
} }
@ -3243,11 +3774,11 @@ func TestOutputChanges(t *testing.T) {
}, },
` `
~ foo = [ ~ foo = [
"alpha", # (1 unchanged element hidden)
"beta", "beta",
+ "gamma", + "gamma",
"delta", "delta",
"epsilon", # (1 unchanged element hidden)
]`, ]`,
}, },
"multiple outputs changed, one sensitive": { "multiple outputs changed, one sensitive": {
@ -3280,6 +3811,7 @@ func TestOutputChanges(t *testing.T) {
for name, tc := range testCases { for name, tc := range testCases {
t.Run(name, func(t *testing.T) { t.Run(name, func(t *testing.T) {
experiment.SetEnabled(experiment.X_concise_diff, true)
output := OutputChanges(tc.changes, color) output := OutputChanges(tc.changes, color)
if output != tc.output { if output != tc.output {
t.Errorf("Unexpected diff.\ngot:\n%s\nwant:\n%s\n", output, tc.output) t.Errorf("Unexpected diff.\ngot:\n%s\nwant:\n%s\n", output, tc.output)

View File

@ -198,8 +198,8 @@ func formatStateModule(p blockBodyDiffPrinter, m *states.Module, schemas *terraf
} }
path := make(cty.Path, 0, 3) path := make(cty.Path, 0, 3)
bodyWritten := p.writeBlockBodyDiff(schema, val.Value, val.Value, 2, path) result := p.writeBlockBodyDiff(schema, val.Value, val.Value, 2, path)
if bodyWritten { if result.bodyWritten {
p.buf.WriteString("\n") p.buf.WriteString("\n")
} }

View File

@ -53,6 +53,9 @@ var (
// Shadow graph. This is already on by default. Disabling it will be // Shadow graph. This is already on by default. Disabling it will be
// allowed for awhile in order for it to not block operations. // allowed for awhile in order for it to not block operations.
X_shadow = newBasicID("shadow", "SHADOW", false) X_shadow = newBasicID("shadow", "SHADOW", false)
// Concise plan diff output
X_concise_diff = newBasicID("concise_diff", "CONCISE_DIFF", true)
) )
// Global variables this package uses because we are a package // Global variables this package uses because we are a package
@ -73,6 +76,7 @@ func init() {
// The list of all experiments, update this when an experiment is added. // The list of all experiments, update this when an experiment is added.
All = []ID{ All = []ID{
X_shadow, X_shadow,
X_concise_diff,
x_force, x_force,
} }