Merge pull request #29603 from hashicorp/alisdair/moved-json-output

json-output: Add support for config-driven move to plan JSON and streaming JSON UI
This commit is contained in:
Alisdair McDiarmid 2021-09-20 16:49:11 -04:00 committed by GitHub
commit 5386d6c5b8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 650 additions and 203 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,12 @@
resource "test_instance" "baz" {
ami = "baz"
}
terraform {
experiments = [ config_driven_move ]
}
moved {
from = test_instance.foo
to = test_instance.baz
}

View File

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

View File

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

View File

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

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

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

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