diff --git a/backend/local/backend_apply.go b/backend/local/backend_apply.go index c1c0af48c..1b479c752 100644 --- a/backend/local/backend_apply.go +++ b/backend/local/backend_apply.go @@ -10,7 +10,6 @@ import ( "github.com/hashicorp/errwrap" "github.com/hashicorp/terraform/backend" - "github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/states/statefile" "github.com/hashicorp/terraform/states/statemgr" @@ -84,8 +83,7 @@ func (b *Local) opApply( return } - dispPlan := format.NewPlan(plan.Changes) - trivialPlan := dispPlan.Empty() + trivialPlan := plan.Changes.Empty() hasUI := op.UIOut != nil && op.UIIn != nil mustConfirm := hasUI && ((op.Destroy && (!op.DestroyForce && !op.AutoApprove)) || (!op.Destroy && !op.AutoApprove && !trivialPlan)) if mustConfirm { @@ -110,7 +108,7 @@ func (b *Local) opApply( if !trivialPlan { // Display the plan of what we are going to apply/destroy. - b.renderPlan(dispPlan) + b.renderPlan(plan, tfCtx.Schemas()) b.CLI.Output("") } diff --git a/backend/local/backend_plan.go b/backend/local/backend_plan.go index df8586404..66595e20b 100644 --- a/backend/local/backend_plan.go +++ b/backend/local/backend_plan.go @@ -135,13 +135,14 @@ func (b *Local) opPlan( // Perform some output tasks if we have a CLI to output to. if b.CLI != nil { - dispPlan := format.NewPlan(plan.Changes) - if dispPlan.Empty() { + schemas := tfCtx.Schemas() + + if plan.Changes.Empty() { b.CLI.Output("\n" + b.Colorize().Color(strings.TrimSpace(planNoChanges))) return } - b.renderPlan(dispPlan) + b.renderPlan(plan, schemas) // If we've accumulated any warnings along the way then we'll show them // here just before we show the summary and next steps. If we encountered @@ -169,24 +170,31 @@ func (b *Local) opPlan( } } -func (b *Local) renderPlan(dispPlan *format.Plan) { +func (b *Local) renderPlan(plan *plans.Plan, schemas *terraform.Schemas) { + + counts := map[plans.Action]int{} + for _, change := range plan.Changes.Resources { + counts[change.Action]++ + } + for _, change := range plan.Changes.RootOutputs { + counts[change.Action]++ + } headerBuf := &bytes.Buffer{} fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(planHeaderIntro)) - counts := dispPlan.ActionCounts() - if counts[terraform.DiffCreate] > 0 { + if counts[plans.Create] > 0 { fmt.Fprintf(headerBuf, "%s create\n", format.DiffActionSymbol(terraform.DiffCreate)) } - if counts[terraform.DiffUpdate] > 0 { + if counts[plans.Update] > 0 { fmt.Fprintf(headerBuf, "%s update in-place\n", format.DiffActionSymbol(terraform.DiffUpdate)) } - if counts[terraform.DiffDestroy] > 0 { + if counts[plans.Delete] > 0 { fmt.Fprintf(headerBuf, "%s destroy\n", format.DiffActionSymbol(terraform.DiffDestroy)) } - if counts[terraform.DiffDestroyCreate] > 0 { + if counts[plans.Replace] > 0 { fmt.Fprintf(headerBuf, "%s destroy and then create replacement\n", format.DiffActionSymbol(terraform.DiffDestroyCreate)) } - if counts[terraform.DiffRefresh] > 0 { + if counts[plans.Read] > 0 { fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(terraform.DiffRefresh)) } @@ -194,13 +202,46 @@ func (b *Local) renderPlan(dispPlan *format.Plan) { b.CLI.Output("Terraform will perform the following actions:\n") - b.CLI.Output(dispPlan.Format(b.Colorize())) + for _, rcs := range plan.Changes.Resources { + if rcs.Action == plans.NoOp { + continue + } + providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.ProviderConfig.Type) + if providerSchema == nil { + // Should never happen + b.CLI.Output(fmt.Sprintf("(schema missing for %s)\n", rcs.ProviderAddr)) + continue + } + rSchema := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource) + if rSchema == nil { + // Should never happen + b.CLI.Output(fmt.Sprintf("(schema missing for %s)\n", rcs.Addr)) + continue + } + b.CLI.Output(format.ResourceChange( + rcs, + rSchema, + b.CLIColor, + )) + } - stats := dispPlan.Stats() + // stats is similar to counts above, but: + // - it considers only resource changes + // - it simplifies "replace" into both a create and a delete + stats := map[plans.Action]int{} + for _, change := range plan.Changes.Resources { + switch change.Action { + case plans.Replace: + counts[plans.Create]++ + counts[plans.Delete]++ + default: + counts[change.Action]++ + } + } b.CLI.Output(b.Colorize().Color(fmt.Sprintf( "[reset][bold]Plan:[reset] "+ "%d to add, %d to change, %d to destroy.", - stats.ToAdd, stats.ToChange, stats.ToDestroy, + stats[plans.Create], stats[plans.Update], stats[plans.Delete], ))) } diff --git a/command/format/diff.go b/command/format/diff.go new file mode 100644 index 000000000..6b7cf4dfc --- /dev/null +++ b/command/format/diff.go @@ -0,0 +1,846 @@ +package format + +import ( + "bufio" + "bytes" + "fmt" + "sort" + "strings" + + "github.com/mitchellh/colorstring" + "github.com/zclconf/go-cty/cty" + + "github.com/hashicorp/terraform/addrs" + "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/plans/objchange" +) + +// 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, + } + } + + buf.WriteString(color.Color("[reset]")) + + 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.Replace: + buf.WriteString(color.Color("[red]-[reset]/[green]+[reset] ")) + case plans.Delete: + buf.WriteString(color.Color("[red] -[reset] ")) + default: + // should never happen, since the above is exhaustive + buf.WriteString(color.Color("??? ")) + } + + switch addr.Resource.Resource.Mode { + case addrs.ManagedResourceMode: + buf.WriteString(color.Color(fmt.Sprintf( + "resource [bold]%q[reset] [bold]%q[reset]", + addr.Resource.Resource.Type, + addr.Resource.Resource.Name, + ))) + if addr.Resource.Key != addrs.NoKey { + buf.WriteString(fmt.Sprintf(" %s", addr.Resource.Key)) + } + case addrs.DataResourceMode: + buf.WriteString(color.Color(fmt.Sprintf( + "data [bold]%q[reset] [bold]%q[reset] ", + addr.Resource.Resource.Type, + addr.Resource.Resource.Name, + ))) + if addr.Resource.Key != addrs.NoKey { + buf.WriteString(fmt.Sprintf(" %s", addr.Resource.Key)) + } + default: + // should never happen, since the above is exhaustive + buf.WriteString(addr.String()) + } + + buf.WriteString(" {") + if change.Action == plans.Replace { + buf.WriteString(color.Color(" [bold][red]# new resource required[reset]")) + } + 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 new resource)[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: + 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") + + it := val.ElementIterator() + for 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(" = ") + 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: list diffs using longest-common-subsequence matching algorithm + // TODO: map diffs showing changes on a per-key basis + // TODO: multi-line string diffs showing lines added/removed using longest-common-subsequence + + case ty == cty.String: + // We only have special behavior for multi-line strings here + oldS := old.AsString() + newS := new.AsString() + 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 + } + } + + // 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 != plans.Replace { + // "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 +} diff --git a/terraform/context.go b/terraform/context.go index a3df33c7c..5f9b3164d 100644 --- a/terraform/context.go +++ b/terraform/context.go @@ -225,6 +225,10 @@ func NewContext(opts *ContextOpts) (*Context, tfdiags.Diagnostics) { }, nil } +func (c *Context) Schemas() *Schemas { + return c.schemas +} + type ContextGraphOpts struct { // If true, validates the graph structure (checks for cycles). Validate bool