From 78c4a8c4617d4fa615768f27a0bd50cc3f648cfe Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Fri, 17 Sep 2021 13:18:34 -0400 Subject: [PATCH 1/2] json-output: Previous address for resource changes Configuration-driven moves are represented in the plan file by setting the resource's `PrevRunAddr` to a different value than its `Addr`. For JSON plan output, we here add a new field to resource changes, `previous_address`, which is present and non-empty only if the resource is planned to be moved. Like the CLI UI, refresh-only plans will include move-only changes in the resource drift JSON output. In normal plan mode, these are elided to avoid redundancy with planned changes. --- internal/command/jsonplan/plan.go | 25 ++- internal/command/jsonplan/resource.go | 12 ++ .../testdata/show-json/moved-drift/main.tf | 22 +++ .../show-json/moved-drift/output.json | 177 ++++++++++++++++++ .../show-json/moved-drift/terraform.tfstate | 38 ++++ .../command/testdata/show-json/moved/main.tf | 12 ++ .../testdata/show-json/moved/output.json | 89 +++++++++ .../show-json/moved/terraform.tfstate | 23 +++ .../multi-resource-update/output.json | 27 +-- website/docs/internals/json-format.html.md | 8 +- 10 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 internal/command/testdata/show-json/moved-drift/main.tf create mode 100644 internal/command/testdata/show-json/moved-drift/output.json create mode 100644 internal/command/testdata/show-json/moved-drift/terraform.tfstate create mode 100644 internal/command/testdata/show-json/moved/main.tf create mode 100644 internal/command/testdata/show-json/moved/output.json create mode 100644 internal/command/testdata/show-json/moved/terraform.tfstate diff --git a/internal/command/jsonplan/plan.go b/internal/command/jsonplan/plan.go index 1d7eff1ff..b2bf2cceb 100644 --- a/internal/command/jsonplan/plan.go +++ b/internal/command/jsonplan/plan.go @@ -130,9 +130,25 @@ func Marshal( } // output.ResourceDrift - output.ResourceDrift, err = output.marshalResourceChanges(p.DriftedResources, schemas) - if err != nil { - return nil, fmt.Errorf("error in marshaling resource drift: %s", err) + if len(p.DriftedResources) > 0 { + // In refresh-only mode, we render 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. + var driftedResources []*plans.ResourceInstanceChangeSrc + if p.UIMode == plans.RefreshOnlyMode { + driftedResources = p.DriftedResources + } else { + for _, dr := range p.DriftedResources { + if dr.Action != plans.NoOp { + driftedResources = append(driftedResources, dr) + } + } + } + output.ResourceDrift, err = output.marshalResourceChanges(driftedResources, schemas) + if err != nil { + return nil, fmt.Errorf("error in marshaling resource drift: %s", err) + } } // output.ResourceChanges @@ -197,6 +213,9 @@ func (p *plan) marshalResourceChanges(resources []*plans.ResourceInstanceChangeS var r resourceChange addr := rc.Addr r.Address = addr.String() + if !addr.Equal(rc.PrevRunAddr) { + r.PreviousAddress = rc.PrevRunAddr.String() + } dataSource := addr.Resource.Resource.Mode == addrs.DataResourceMode // We create "delete" actions for data resources so we can clean up diff --git a/internal/command/jsonplan/resource.go b/internal/command/jsonplan/resource.go index ca1299c99..1e737a626 100644 --- a/internal/command/jsonplan/resource.go +++ b/internal/command/jsonplan/resource.go @@ -48,6 +48,18 @@ type resourceChange struct { // Address is the absolute resource address Address string `json:"address,omitempty"` + // PreviousAddress is the absolute address that this resource instance had + // at the conclusion of a previous run. + // + // This will typically be omitted, but will be present if the previous + // resource instance was subject to a "moved" block that we handled in the + // process of creating this plan. + // + // Note that this behavior diverges from the internal plan data structure, + // where the previous address is set equal to the current address in the + // common case, rather than being omitted. + PreviousAddress string `json:"previous_address,omitempty"` + // ModuleAddress is the module portion of the above address. Omitted if the // instance is in the root module. ModuleAddress string `json:"module_address,omitempty"` diff --git a/internal/command/testdata/show-json/moved-drift/main.tf b/internal/command/testdata/show-json/moved-drift/main.tf new file mode 100644 index 000000000..afdf9fe66 --- /dev/null +++ b/internal/command/testdata/show-json/moved-drift/main.tf @@ -0,0 +1,22 @@ +# In state with `ami = "foo"`, so this should be a regular update. The provider +# should not detect changes on refresh. +resource "test_instance" "no_refresh" { + ami = "bar" +} + +# In state with `ami = "refresh-me"`, but the provider will return +# `"refreshed"` after the refresh phase. The plan should show the drift +# (`"refresh-me"` to `"refreshed"`) and plan the update (`"refreshed"` to +# `"baz"`). +resource "test_instance" "should_refresh_with_move" { + ami = "baz" +} + +terraform { + experiments = [ config_driven_move ] +} + +moved { + from = test_instance.should_refresh + to = test_instance.should_refresh_with_move +} diff --git a/internal/command/testdata/show-json/moved-drift/output.json b/internal/command/testdata/show-json/moved-drift/output.json new file mode 100644 index 000000000..0d151808f --- /dev/null +++ b/internal/command/testdata/show-json/moved-drift/output.json @@ -0,0 +1,177 @@ +{ + "format_version": "0.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "test_instance.no_refresh", + "mode": "managed", + "type": "test_instance", + "name": "no_refresh", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "bar", + "id": "placeholder" + }, + "sensitive_values": {} + }, + { + "address": "test_instance.should_refresh_with_move", + "mode": "managed", + "type": "test_instance", + "name": "should_refresh_with_move", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz", + "id": "placeholder" + }, + "sensitive_values": {} + } + ] + } + }, + "resource_drift": [ + { + "address": "test_instance.should_refresh_with_move", + "mode": "managed", + "type": "test_instance", + "previous_address": "test_instance.should_refresh", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "should_refresh_with_move", + "change": { + "actions": [ + "update" + ], + "before": { + "ami": "refresh-me", + "id": "placeholder" + }, + "after": { + "ami": "refreshed", + "id": "placeholder" + }, + "after_sensitive": {}, + "after_unknown": {}, + "before_sensitive": {} + } + } + ], + "resource_changes": [ + { + "address": "test_instance.no_refresh", + "mode": "managed", + "type": "test_instance", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "no_refresh", + "change": { + "actions": [ + "update" + ], + "before": { + "ami": "foo", + "id": "placeholder" + }, + "after": { + "ami": "bar", + "id": "placeholder" + }, + "after_unknown": {}, + "after_sensitive": {}, + "before_sensitive": {} + } + }, + { + "address": "test_instance.should_refresh_with_move", + "mode": "managed", + "type": "test_instance", + "previous_address": "test_instance.should_refresh", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "should_refresh_with_move", + "change": { + "actions": [ + "update" + ], + "before": { + "ami": "refreshed", + "id": "placeholder" + }, + "after": { + "ami": "baz", + "id": "placeholder" + }, + "after_unknown": {}, + "after_sensitive": {}, + "before_sensitive": {} + } + } + ], + "prior_state": { + "format_version": "0.2", + "values": { + "root_module": { + "resources": [ + { + "address": "test_instance.no_refresh", + "mode": "managed", + "type": "test_instance", + "name": "no_refresh", + "schema_version": 0, + "provider_name": "registry.terraform.io/hashicorp/test", + "values": { + "ami": "foo", + "id": "placeholder" + }, + "sensitive_values": {} + }, + { + "address": "test_instance.should_refresh_with_move", + "mode": "managed", + "type": "test_instance", + "name": "should_refresh_with_move", + "schema_version": 0, + "provider_name": "registry.terraform.io/hashicorp/test", + "values": { + "ami": "refreshed", + "id": "placeholder" + }, + "sensitive_values": {} + } + ] + } + } + }, + "configuration": { + "root_module": { + "resources": [ + { + "address": "test_instance.no_refresh", + "mode": "managed", + "type": "test_instance", + "name": "no_refresh", + "provider_config_key": "test", + "schema_version": 0, + "expressions": { + "ami": { + "constant_value": "bar" + } + } + }, + { + "address": "test_instance.should_refresh_with_move", + "mode": "managed", + "type": "test_instance", + "name": "should_refresh_with_move", + "provider_config_key": "test", + "schema_version": 0, + "expressions": { + "ami": { + "constant_value": "baz" + } + } + } + ] + } + } +} diff --git a/internal/command/testdata/show-json/moved-drift/terraform.tfstate b/internal/command/testdata/show-json/moved-drift/terraform.tfstate new file mode 100644 index 000000000..02b8944d8 --- /dev/null +++ b/internal/command/testdata/show-json/moved-drift/terraform.tfstate @@ -0,0 +1,38 @@ +{ + "version": 4, + "terraform_version": "0.12.0", + "serial": 7, + "lineage": "configuredUnchanged", + "resources": [ + { + "mode": "managed", + "type": "test_instance", + "name": "no_refresh", + "provider": "provider[\"registry.terraform.io/hashicorp/test\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "ami": "foo", + "id": "placeholder" + } + } + ] + }, + { + "mode": "managed", + "type": "test_instance", + "name": "should_refresh", + "provider": "provider[\"registry.terraform.io/hashicorp/test\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "ami": "refresh-me", + "id": "placeholder" + } + } + ] + } + ] +} diff --git a/internal/command/testdata/show-json/moved/main.tf b/internal/command/testdata/show-json/moved/main.tf new file mode 100644 index 000000000..0be803cbc --- /dev/null +++ b/internal/command/testdata/show-json/moved/main.tf @@ -0,0 +1,12 @@ +resource "test_instance" "baz" { + ami = "baz" +} + +terraform { + experiments = [ config_driven_move ] +} + +moved { + from = test_instance.foo + to = test_instance.baz +} diff --git a/internal/command/testdata/show-json/moved/output.json b/internal/command/testdata/show-json/moved/output.json new file mode 100644 index 000000000..3ce281983 --- /dev/null +++ b/internal/command/testdata/show-json/moved/output.json @@ -0,0 +1,89 @@ +{ + "format_version": "0.2", + "planned_values": { + "root_module": { + "resources": [ + { + "address": "test_instance.baz", + "mode": "managed", + "type": "test_instance", + "name": "baz", + "provider_name": "registry.terraform.io/hashicorp/test", + "schema_version": 0, + "values": { + "ami": "baz", + "id": "placeholder" + }, + "sensitive_values": {} + } + ] + } + }, + "resource_changes": [ + { + "address": "test_instance.baz", + "mode": "managed", + "type": "test_instance", + "previous_address": "test_instance.foo", + "provider_name": "registry.terraform.io/hashicorp/test", + "name": "baz", + "change": { + "actions": [ + "update" + ], + "before": { + "ami": "foo", + "id": "placeholder" + }, + "after": { + "ami": "baz", + "id": "placeholder" + }, + "after_unknown": {}, + "after_sensitive": {}, + "before_sensitive": {} + } + } + ], + "prior_state": { + "format_version": "0.2", + "values": { + "root_module": { + "resources": [ + { + "address": "test_instance.baz", + "mode": "managed", + "type": "test_instance", + "name": "baz", + "schema_version": 0, + "provider_name": "registry.terraform.io/hashicorp/test", + "values": { + "ami": "foo", + "id": "placeholder" + }, + "sensitive_values": {} + } + ] + } + } + }, + "configuration": { + "root_module": { + "resources": [ + { + "address": "test_instance.baz", + "mode": "managed", + "type": "test_instance", + "name": "baz", + "provider_config_key": "test", + "schema_version": 0, + "expressions": { + "ami": { + "constant_value": "baz" + } + } + } + ] + } + } +} diff --git a/internal/command/testdata/show-json/moved/terraform.tfstate b/internal/command/testdata/show-json/moved/terraform.tfstate new file mode 100644 index 000000000..b4e571887 --- /dev/null +++ b/internal/command/testdata/show-json/moved/terraform.tfstate @@ -0,0 +1,23 @@ +{ + "version": 4, + "terraform_version": "0.12.0", + "serial": 7, + "lineage": "configuredUnchanged", + "resources": [ + { + "mode": "managed", + "type": "test_instance", + "name": "foo", + "provider": "provider[\"registry.terraform.io/hashicorp/test\"]", + "instances": [ + { + "schema_version": 0, + "attributes": { + "ami": "foo", + "id": "placeholder" + } + } + ] + } + ] +} diff --git a/internal/command/testdata/show-json/multi-resource-update/output.json b/internal/command/testdata/show-json/multi-resource-update/output.json index 247749261..262b6194b 100644 --- a/internal/command/testdata/show-json/multi-resource-update/output.json +++ b/internal/command/testdata/show-json/multi-resource-update/output.json @@ -45,32 +45,6 @@ ] } }, - "resource_drift": [ - { - "address": "test_instance.test[0]", - "mode": "managed", - "type": "test_instance", - "provider_name": "registry.terraform.io/hashicorp/test", - "name": "test", - "index": 0, - "change": { - "actions": [ - "no-op" - ], - "before": { - "ami": "bar", - "id": "placeholder" - }, - "after": { - "ami": "bar", - "id": "placeholder" - }, - "before_sensitive": {}, - "after_sensitive": {}, - "after_unknown": {} - } - } - ], "resource_changes": [ { "address": "test_instance.test[0]", @@ -78,6 +52,7 @@ "type": "test_instance", "name": "test", "index": 0, + "previous_address": "test_instance.test", "provider_name": "registry.terraform.io/hashicorp/test", "change": { "actions": [ diff --git a/website/docs/internals/json-format.html.md b/website/docs/internals/json-format.html.md index 9a3efeff5..ffe54c2cc 100644 --- a/website/docs/internals/json-format.html.md +++ b/website/docs/internals/json-format.html.md @@ -98,9 +98,15 @@ For ease of consumption by callers, the plan representation includes a partial r { // "address" is the full absolute address of the resource instance this // change applies to, in the same format as addresses in a value - // representation + // representation. "address": "module.child.aws_instance.foo[0]", + // "previous_address" is the full absolute address of this resource + // instance as it was known after the previous Terraform run. + // Included only if the address has changed, e.g. by handling + // a "moved" block in the configuration. + "previous_address": "module.instances.aws_instance.foo[0]", + // "module_address", if set, is the module portion of the above address. // Omitted if the instance is in the root module. "module_address": "module.child", From b59b057591bfab5e1561a66a249df360756a6e60 Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Fri, 17 Sep 2021 14:09:20 -0400 Subject: [PATCH 2/2] json-output: Config-driven move support in JSON UI Add previous address information to the `planned_change` and `resource_drift` messages for the streaming JSON UI output of plan and apply operations. Here we also add a "move" action value to the `change` object of these messages, to represent a move-only operation. As part of this work we also simplify this code to use the plan's DriftedResources values instead of recomputing the drift from state. --- internal/command/views/json/change.go | 15 +- internal/command/views/json_view_test.go | 6 +- internal/command/views/operation.go | 103 +----- internal/command/views/operation_test.go | 293 +++++++++++++----- .../internals/machine-readable-ui.html.md | 3 +- 5 files changed, 247 insertions(+), 173 deletions(-) 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