diff --git a/internal/command/views/json/change.go b/internal/command/views/json/change.go index bee20904e..c18a2c15a 100644 --- a/internal/command/views/json/change.go +++ b/internal/command/views/json/change.go @@ -12,14 +12,22 @@ func NewResourceInstanceChange(change *plans.ResourceInstanceChangeSrc) *Resourc Action: changeAction(change.Action), Reason: changeReason(change.ActionReason), } + if !change.Addr.Equal(change.PrevRunAddr) { + if c.Action == ActionNoOp { + c.Action = ActionMove + } + pr := newResourceAddr(change.PrevRunAddr) + c.PreviousResource = &pr + } return c } type ResourceInstanceChange struct { - Resource ResourceAddr `json:"resource"` - Action ChangeAction `json:"action"` - Reason ChangeReason `json:"reason,omitempty"` + Resource ResourceAddr `json:"resource"` + PreviousResource *ResourceAddr `json:"previous_resource,omitempty"` + Action ChangeAction `json:"action"` + Reason ChangeReason `json:"reason,omitempty"` } func (c *ResourceInstanceChange) String() string { @@ -30,6 +38,7 @@ type ChangeAction string const ( ActionNoOp ChangeAction = "noop" + ActionMove ChangeAction = "move" ActionCreate ChangeAction = "create" ActionRead ChangeAction = "read" ActionUpdate ChangeAction = "update" diff --git a/internal/command/views/json_view_test.go b/internal/command/views/json_view_test.go index c755cf0b7..6bb5c4913 100644 --- a/internal/command/views/json_view_test.go +++ b/internal/command/views/json_view_test.go @@ -111,7 +111,8 @@ func TestJSONView_PlannedChange(t *testing.T) { } managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} cs := &plans.ResourceInstanceChangeSrc{ - Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), ChangeSrc: plans.ChangeSrc{ Action: plans.Create, }, @@ -151,7 +152,8 @@ func TestJSONView_ResourceDrift(t *testing.T) { } managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} cs := &plans.ResourceInstanceChangeSrc{ - Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + PrevRunAddr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), ChangeSrc: plans.ChangeSrc{ Action: plans.Update, }, diff --git a/internal/command/views/operation.go b/internal/command/views/operation.go index b38e93b6f..01daedc39 100644 --- a/internal/command/views/operation.go +++ b/internal/command/views/operation.go @@ -3,7 +3,6 @@ package views import ( "bytes" "fmt" - "sort" "strings" "github.com/hashicorp/terraform/internal/addrs" @@ -11,11 +10,9 @@ import ( "github.com/hashicorp/terraform/internal/command/format" "github.com/hashicorp/terraform/internal/command/views/json" "github.com/hashicorp/terraform/internal/plans" - "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/tfdiags" - "github.com/zclconf/go-cty/cty" ) type Operation interface { @@ -163,10 +160,14 @@ func (v *OperationJSON) EmergencyDumpState(stateFile *statefile.File) error { // Log a change summary and a series of "planned" messages for the changes in // the plan. func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { - if err := v.resourceDrift(plan.PrevRunState, plan.PriorState, schemas); err != nil { - var diags tfdiags.Diagnostics - diags = diags.Append(err) - v.Diagnostics(diags) + for _, dr := range plan.DriftedResources { + // In refresh-only mode, we output all resources marked as drifted, + // including those which have moved without other changes. In other plan + // modes, move-only changes will be included in the planned changes, so + // we skip them here. + if dr.Action != plans.NoOp || plan.UIMode == plans.RefreshOnlyMode { + v.view.ResourceDrift(json.NewResourceInstanceChange(dr)) + } } cs := &json.ChangeSummary{ @@ -189,7 +190,7 @@ func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { cs.Remove++ } - if change.Action != plans.NoOp { + if change.Action != plans.NoOp || !change.Addr.Equal(change.PrevRunAddr) { v.view.PlannedChange(json.NewResourceInstanceChange(change)) } } @@ -208,92 +209,6 @@ func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { } } -func (v *OperationJSON) resourceDrift(oldState, newState *states.State, schemas *terraform.Schemas) error { - if newState.ManagedResourcesEqual(oldState) { - // Nothing to do, because we only detect and report drift for managed - // resource instances. - return nil - } - var changes []*json.ResourceInstanceChange - for _, ms := range oldState.Modules { - for _, rs := range ms.Resources { - if rs.Addr.Resource.Mode != addrs.ManagedResourceMode { - // Drift reporting is only for managed resources - continue - } - - provider := rs.ProviderConfig.Provider - for key, oldIS := range rs.Instances { - if oldIS.Current == nil { - // Not interested in instances that only have deposed objects - continue - } - addr := rs.Addr.Instance(key) - newIS := newState.ResourceInstance(addr) - - schema, _ := schemas.ResourceTypeConfig( - provider, - addr.Resource.Resource.Mode, - addr.Resource.Resource.Type, - ) - if schema == nil { - return fmt.Errorf("no schema found for %s (in provider %s)", addr, provider) - } - ty := schema.ImpliedType() - - oldObj, err := oldIS.Current.Decode(ty) - if err != nil { - return fmt.Errorf("failed to decode previous run data for %s: %s", addr, err) - } - - var newObj *states.ResourceInstanceObject - if newIS != nil && newIS.Current != nil { - newObj, err = newIS.Current.Decode(ty) - if err != nil { - return fmt.Errorf("failed to decode refreshed data for %s: %s", addr, err) - } - } - - var oldVal, newVal cty.Value - oldVal = oldObj.Value - if newObj != nil { - newVal = newObj.Value - } else { - newVal = cty.NullVal(ty) - } - - if oldVal.RawEquals(newVal) { - // No drift if the two values are semantically equivalent - continue - } - - // We can only detect updates and deletes as drift. - action := plans.Update - if newVal.IsNull() { - action = plans.Delete - } - - change := &plans.ResourceInstanceChangeSrc{ - Addr: addr, - ChangeSrc: plans.ChangeSrc{ - Action: action, - }, - } - changes = append(changes, json.NewResourceInstanceChange(change)) - } - } - } - - // Sort the change structs lexically by address to give stable output - sort.Slice(changes, func(i, j int) bool { return changes[i].Resource.Addr < changes[j].Resource.Addr }) - - for _, change := range changes { - v.view.ResourceDrift(change) - } - - return nil -} - func (v *OperationJSON) PlannedChange(change *plans.ResourceInstanceChangeSrc) { if change.Action == plans.Delete && change.Addr.Resource.Resource.Mode == addrs.DataResourceMode { // Avoid rendering data sources on deletion diff --git a/internal/command/views/operation_test.go b/internal/command/views/operation_test.go index 56ced3577..aa86fe144 100644 --- a/internal/command/views/operation_test.go +++ b/internal/command/views/operation_test.go @@ -479,29 +479,35 @@ func TestOperationJSON_plan(t *testing.T) { Changes: &plans.Changes{ Resources: []*plans.ResourceInstanceChangeSrc{ { - Addr: boop.Instance(addrs.IntKey(0)).Absolute(root), - ChangeSrc: plans.ChangeSrc{Action: plans.CreateThenDelete}, + Addr: boop.Instance(addrs.IntKey(0)).Absolute(root), + PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.CreateThenDelete}, }, { - Addr: boop.Instance(addrs.IntKey(1)).Absolute(root), - ChangeSrc: plans.ChangeSrc{Action: plans.Create}, + Addr: boop.Instance(addrs.IntKey(1)).Absolute(root), + PrevRunAddr: boop.Instance(addrs.IntKey(1)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Create}, }, { - Addr: boop.Instance(addrs.IntKey(0)).Absolute(vpc), - ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + Addr: boop.Instance(addrs.IntKey(0)).Absolute(vpc), + PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(vpc), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, }, { - Addr: beep.Instance(addrs.NoKey).Absolute(root), - ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate}, + Addr: beep.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate}, }, { - Addr: beep.Instance(addrs.NoKey).Absolute(vpc), - ChangeSrc: plans.ChangeSrc{Action: plans.Update}, + Addr: beep.Instance(addrs.NoKey).Absolute(vpc), + PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(vpc), + ChangeSrc: plans.ChangeSrc{Action: plans.Update}, }, // Data source deletion should not show up in the logs { - Addr: derp.Instance(addrs.NoKey).Absolute(root), - ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + Addr: derp.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: derp.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, }, }, }, @@ -623,74 +629,175 @@ func TestOperationJSON_plan(t *testing.T) { testJSONViewOutputEquals(t, done(t).Stdout(), want) } -func TestOperationJSON_planDrift(t *testing.T) { +func TestOperationJSON_planDriftWithMove(t *testing.T) { streams, done := terminal.StreamsForTesting(t) v := &OperationJSON{view: NewJSONView(NewView(streams))} root := addrs.RootModuleInstance boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"} beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"} - derp := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "derp"} + blep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "blep"} + honk := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "honk"} plan := &plans.Plan{ + UIMode: plans.NormalMode, + Changes: &plans.Changes{ + Resources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: honk.Instance(addrs.StringKey("bonk")).Absolute(root), + PrevRunAddr: honk.Instance(addrs.IntKey(0)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.NoOp}, + }, + }, + }, + DriftedResources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: beep.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + }, + { + Addr: boop.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: blep.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Update}, + }, + // Move-only resource drift should not be present in normal mode plans + { + Addr: honk.Instance(addrs.StringKey("bonk")).Absolute(root), + PrevRunAddr: honk.Instance(addrs.IntKey(0)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.NoOp}, + }, + }, + } + v.Plan(plan, testSchemas()) + + want := []map[string]interface{}{ + // Drift detected: delete + { + "@level": "info", + "@message": "test_resource.beep: Drift detected (delete)", + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "delete", + "resource": map[string]interface{}{ + "addr": "test_resource.beep", + "implied_provider": "test", + "module": "", + "resource": "test_resource.beep", + "resource_key": nil, + "resource_name": "beep", + "resource_type": "test_resource", + }, + }, + }, + // Drift detected: update with move + { + "@level": "info", + "@message": "test_resource.boop: Drift detected (update)", + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": "test_resource.boop", + "implied_provider": "test", + "module": "", + "resource": "test_resource.boop", + "resource_key": nil, + "resource_name": "boop", + "resource_type": "test_resource", + }, + "previous_resource": map[string]interface{}{ + "addr": "test_resource.blep", + "implied_provider": "test", + "module": "", + "resource": "test_resource.blep", + "resource_key": nil, + "resource_name": "blep", + "resource_type": "test_resource", + }, + }, + }, + // Move-only change + { + "@level": "info", + "@message": `test_resource.honk["bonk"]: Plan to move`, + "@module": "terraform.ui", + "type": "planned_change", + "change": map[string]interface{}{ + "action": "move", + "resource": map[string]interface{}{ + "addr": `test_resource.honk["bonk"]`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.honk["bonk"]`, + "resource_key": "bonk", + "resource_name": "honk", + "resource_type": "test_resource", + }, + "previous_resource": map[string]interface{}{ + "addr": `test_resource.honk[0]`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.honk[0]`, + "resource_key": float64(0), + "resource_name": "honk", + "resource_type": "test_resource", + }, + }, + }, + // No changes + { + "@level": "info", + "@message": "Plan: 0 to add, 0 to change, 0 to destroy.", + "@module": "terraform.ui", + "type": "change_summary", + "changes": map[string]interface{}{ + "operation": "plan", + "add": float64(0), + "change": float64(0), + "remove": float64(0), + }, + }, + } + + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + +func TestOperationJSON_planDriftWithMoveRefreshOnly(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := &OperationJSON{view: NewJSONView(NewView(streams))} + + root := addrs.RootModuleInstance + boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "boop"} + beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "beep"} + blep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "blep"} + honk := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_resource", Name: "honk"} + + plan := &plans.Plan{ + UIMode: plans.RefreshOnlyMode, Changes: &plans.Changes{ Resources: []*plans.ResourceInstanceChangeSrc{}, }, - PrevRunState: states.BuildState(func(state *states.SyncState) { - // Update - state.SetResourceInstanceCurrent( - boop.Instance(addrs.NoKey).Absolute(root), - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"foo":"bar"}`), - }, - root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), - ) - // Delete - state.SetResourceInstanceCurrent( - beep.Instance(addrs.NoKey).Absolute(root), - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"foo":"boop"}`), - }, - root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), - ) - // No-op - state.SetResourceInstanceCurrent( - derp.Instance(addrs.NoKey).Absolute(root), - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"foo":"boop"}`), - }, - root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), - ) - }), - PriorState: states.BuildState(func(state *states.SyncState) { - // Update - state.SetResourceInstanceCurrent( - boop.Instance(addrs.NoKey).Absolute(root), - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"foo":"baz"}`), - }, - root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), - ) - // Delete - state.SetResourceInstanceCurrent( - beep.Instance(addrs.NoKey).Absolute(root), - nil, - root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), - ) - // No-op - state.SetResourceInstanceCurrent( - derp.Instance(addrs.NoKey).Absolute(root), - &states.ResourceInstanceObjectSrc{ - Status: states.ObjectReady, - AttrsJSON: []byte(`{"foo":"boop"}`), - }, - root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), - ) - }), + DriftedResources: []*plans.ResourceInstanceChangeSrc{ + { + Addr: beep.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: beep.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + }, + { + Addr: boop.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: blep.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Update}, + }, + // Move-only resource drift should be present in refresh-only plans + { + Addr: honk.Instance(addrs.StringKey("bonk")).Absolute(root), + PrevRunAddr: honk.Instance(addrs.IntKey(0)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.NoOp}, + }, + }, } v.Plan(plan, testSchemas()) @@ -731,6 +838,43 @@ func TestOperationJSON_planDrift(t *testing.T) { "resource_name": "boop", "resource_type": "test_resource", }, + "previous_resource": map[string]interface{}{ + "addr": "test_resource.blep", + "implied_provider": "test", + "module": "", + "resource": "test_resource.blep", + "resource_key": nil, + "resource_name": "blep", + "resource_type": "test_resource", + }, + }, + }, + // Drift detected: Move-only change + { + "@level": "info", + "@message": `test_resource.honk["bonk"]: Drift detected (move)`, + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "move", + "resource": map[string]interface{}{ + "addr": `test_resource.honk["bonk"]`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.honk["bonk"]`, + "resource_key": "bonk", + "resource_name": "honk", + "resource_type": "test_resource", + }, + "previous_resource": map[string]interface{}{ + "addr": `test_resource.honk[0]`, + "implied_provider": "test", + "module": "", + "resource": `test_resource.honk[0]`, + "resource_key": float64(0), + "resource_name": "honk", + "resource_type": "test_resource", + }, }, }, // No changes @@ -846,20 +990,23 @@ func TestOperationJSON_plannedChange(t *testing.T) { // Replace requested by user v.PlannedChange(&plans.ResourceInstanceChangeSrc{ Addr: boop.Instance(addrs.IntKey(0)).Absolute(root), + PrevRunAddr: boop.Instance(addrs.IntKey(0)).Absolute(root), ChangeSrc: plans.ChangeSrc{Action: plans.DeleteThenCreate}, ActionReason: plans.ResourceInstanceReplaceByRequest, }) // Simple create v.PlannedChange(&plans.ResourceInstanceChangeSrc{ - Addr: boop.Instance(addrs.IntKey(1)).Absolute(root), - ChangeSrc: plans.ChangeSrc{Action: plans.Create}, + Addr: boop.Instance(addrs.IntKey(1)).Absolute(root), + PrevRunAddr: boop.Instance(addrs.IntKey(1)).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Create}, }) // Data source deletion v.PlannedChange(&plans.ResourceInstanceChangeSrc{ - Addr: derp.Instance(addrs.NoKey).Absolute(root), - ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, + Addr: derp.Instance(addrs.NoKey).Absolute(root), + PrevRunAddr: derp.Instance(addrs.NoKey).Absolute(root), + ChangeSrc: plans.ChangeSrc{Action: plans.Delete}, }) // Expect only two messages, as the data source deletion should be a no-op diff --git a/website/docs/internals/machine-readable-ui.html.md b/website/docs/internals/machine-readable-ui.html.md index 250481eb7..53b3e47e0 100644 --- a/website/docs/internals/machine-readable-ui.html.md +++ b/website/docs/internals/machine-readable-ui.html.md @@ -124,7 +124,8 @@ This message does not include details about the exact changes which caused the c At the end of a plan or before an apply, Terraform will emit a `planned_change` message for each resource which has changes to apply. This message has an embedded `change` object with the following keys: - `resource`: object describing the address of the resource to be changed; see [resource object](#resource-object) below for details -- `action`: the action planned to be taken for the resource. Values: `noop`, `create`, `read`, `update`, `replace`, `delete`. +- `previous_resource`: object describing the previous address of the resource, if this change includes a configuration-driven move +- `action`: the action planned to be taken for the resource. Values: `noop`, `create`, `read`, `update`, `replace`, `delete`, `move`. - `reason`: an optional reason for the change, currently only used when the action is `replace`. Values: - `tainted`: resource was marked as tainted - `requested`: user requested that the resource be replaced, for example via the `-replace` plan flag