command: Render "moved" annotations in plan UI

For resources which are planned to move, render the previous run address
as additional information in the plan UI. For the case of a move-only
resource (which otherwise is unchanged), we also render that as a
planned change, but without any corresponding action symbol.

If all changes in the plan are moves without changes, the plan is no
longer considered "empty". In this case, we skip rendering the action
symbols in the UI.
This commit is contained in:
Alisdair McDiarmid 2021-09-03 12:10:16 -04:00
parent a9dfe7ba7b
commit 29999c1d6f
7 changed files with 248 additions and 20 deletions

View File

@ -72,13 +72,27 @@ func ResourceChange(
// Some extra context about this unusual situation. // Some extra context about this unusual situation.
buf.WriteString(color.Color("\n # (left over from a partially-failed replacement of this instance)")) buf.WriteString(color.Color("\n # (left over from a partially-failed replacement of this instance)"))
} }
case plans.NoOp:
if change.Moved() {
buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] has moved to [bold]%s[reset]", change.PrevRunAddr.String(), dispAddr)))
break
}
fallthrough
default: default:
// should never happen, since the above is exhaustive // 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(fmt.Sprintf("%s has an action the plan renderer doesn't support (this is a bug)", dispAddr))
} }
buf.WriteString(color.Color("[reset]\n")) buf.WriteString(color.Color("[reset]\n"))
if change.Moved() && change.Action != plans.NoOp {
buf.WriteString(color.Color(fmt.Sprintf("[bold] # [reset]([bold]%s[reset] has moved to [bold]%s[reset])\n", change.PrevRunAddr.String(), dispAddr)))
}
if change.Moved() && change.Action == plans.NoOp {
buf.WriteString(" ")
} else {
buf.WriteString(color.Color(DiffActionSymbol(change.Action)) + " ") buf.WriteString(color.Color(DiffActionSymbol(change.Action)) + " ")
}
switch addr.Resource.Resource.Mode { switch addr.Resource.Resource.Mode {
case addrs.ManagedResourceMode: case addrs.ManagedResourceMode:

View File

@ -4448,6 +4448,79 @@ func TestResourceChange_sensitiveVariable(t *testing.T) {
runTestCases(t, testCases) runTestCases(t, testCases)
} }
func TestResourceChange_moved(t *testing.T) {
prevRunAddr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_instance",
Name: "previous",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
testCases := map[string]testCase{
"moved and updated": {
PrevRunAddr: prevRunAddr,
Action: plans.Update,
Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("12345"),
"foo": cty.StringVal("hello"),
"bar": cty.StringVal("baz"),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("12345"),
"foo": cty.StringVal("hello"),
"bar": cty.StringVal("boop"),
}),
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
"foo": {Type: cty.String, Optional: true},
"bar": {Type: cty.String, Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
ExpectedOutput: ` # test_instance.example will be updated in-place
# (test_instance.previous has moved to test_instance.example)
~ resource "test_instance" "example" {
~ bar = "baz" -> "boop"
id = "12345"
# (1 unchanged attribute hidden)
}
`,
},
"moved without changes": {
PrevRunAddr: prevRunAddr,
Action: plans.NoOp,
Mode: addrs.ManagedResourceMode,
Before: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("12345"),
"foo": cty.StringVal("hello"),
"bar": cty.StringVal("baz"),
}),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("12345"),
"foo": cty.StringVal("hello"),
"bar": cty.StringVal("baz"),
}),
Schema: &configschema.Block{
Attributes: map[string]*configschema.Attribute{
"id": {Type: cty.String, Computed: true},
"foo": {Type: cty.String, Optional: true},
"bar": {Type: cty.String, Optional: true},
},
},
RequiredReplace: cty.NewPathSet(),
ExpectedOutput: ` # test_instance.previous has moved to test_instance.example
resource "test_instance" "example" {
id = "12345"
# (2 unchanged attributes hidden)
}
`,
},
}
runTestCases(t, testCases)
}
type testCase struct { type testCase struct {
Action plans.Action Action plans.Action
ActionReason plans.ResourceInstanceChangeActionReason ActionReason plans.ResourceInstanceChangeActionReason
@ -4460,6 +4533,7 @@ type testCase struct {
Schema *configschema.Block Schema *configschema.Block
RequiredReplace cty.PathSet RequiredReplace cty.PathSet
ExpectedOutput string ExpectedOutput string
PrevRunAddr addrs.AbsResourceInstance
} }
func runTestCases(t *testing.T, testCases map[string]testCase) { func runTestCases(t *testing.T, testCases map[string]testCase) {
@ -4493,12 +4567,22 @@ func runTestCases(t *testing.T, testCases map[string]testCase) {
t.Fatal(err) t.Fatal(err)
} }
change := &plans.ResourceInstanceChangeSrc{ addr := addrs.Resource{
Addr: addrs.Resource{
Mode: tc.Mode, Mode: tc.Mode,
Type: "test_instance", Type: "test_instance",
Name: "example", Name: "example",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
prevRunAddr := tc.PrevRunAddr
// If no previous run address is given, reuse the current address
// to make initialization easier
if prevRunAddr.Resource.Resource.Type == "" {
prevRunAddr = addr
}
change := &plans.ResourceInstanceChangeSrc{
Addr: addr,
PrevRunAddr: prevRunAddr,
DeposedKey: tc.DeposedKey, DeposedKey: tc.DeposedKey,
ProviderAddr: addrs.AbsProviderConfig{ ProviderAddr: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"), Provider: addrs.NewDefaultProvider("test"),

View File

@ -116,7 +116,7 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
counts := map[plans.Action]int{} counts := map[plans.Action]int{}
var rChanges []*plans.ResourceInstanceChangeSrc var rChanges []*plans.ResourceInstanceChangeSrc
for _, change := range plan.Changes.Resources { for _, change := range plan.Changes.Resources {
if change.Action == plans.NoOp { if change.Action == plans.NoOp && !change.Moved() {
continue // We don't show anything for no-op changes continue // We don't show anything for no-op changes
} }
if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode {
@ -125,8 +125,12 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
} }
rChanges = append(rChanges, change) rChanges = append(rChanges, change)
// Don't count move-only changes
if change.Action != plans.NoOp {
counts[change.Action]++ counts[change.Action]++
} }
}
var changedRootModuleOutputs []*plans.OutputChangeSrc var changedRootModuleOutputs []*plans.OutputChangeSrc
for _, output := range plan.Changes.Outputs { for _, output := range plan.Changes.Outputs {
if !output.Addr.Module.IsRoot() { if !output.Addr.Module.IsRoot() {
@ -138,7 +142,7 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
changedRootModuleOutputs = append(changedRootModuleOutputs, output) changedRootModuleOutputs = append(changedRootModuleOutputs, output)
} }
if len(counts) == 0 && len(changedRootModuleOutputs) == 0 { if len(rChanges) == 0 && len(changedRootModuleOutputs) == 0 {
// If we didn't find any changes to report at all then this is a // If we didn't find any changes to report at all then this is a
// "No changes" plan. How we'll present this depends on whether // "No changes" plan. How we'll present this depends on whether
// the plan is "applyable" and, if so, whether it had refresh changes // the plan is "applyable" and, if so, whether it had refresh changes
@ -225,7 +229,7 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
view.streams.Println("") view.streams.Println("")
} }
if len(counts) != 0 { if len(counts) > 0 {
headerBuf := &bytes.Buffer{} headerBuf := &bytes.Buffer{}
fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns()))) fmt.Fprintf(headerBuf, "\n%s\n", strings.TrimSpace(format.WordWrap(planHeaderIntro, view.outputColumns())))
if counts[plans.Create] > 0 { if counts[plans.Create] > 0 {
@ -247,9 +251,11 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read)) fmt.Fprintf(headerBuf, "%s read (data resources)\n", format.DiffActionSymbol(plans.Read))
} }
view.streams.Println(view.colorize.Color(headerBuf.String())) view.streams.Print(view.colorize.Color(headerBuf.String()))
}
view.streams.Printf("Terraform will perform the following actions:\n\n") if len(rChanges) > 0 {
view.streams.Printf("\nTerraform will perform the following actions:\n\n")
// Note: we're modifying the backing slice of this plan object in-place // Note: we're modifying the backing slice of this plan object in-place
// here. The ordering of resource changes in a plan is not significant, // here. The ordering of resource changes in a plan is not significant,
@ -265,7 +271,7 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) {
}) })
for _, rcs := range rChanges { for _, rcs := range rChanges {
if rcs.Action == plans.NoOp { if rcs.Action == plans.NoOp && !rcs.Moved() {
continue continue
} }

View File

@ -63,12 +63,15 @@ func testPlan(t *testing.T) *plans.Plan {
} }
changes := plans.NewChanges() changes := plans.NewChanges()
changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{ addr := addrs.Resource{
Addr: addrs.Resource{
Mode: addrs.ManagedResourceMode, Mode: addrs.ManagedResourceMode,
Type: "test_resource", Type: "test_resource",
Name: "foo", Name: "foo",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
changes.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChangeSrc{
Addr: addr,
PrevRunAddr: addr,
ProviderAddr: addrs.AbsProviderConfig{ ProviderAddr: addrs.AbsProviderConfig{
Provider: addrs.NewDefaultProvider("test"), Provider: addrs.NewDefaultProvider("test"),
Module: addrs.RootModule, Module: addrs.RootModule,

View File

@ -33,7 +33,7 @@ func NewChanges() *Changes {
func (c *Changes) Empty() bool { func (c *Changes) Empty() bool {
for _, res := range c.Resources { for _, res := range c.Resources {
if res.Action != NoOp { if res.Action != NoOp || res.Moved() {
return false return false
} }
} }

View File

@ -125,6 +125,10 @@ func (rcs *ResourceInstanceChangeSrc) DeepCopy() *ResourceInstanceChangeSrc {
return &ret return &ret
} }
func (rcs *ResourceInstanceChangeSrc) Moved() bool {
return !rcs.Addr.Equal(rcs.PrevRunAddr)
}
// OutputChangeSrc describes a change to an output value. // OutputChangeSrc describes a change to an output value.
type OutputChangeSrc struct { type OutputChangeSrc struct {
// Addr is the absolute address of the output value that the change // Addr is the absolute address of the output value that the change

View File

@ -4,10 +4,127 @@ import (
"fmt" "fmt"
"testing" "testing"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/lang/marks"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
) )
func TestChangesEmpty(t *testing.T) {
testCases := map[string]struct {
changes *Changes
want bool
}{
"no changes": {
&Changes{},
true,
},
"resource change": {
&Changes{
Resources: []*ResourceInstanceChangeSrc{
{
Addr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
PrevRunAddr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: Update,
},
},
},
},
false,
},
"resource change with no-op action": {
&Changes{
Resources: []*ResourceInstanceChangeSrc{
{
Addr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
PrevRunAddr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: NoOp,
},
},
},
},
true,
},
"resource moved with no-op change": {
&Changes{
Resources: []*ResourceInstanceChangeSrc{
{
Addr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "woot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
PrevRunAddr: addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_thing",
Name: "toot",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: NoOp,
},
},
},
},
false,
},
"output change": {
&Changes{
Outputs: []*OutputChangeSrc{
{
Addr: addrs.OutputValue{
Name: "result",
}.Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: Update,
},
},
},
},
false,
},
"output change no-op": {
&Changes{
Outputs: []*OutputChangeSrc{
{
Addr: addrs.OutputValue{
Name: "result",
}.Absolute(addrs.RootModuleInstance),
ChangeSrc: ChangeSrc{
Action: NoOp,
},
},
},
},
true,
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
if got, want := tc.changes.Empty(), tc.want; got != want {
t.Fatalf("unexpected result: got %v, want %v", got, want)
}
})
}
}
func TestChangeEncodeSensitive(t *testing.T) { func TestChangeEncodeSensitive(t *testing.T) {
testVals := []cty.Value{ testVals := []cty.Value{
cty.ObjectVal(map[string]cty.Value{ cty.ObjectVal(map[string]cty.Value{