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.
This commit is contained in:
Alisdair McDiarmid 2021-09-17 14:09:20 -04:00
parent 78c4a8c461
commit b59b057591
5 changed files with 247 additions and 173 deletions

View File

@ -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"

View File

@ -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,
},

View File

@ -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

View File

@ -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

View File

@ -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