mirror of
https://github.com/opentffoundation/opentf.git
synced 2026-01-01 10:01:03 -05:00
Previously we used a single plan action "Replace" to represent both the destroy-before-create and the create-before-destroy variants of replacing. However, this forces the apply graph builder to jump through a lot of hoops to figure out which nodes need it forced on and rebuild parts of the graph to represent that. If we instead decide between these two cases at plan time, the actual determination of it is more straightforward because each resource is represented by only one node in the plan graph, and then we can ensure we put the right nodes in the graph during DiffTransformer and thus avoid the logic for dealing with deposed instances being spread across various different transformers and node types. As a nice side-effect, this also allows us to show the difference between destroy-then-create and create-then-destroy in the rendered diff in the CLI, although this change doesn't fully implement that yet.
1024 lines
30 KiB
Go
1024 lines
30 KiB
Go
package format
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/mitchellh/colorstring"
|
|
"github.com/zclconf/go-cty/cty"
|
|
ctyjson "github.com/zclconf/go-cty/cty/json"
|
|
|
|
"github.com/hashicorp/terraform/addrs"
|
|
"github.com/hashicorp/terraform/configs/configschema"
|
|
"github.com/hashicorp/terraform/plans"
|
|
"github.com/hashicorp/terraform/plans/objchange"
|
|
"github.com/hashicorp/terraform/states"
|
|
)
|
|
|
|
// ResourceChange returns a string representation of a change to a particular
|
|
// resource, for inclusion in user-facing plan output.
|
|
//
|
|
// The resource schema must be provided along with the change so that the
|
|
// formatted change can reflect the configuration structure for the associated
|
|
// resource.
|
|
//
|
|
// If "color" is non-nil, it will be used to color the result. Otherwise,
|
|
// no color codes will be included.
|
|
func ResourceChange(
|
|
change *plans.ResourceInstanceChangeSrc,
|
|
schema *configschema.Block,
|
|
color *colorstring.Colorize,
|
|
) string {
|
|
addr := change.Addr
|
|
var buf bytes.Buffer
|
|
|
|
if color == nil {
|
|
color = &colorstring.Colorize{
|
|
Colors: colorstring.DefaultColors,
|
|
Disable: true,
|
|
Reset: false,
|
|
}
|
|
}
|
|
|
|
dispAddr := addr.String()
|
|
if change.DeposedKey != states.NotDeposed {
|
|
dispAddr = fmt.Sprintf("%s (deposed object %s)", dispAddr, change.DeposedKey)
|
|
}
|
|
|
|
switch change.Action {
|
|
case plans.Create:
|
|
buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] will be created", dispAddr)))
|
|
case plans.Read:
|
|
buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] will be read during apply\n # (config refers to values not yet known)", dispAddr)))
|
|
case plans.Update:
|
|
buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] will be updated in-place", dispAddr)))
|
|
case plans.CreateThenDelete, plans.DeleteThenCreate:
|
|
buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] must be [bold][red]replaced", dispAddr)))
|
|
case plans.Delete:
|
|
buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] will be [bold][red]destroyed", dispAddr)))
|
|
default:
|
|
// should never happen, since the above is exhaustive
|
|
buf.WriteString(fmt.Sprintf("%s has an action the plan renderer doesn't support (this is a bug)", dispAddr))
|
|
}
|
|
buf.WriteString(color.Color("[reset]\n"))
|
|
|
|
switch change.Action {
|
|
case plans.Create:
|
|
buf.WriteString(color.Color("[green] +[reset] "))
|
|
case plans.Read:
|
|
buf.WriteString(color.Color("[cyan] <=[reset] "))
|
|
case plans.Update:
|
|
buf.WriteString(color.Color("[yellow] ~[reset] "))
|
|
case plans.DeleteThenCreate:
|
|
buf.WriteString(color.Color("[red]-[reset]/[green]+[reset] "))
|
|
case plans.CreateThenDelete:
|
|
buf.WriteString(color.Color("[green]+[reset]/[red]-[reset] "))
|
|
case plans.Delete:
|
|
buf.WriteString(color.Color("[red] -[reset] "))
|
|
default:
|
|
buf.WriteString(color.Color("??? "))
|
|
}
|
|
|
|
switch addr.Resource.Resource.Mode {
|
|
case addrs.ManagedResourceMode:
|
|
buf.WriteString(fmt.Sprintf(
|
|
"resource %q %q",
|
|
addr.Resource.Resource.Type,
|
|
addr.Resource.Resource.Name,
|
|
))
|
|
case addrs.DataResourceMode:
|
|
buf.WriteString(fmt.Sprintf(
|
|
"data %q %q ",
|
|
addr.Resource.Resource.Type,
|
|
addr.Resource.Resource.Name,
|
|
))
|
|
default:
|
|
// should never happen, since the above is exhaustive
|
|
buf.WriteString(addr.String())
|
|
}
|
|
|
|
buf.WriteString(" {\n")
|
|
|
|
p := blockBodyDiffPrinter{
|
|
buf: &buf,
|
|
color: color,
|
|
action: change.Action,
|
|
requiredReplace: change.RequiredReplace,
|
|
}
|
|
|
|
// Most commonly-used resources have nested blocks that result in us
|
|
// going at least three traversals deep while we recurse here, so we'll
|
|
// start with that much capacity and then grow as needed for deeper
|
|
// structures.
|
|
path := make(cty.Path, 0, 3)
|
|
|
|
changeV, err := change.Decode(schema.ImpliedType())
|
|
if err != nil {
|
|
// Should never happen in here, since we've already been through
|
|
// loads of layers of encode/decode of the planned changes before now.
|
|
panic(fmt.Sprintf("failed to decode plan for %s while rendering diff: %s", addr, err))
|
|
}
|
|
|
|
p.writeBlockBodyDiff(schema, changeV.Before, changeV.After, 6, path)
|
|
|
|
buf.WriteString(" }\n")
|
|
|
|
return buf.String()
|
|
}
|
|
|
|
type ctyValueDiff struct {
|
|
Action plans.Action
|
|
Value cty.Value
|
|
}
|
|
|
|
type blockBodyDiffPrinter struct {
|
|
buf *bytes.Buffer
|
|
color *colorstring.Colorize
|
|
action plans.Action
|
|
requiredReplace cty.PathSet
|
|
}
|
|
|
|
const forcesNewResourceCaption = " [red]# forces replacement[reset]"
|
|
|
|
func (p *blockBodyDiffPrinter) writeBlockBodyDiff(schema *configschema.Block, old, new cty.Value, indent int, path cty.Path) {
|
|
path = ctyEnsurePathCapacity(path, 1)
|
|
|
|
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)
|
|
|
|
p.writeAttrDiff(name, attrS, oldVal, newVal, attrNameLen, indent, path)
|
|
}
|
|
}
|
|
|
|
{
|
|
blockTypeNames := make([]string, 0, len(schema.BlockTypes))
|
|
for name := range schema.BlockTypes {
|
|
blockTypeNames = append(blockTypeNames, name)
|
|
}
|
|
sort.Strings(blockTypeNames)
|
|
|
|
for _, name := range blockTypeNames {
|
|
blockS := schema.BlockTypes[name]
|
|
oldVal := ctyGetAttrMaybeNull(old, name)
|
|
newVal := ctyGetAttrMaybeNull(new, name)
|
|
|
|
p.writeNestedBlockDiffs(name, blockS, oldVal, newVal, blankBeforeBlocks, indent, path)
|
|
|
|
// Always include a blank for any subsequent block types.
|
|
blankBeforeBlocks = true
|
|
}
|
|
}
|
|
}
|
|
|
|
func (p *blockBodyDiffPrinter) writeAttrDiff(name string, attrS *configschema.Attribute, old, new cty.Value, nameLen, indent int, path cty.Path) {
|
|
path = append(path, cty.GetAttrStep{Name: name})
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
showJustNew := false
|
|
var action plans.Action
|
|
switch {
|
|
case old.IsNull():
|
|
action = plans.Create
|
|
showJustNew = true
|
|
case new.IsNull():
|
|
action = plans.Delete
|
|
case ctyEqualWithUnknown(old, new):
|
|
action = plans.NoOp
|
|
showJustNew = true
|
|
default:
|
|
action = plans.Update
|
|
}
|
|
|
|
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)))
|
|
p.buf.WriteString(" = ")
|
|
|
|
if attrS.Sensitive {
|
|
p.buf.WriteString("(sensitive value)")
|
|
} else {
|
|
switch {
|
|
case showJustNew:
|
|
p.writeValue(new, action, indent+2)
|
|
default:
|
|
// We show new even if it is null to emphasize the fact
|
|
// that it is being unset, since otherwise it is easy to
|
|
// misunderstand that the value is still set to the old value.
|
|
p.writeValueDiff(old, new, indent+2, path)
|
|
}
|
|
}
|
|
|
|
p.buf.WriteString("\n")
|
|
|
|
}
|
|
|
|
func (p *blockBodyDiffPrinter) writeNestedBlockDiffs(name string, blockS *configschema.NestedBlock, old, new cty.Value, blankBefore bool, indent int, path cty.Path) {
|
|
path = append(path, cty.GetAttrStep{Name: name})
|
|
if old.IsNull() && new.IsNull() {
|
|
// Nothing to do if both old and new is null
|
|
return
|
|
}
|
|
|
|
// Where old/new are collections representing a nesting mode other than
|
|
// NestingSingle, we assume the collection value can never be unknown
|
|
// since we always produce the container for the nested objects, even if
|
|
// the objects within are computed.
|
|
|
|
switch blockS.Nesting {
|
|
case configschema.NestingSingle:
|
|
var action plans.Action
|
|
switch {
|
|
case old.IsNull():
|
|
action = plans.Create
|
|
case new.IsNull():
|
|
action = plans.Delete
|
|
case !new.IsKnown() || !old.IsKnown():
|
|
// "old" should actually always be known due to our contract
|
|
// that old values must never be unknown, but we'll allow it
|
|
// anyway to be robust.
|
|
action = plans.Update
|
|
case !(new.Equals(old).True()):
|
|
action = plans.Update
|
|
}
|
|
|
|
if blankBefore {
|
|
p.buf.WriteRune('\n')
|
|
}
|
|
p.writeNestedBlockDiff(name, nil, &blockS.Block, action, old, new, indent, path)
|
|
case configschema.NestingList:
|
|
// 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
|
|
// distinguish these anyway.
|
|
if old.IsNull() {
|
|
old = cty.ListValEmpty(old.Type().ElementType())
|
|
}
|
|
if new.IsNull() {
|
|
new = cty.ListValEmpty(new.Type().ElementType())
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if blankBefore && (len(oldItems) > 0 || len(newItems) > 0) {
|
|
p.buf.WriteRune('\n')
|
|
}
|
|
for i := 0; i < commonLen; i++ {
|
|
path := append(path, cty.IndexStep{Key: cty.NumberIntVal(int64(i))})
|
|
oldItem := oldItems[i]
|
|
newItem := newItems[i]
|
|
p.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Update, oldItem, newItem, indent, path)
|
|
}
|
|
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.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Delete, oldItem, newItem, indent, path)
|
|
}
|
|
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.writeNestedBlockDiff(name, nil, &blockS.Block, plans.Create, oldItem, newItem, indent, path)
|
|
}
|
|
case configschema.NestingSet:
|
|
// For the sake of handling nested blocks, we'll treat a null set
|
|
// the same as an empty set since the config language doesn't
|
|
// distinguish these anyway.
|
|
if old.IsNull() {
|
|
old = cty.SetValEmpty(old.Type().ElementType())
|
|
}
|
|
if new.IsNull() {
|
|
new = cty.SetValEmpty(new.Type().ElementType())
|
|
}
|
|
|
|
oldItems := ctyCollectionValues(old)
|
|
newItems := ctyCollectionValues(new)
|
|
|
|
if (len(oldItems) + len(newItems)) == 0 {
|
|
// Nothing to do if both sets are empty
|
|
return
|
|
}
|
|
|
|
allItems := make([]cty.Value, 0, len(oldItems)+len(newItems))
|
|
allItems = append(allItems, oldItems...)
|
|
allItems = append(allItems, newItems...)
|
|
all := cty.SetVal(allItems)
|
|
|
|
if blankBefore {
|
|
p.buf.WriteRune('\n')
|
|
}
|
|
|
|
for it := all.ElementIterator(); it.Next(); {
|
|
_, val := it.Element()
|
|
var action plans.Action
|
|
var oldValue, newValue cty.Value
|
|
switch {
|
|
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
|
|
}
|
|
path := append(path, cty.IndexStep{Key: val})
|
|
p.writeNestedBlockDiff(name, nil, &blockS.Block, action, oldValue, newValue, indent, path)
|
|
}
|
|
|
|
case configschema.NestingMap:
|
|
// TODO: Implement this, once helper/schema is actually able to
|
|
// produce schemas containing nested map block types.
|
|
}
|
|
}
|
|
|
|
func (p *blockBodyDiffPrinter) writeNestedBlockDiff(name string, label *string, blockS *configschema.Block, action plans.Action, old, new cty.Value, indent int, path cty.Path) {
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.writeActionSymbol(action)
|
|
|
|
if label != nil {
|
|
fmt.Fprintf(p.buf, "%s %q {", name, label)
|
|
} else {
|
|
fmt.Fprintf(p.buf, "%s {", name)
|
|
}
|
|
|
|
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.writeBlockBodyDiff(blockS, old, new, indent+4, path)
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.buf.WriteString("}\n")
|
|
}
|
|
|
|
func (p *blockBodyDiffPrinter) writeValue(val cty.Value, action plans.Action, indent int) {
|
|
if !val.IsKnown() {
|
|
p.buf.WriteString("(known after apply)")
|
|
return
|
|
}
|
|
if val.IsNull() {
|
|
p.buf.WriteString("null")
|
|
return
|
|
}
|
|
|
|
ty := val.Type()
|
|
|
|
switch {
|
|
case ty.IsPrimitiveType():
|
|
switch ty {
|
|
case cty.String:
|
|
{
|
|
// Special behavior for JSON strings containing array or object
|
|
src := []byte(val.AsString())
|
|
ty, err := ctyjson.ImpliedType(src)
|
|
if err == nil && !ty.IsPrimitiveType() {
|
|
jv, err := ctyjson.Unmarshal(src, ty)
|
|
if err == nil {
|
|
p.buf.WriteString("jsonencode(")
|
|
p.buf.WriteByte('\n')
|
|
p.buf.WriteString(strings.Repeat(" ", indent+4))
|
|
p.writeValue(jv, action, indent+4)
|
|
p.buf.WriteByte('\n')
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteByte(')')
|
|
break // don't *also* do the normal behavior below
|
|
}
|
|
}
|
|
}
|
|
fmt.Fprintf(p.buf, "%q", val.AsString())
|
|
case cty.Bool:
|
|
if val.True() {
|
|
p.buf.WriteString("true")
|
|
} else {
|
|
p.buf.WriteString("false")
|
|
}
|
|
case cty.Number:
|
|
bf := val.AsBigFloat()
|
|
p.buf.WriteString(bf.Text('f', -1))
|
|
default:
|
|
// should never happen, since the above is exhaustive
|
|
fmt.Fprintf(p.buf, "%#v", val)
|
|
}
|
|
case ty.IsListType() || ty.IsSetType() || ty.IsTupleType():
|
|
p.buf.WriteString("[\n")
|
|
|
|
it := val.ElementIterator()
|
|
for it.Next() {
|
|
_, val := it.Element()
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.writeActionSymbol(action)
|
|
p.writeValue(val, action, indent+4)
|
|
p.buf.WriteString(",\n")
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteString("]")
|
|
case ty.IsMapType():
|
|
p.buf.WriteString("{\n")
|
|
|
|
keyLen := 0
|
|
for it := val.ElementIterator(); it.Next(); {
|
|
key, _ := it.Element()
|
|
if keyStr := key.AsString(); len(keyStr) > keyLen {
|
|
keyLen = len(keyStr)
|
|
}
|
|
}
|
|
|
|
for it := val.ElementIterator(); it.Next(); {
|
|
key, val := it.Element()
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.writeActionSymbol(action)
|
|
p.writeValue(key, action, indent+4)
|
|
p.buf.WriteString(strings.Repeat(" ", keyLen-len(key.AsString())))
|
|
p.buf.WriteString(" = ")
|
|
p.writeValue(val, action, indent+4)
|
|
p.buf.WriteString("\n")
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteString("}")
|
|
case ty.IsObjectType():
|
|
p.buf.WriteString("{\n")
|
|
|
|
atys := ty.AttributeTypes()
|
|
attrNames := make([]string, 0, len(atys))
|
|
nameLen := 0
|
|
for attrName := range atys {
|
|
attrNames = append(attrNames, attrName)
|
|
if len(attrName) > nameLen {
|
|
nameLen = len(attrName)
|
|
}
|
|
}
|
|
sort.Strings(attrNames)
|
|
|
|
for _, attrName := range attrNames {
|
|
val := val.GetAttr(attrName)
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.writeActionSymbol(action)
|
|
p.buf.WriteString(attrName)
|
|
p.buf.WriteString(strings.Repeat(" ", nameLen-len(attrName)))
|
|
p.buf.WriteString(" = ")
|
|
p.writeValue(val, action, indent+4)
|
|
p.buf.WriteString("\n")
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteString("}")
|
|
}
|
|
}
|
|
|
|
func (p *blockBodyDiffPrinter) writeValueDiff(old, new cty.Value, indent int, path cty.Path) {
|
|
ty := old.Type()
|
|
|
|
// We have some specialized diff implementations for certain complex
|
|
// values where it's useful to see a visualization of the diff of
|
|
// the nested elements rather than just showing the entire old and
|
|
// new values verbatim.
|
|
// However, these specialized implementations can apply only if both
|
|
// values are known and non-null.
|
|
if old.IsKnown() && new.IsKnown() && !old.IsNull() && !new.IsNull() {
|
|
switch {
|
|
// TODO: object diffs that behave a bit like the map diffs, including if the two object types don't exactly match
|
|
|
|
case ty == cty.String:
|
|
// We have special behavior for both multi-line strings in general
|
|
// and for strings that can parse as JSON. For the JSON handling
|
|
// to apply, both old and new must be valid JSON.
|
|
// For single-line strings that don't parse as JSON we just fall
|
|
// out of this switch block and do the default old -> new rendering.
|
|
oldS := old.AsString()
|
|
newS := new.AsString()
|
|
|
|
{
|
|
// Special behavior for JSON strings containing object or
|
|
// list values.
|
|
oldBytes := []byte(oldS)
|
|
newBytes := []byte(newS)
|
|
oldType, oldErr := ctyjson.ImpliedType(oldBytes)
|
|
newType, newErr := ctyjson.ImpliedType(newBytes)
|
|
if oldErr == nil && newErr == nil && !(oldType.IsPrimitiveType() && newType.IsPrimitiveType()) {
|
|
oldJV, oldErr := ctyjson.Unmarshal(oldBytes, oldType)
|
|
newJV, newErr := ctyjson.Unmarshal(newBytes, newType)
|
|
if oldErr == nil && newErr == nil {
|
|
if !oldJV.RawEquals(newJV) { // two JSON values may differ only in insignificant whitespace
|
|
p.buf.WriteString("jsonencode(")
|
|
p.buf.WriteByte('\n')
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.writeActionSymbol(plans.Update)
|
|
p.writeValueDiff(oldJV, newJV, indent+4, path)
|
|
p.buf.WriteByte('\n')
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteByte(')')
|
|
} else {
|
|
// if they differ only in insigificant whitespace
|
|
// then we'll note that but still expand out the
|
|
// effective value.
|
|
if p.pathForcesNewResource(path) {
|
|
p.buf.WriteString(p.color.Color("jsonencode( [red]# whitespace changes force replacement[reset]"))
|
|
} else {
|
|
p.buf.WriteString(p.color.Color("jsonencode( [dim]# whitespace changes[reset]"))
|
|
}
|
|
p.buf.WriteByte('\n')
|
|
p.buf.WriteString(strings.Repeat(" ", indent+4))
|
|
p.writeValue(oldJV, plans.NoOp, indent+4)
|
|
p.buf.WriteByte('\n')
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteByte(')')
|
|
}
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
if strings.Index(oldS, "\n") < 0 && strings.Index(newS, "\n") < 0 {
|
|
break
|
|
}
|
|
|
|
p.buf.WriteString("<<~EOT")
|
|
if p.pathForcesNewResource(path) {
|
|
p.buf.WriteString(p.color.Color(forcesNewResourceCaption))
|
|
}
|
|
p.buf.WriteString("\n")
|
|
|
|
var oldLines, newLines []cty.Value
|
|
{
|
|
r := strings.NewReader(oldS)
|
|
sc := bufio.NewScanner(r)
|
|
for sc.Scan() {
|
|
oldLines = append(oldLines, cty.StringVal(sc.Text()))
|
|
}
|
|
}
|
|
{
|
|
r := strings.NewReader(newS)
|
|
sc := bufio.NewScanner(r)
|
|
for sc.Scan() {
|
|
newLines = append(newLines, cty.StringVal(sc.Text()))
|
|
}
|
|
}
|
|
|
|
diffLines := ctySequenceDiff(oldLines, newLines)
|
|
for _, diffLine := range diffLines {
|
|
line := diffLine.Value.AsString()
|
|
switch diffLine.Action {
|
|
case plans.Create:
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.buf.WriteString(p.color.Color("[green]+[reset] "))
|
|
p.buf.WriteString(line)
|
|
p.buf.WriteString("\n")
|
|
case plans.Delete:
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.buf.WriteString(p.color.Color("[red]-[reset] "))
|
|
p.buf.WriteString(line)
|
|
p.buf.WriteString("\n")
|
|
case plans.NoOp:
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.buf.WriteString(p.color.Color(" "))
|
|
p.buf.WriteString(line)
|
|
p.buf.WriteString("\n")
|
|
default:
|
|
// Should never happen since the above covers all
|
|
// actions that ctySequenceDiff can return.
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.buf.WriteString(p.color.Color("? "))
|
|
p.buf.WriteString(line)
|
|
p.buf.WriteString("\n")
|
|
}
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent)) // +4 here because there's no symbol
|
|
p.buf.WriteString("EOT")
|
|
|
|
return
|
|
|
|
case ty.IsSetType():
|
|
p.buf.WriteString("[")
|
|
if p.pathForcesNewResource(path) {
|
|
p.buf.WriteString(p.color.Color(forcesNewResourceCaption))
|
|
}
|
|
p.buf.WriteString("\n")
|
|
|
|
var addedVals, removedVals, allVals []cty.Value
|
|
for it := old.ElementIterator(); it.Next(); {
|
|
_, val := it.Element()
|
|
allVals = append(allVals, val)
|
|
if new.HasElement(val).False() {
|
|
removedVals = append(removedVals, val)
|
|
}
|
|
}
|
|
for it := new.ElementIterator(); it.Next(); {
|
|
_, val := it.Element()
|
|
allVals = append(allVals, val)
|
|
if old.HasElement(val).False() {
|
|
addedVals = append(addedVals, val)
|
|
}
|
|
}
|
|
|
|
var all, added, removed cty.Value
|
|
if len(allVals) > 0 {
|
|
all = cty.SetVal(allVals)
|
|
} else {
|
|
all = cty.SetValEmpty(ty.ElementType())
|
|
}
|
|
if len(addedVals) > 0 {
|
|
added = cty.SetVal(addedVals)
|
|
} else {
|
|
added = cty.SetValEmpty(ty.ElementType())
|
|
}
|
|
if len(removedVals) > 0 {
|
|
removed = cty.SetVal(removedVals)
|
|
} else {
|
|
removed = cty.SetValEmpty(ty.ElementType())
|
|
}
|
|
|
|
for it := all.ElementIterator(); it.Next(); {
|
|
_, val := it.Element()
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
|
|
var action plans.Action
|
|
switch {
|
|
case added.HasElement(val).True():
|
|
action = plans.Create
|
|
case removed.HasElement(val).True():
|
|
action = plans.Delete
|
|
default:
|
|
action = plans.NoOp
|
|
}
|
|
|
|
p.writeActionSymbol(action)
|
|
p.writeValue(val, action, indent+4)
|
|
p.buf.WriteString(",\n")
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteString("]")
|
|
return
|
|
|
|
case ty.IsListType() || ty.IsTupleType():
|
|
p.buf.WriteString("[")
|
|
if p.pathForcesNewResource(path) {
|
|
p.buf.WriteString(p.color.Color(forcesNewResourceCaption))
|
|
}
|
|
p.buf.WriteString("\n")
|
|
|
|
elemDiffs := ctySequenceDiff(old.AsValueSlice(), new.AsValueSlice())
|
|
for _, elemDiff := range elemDiffs {
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
p.writeActionSymbol(elemDiff.Action)
|
|
p.writeValue(elemDiff.Value, elemDiff.Action, indent+4)
|
|
p.buf.WriteString(",\n")
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteString("]")
|
|
return
|
|
|
|
case ty.IsMapType():
|
|
p.buf.WriteString("{")
|
|
if p.pathForcesNewResource(path) {
|
|
p.buf.WriteString(p.color.Color(forcesNewResourceCaption))
|
|
}
|
|
p.buf.WriteString("\n")
|
|
|
|
var allKeys []string
|
|
keyLen := 0
|
|
for it := old.ElementIterator(); it.Next(); {
|
|
k, _ := it.Element()
|
|
keyStr := k.AsString()
|
|
allKeys = append(allKeys, keyStr)
|
|
if len(keyStr) > keyLen {
|
|
keyLen = len(keyStr)
|
|
}
|
|
}
|
|
for it := new.ElementIterator(); it.Next(); {
|
|
k, _ := it.Element()
|
|
keyStr := k.AsString()
|
|
allKeys = append(allKeys, keyStr)
|
|
if len(keyStr) > keyLen {
|
|
keyLen = len(keyStr)
|
|
}
|
|
}
|
|
|
|
sort.Strings(allKeys)
|
|
|
|
lastK := ""
|
|
for i, k := range allKeys {
|
|
if i > 0 && lastK == k {
|
|
continue // skip duplicates (list is sorted)
|
|
}
|
|
lastK = k
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent+2))
|
|
kV := cty.StringVal(k)
|
|
var action plans.Action
|
|
if old.HasIndex(kV).False() {
|
|
action = plans.Create
|
|
} else if new.HasIndex(kV).False() {
|
|
action = plans.Delete
|
|
} else if eqV := old.Index(kV).Equals(new.Index(kV)); eqV.IsKnown() && eqV.True() {
|
|
action = plans.NoOp
|
|
} else {
|
|
action = plans.Update
|
|
}
|
|
|
|
path := append(path, cty.IndexStep{Key: kV})
|
|
|
|
p.writeActionSymbol(action)
|
|
p.writeValue(kV, action, indent+4)
|
|
p.buf.WriteString(strings.Repeat(" ", keyLen-len(k)))
|
|
p.buf.WriteString(" = ")
|
|
switch action {
|
|
case plans.Create, plans.NoOp:
|
|
v := new.Index(kV)
|
|
p.writeValue(v, action, indent+4)
|
|
case plans.Delete:
|
|
oldV := old.Index(kV)
|
|
newV := cty.NullVal(oldV.Type())
|
|
p.writeValueDiff(oldV, newV, indent+4, path)
|
|
default:
|
|
oldV := old.Index(kV)
|
|
newV := new.Index(kV)
|
|
p.writeValueDiff(oldV, newV, indent+4, path)
|
|
}
|
|
|
|
p.buf.WriteByte('\n')
|
|
}
|
|
|
|
p.buf.WriteString(strings.Repeat(" ", indent))
|
|
p.buf.WriteString("}")
|
|
return
|
|
}
|
|
}
|
|
|
|
// In all other cases, we just show the new and old values as-is
|
|
p.writeValue(old, plans.Delete, indent)
|
|
p.buf.WriteString(p.color.Color(" [yellow]->[reset] "))
|
|
p.writeValue(new, plans.Create, indent)
|
|
if p.pathForcesNewResource(path) {
|
|
p.buf.WriteString(p.color.Color(forcesNewResourceCaption))
|
|
}
|
|
}
|
|
|
|
// writeActionSymbol writes a symbol to represent the given action, followed
|
|
// by a space.
|
|
//
|
|
// It only supports the actions that can be represented with a single character:
|
|
// Create, Delete, Update and NoAction.
|
|
func (p *blockBodyDiffPrinter) writeActionSymbol(action plans.Action) {
|
|
switch action {
|
|
case plans.Create:
|
|
p.buf.WriteString(p.color.Color("[green]+[reset] "))
|
|
case plans.Delete:
|
|
p.buf.WriteString(p.color.Color("[red]-[reset] "))
|
|
case plans.Update:
|
|
p.buf.WriteString(p.color.Color("[yellow]~[reset] "))
|
|
case plans.NoOp:
|
|
p.buf.WriteString(" ")
|
|
default:
|
|
// Should never happen
|
|
p.buf.WriteString(p.color.Color("? "))
|
|
}
|
|
}
|
|
|
|
func (p *blockBodyDiffPrinter) pathForcesNewResource(path cty.Path) bool {
|
|
if p.action.IsReplace() {
|
|
// "requiredReplace" only applies when the instance is being replaced
|
|
return false
|
|
}
|
|
return p.requiredReplace.Has(path)
|
|
}
|
|
|
|
func ctyGetAttrMaybeNull(val cty.Value, name string) cty.Value {
|
|
if val.IsNull() {
|
|
ty := val.Type().AttributeType(name)
|
|
return cty.NullVal(ty)
|
|
}
|
|
|
|
return val.GetAttr(name)
|
|
}
|
|
|
|
func ctyCollectionValues(val cty.Value) []cty.Value {
|
|
ret := make([]cty.Value, 0, val.LengthInt())
|
|
for it := val.ElementIterator(); it.Next(); {
|
|
_, value := it.Element()
|
|
ret = append(ret, value)
|
|
}
|
|
return ret
|
|
}
|
|
|
|
func ctySequenceDiff(old, new []cty.Value) []ctyValueDiff {
|
|
var ret []ctyValueDiff
|
|
lcs := objchange.LongestCommonSubsequence(old, new)
|
|
var oldI, newI, lcsI int
|
|
for oldI < len(old) || newI < len(new) || lcsI < len(lcs) {
|
|
for oldI < len(old) && (lcsI >= len(lcs) || !old[oldI].RawEquals(lcs[lcsI])) {
|
|
ret = append(ret, ctyValueDiff{
|
|
Action: plans.Delete,
|
|
Value: old[oldI],
|
|
})
|
|
oldI++
|
|
}
|
|
for newI < len(new) && (lcsI >= len(lcs) || !new[newI].RawEquals(lcs[lcsI])) {
|
|
ret = append(ret, ctyValueDiff{
|
|
Action: plans.Create,
|
|
Value: new[newI],
|
|
})
|
|
newI++
|
|
}
|
|
if lcsI < len(lcs) {
|
|
ret = append(ret, ctyValueDiff{
|
|
Action: plans.NoOp,
|
|
Value: new[newI],
|
|
})
|
|
|
|
// All of our indexes advance together now, since the line
|
|
// is common to all three sequences.
|
|
lcsI++
|
|
oldI++
|
|
newI++
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// ctyObjectSequenceDiff is a variant of ctySequenceDiff that only works for
|
|
// values of object types. Whereas ctySequenceDiff can only return Create
|
|
// and Delete actions, this function can additionally return Update actions
|
|
// heuristically based on similarity of objects in the lists, which must
|
|
// be greater than or equal to the caller-specified threshold.
|
|
//
|
|
// See ctyObjectSimilarity for details on what "similarity" means here.
|
|
func ctyObjectSequenceDiff(old, new []cty.Value, threshold float64) []*plans.Change {
|
|
var ret []*plans.Change
|
|
lcs := objchange.LongestCommonSubsequence(old, new)
|
|
var oldI, newI, lcsI int
|
|
for oldI < len(old) || newI < len(new) || lcsI < len(lcs) {
|
|
for oldI < len(old) && (lcsI >= len(lcs) || !old[oldI].RawEquals(lcs[lcsI])) {
|
|
if newI < len(new) {
|
|
// See if the next "new" is similar enough to our "old" that
|
|
// we'll treat this as an Update rather than a Delete/Create.
|
|
similarity := ctyObjectSimilarity(old[oldI], new[newI])
|
|
if similarity >= threshold {
|
|
ret = append(ret, &plans.Change{
|
|
Action: plans.Update,
|
|
Before: old[oldI],
|
|
After: new[newI],
|
|
})
|
|
oldI++
|
|
newI++ // we also consume the next "new" in this case
|
|
continue
|
|
}
|
|
}
|
|
|
|
ret = append(ret, &plans.Change{
|
|
Action: plans.Delete,
|
|
Before: old[oldI],
|
|
After: cty.NullVal(old[oldI].Type()),
|
|
})
|
|
oldI++
|
|
}
|
|
for newI < len(new) && (lcsI >= len(lcs) || !new[newI].RawEquals(lcs[lcsI])) {
|
|
ret = append(ret, &plans.Change{
|
|
Action: plans.Create,
|
|
Before: cty.NullVal(new[newI].Type()),
|
|
After: new[newI],
|
|
})
|
|
newI++
|
|
}
|
|
if lcsI < len(lcs) {
|
|
ret = append(ret, &plans.Change{
|
|
Action: plans.NoOp,
|
|
Before: new[newI],
|
|
After: new[newI],
|
|
})
|
|
|
|
// All of our indexes advance together now, since the line
|
|
// is common to all three sequences.
|
|
lcsI++
|
|
oldI++
|
|
newI++
|
|
}
|
|
}
|
|
return ret
|
|
}
|
|
|
|
// ctyObjectSimilarity returns a number between 0 and 1 that describes
|
|
// approximately how similar the two given values are, comparing in terms of
|
|
// how many of the corresponding attributes have the same value in both
|
|
// objects.
|
|
//
|
|
// This function expects the two values to have a similar set of attribute
|
|
// names, though doesn't mind if the two slightly differ since it will
|
|
// count missing attributes as differences.
|
|
//
|
|
// This function will panic if either of the given values is not an object.
|
|
func ctyObjectSimilarity(old, new cty.Value) float64 {
|
|
oldType := old.Type()
|
|
newType := new.Type()
|
|
attrNames := make(map[string]struct{})
|
|
for name := range oldType.AttributeTypes() {
|
|
attrNames[name] = struct{}{}
|
|
}
|
|
for name := range newType.AttributeTypes() {
|
|
attrNames[name] = struct{}{}
|
|
}
|
|
|
|
matches := 0
|
|
|
|
for name := range attrNames {
|
|
if !oldType.HasAttribute(name) {
|
|
continue
|
|
}
|
|
if !newType.HasAttribute(name) {
|
|
continue
|
|
}
|
|
eq := old.GetAttr(name).Equals(new.GetAttr(name))
|
|
if !eq.IsKnown() {
|
|
continue
|
|
}
|
|
if eq.True() {
|
|
matches++
|
|
}
|
|
}
|
|
|
|
return float64(matches) / float64(len(attrNames))
|
|
}
|
|
|
|
func ctyEqualWithUnknown(old, new cty.Value) bool {
|
|
if !old.IsKnown() || !new.IsKnown() {
|
|
return false
|
|
}
|
|
return old.Equals(new).True()
|
|
}
|
|
|
|
func ctyEnsurePathCapacity(path cty.Path, minExtra int) cty.Path {
|
|
if cap(path)-len(path) >= minExtra {
|
|
return path
|
|
}
|
|
newCap := cap(path) * 2
|
|
if newCap < (len(path) + minExtra) {
|
|
newCap = len(path) + minExtra
|
|
}
|
|
newPath := make(cty.Path, len(path), newCap)
|
|
copy(newPath, path)
|
|
return newPath
|
|
}
|