diff --git a/internal/command/views/json/message_types.go b/internal/command/views/json/message_types.go index eb10c7b1e..5d19705d0 100644 --- a/internal/command/views/json/message_types.go +++ b/internal/command/views/json/message_types.go @@ -9,6 +9,7 @@ const ( MessageDiagnostic MessageType = "diagnostic" // Operation results + MessageResourceDrift MessageType = "resource_drift" MessagePlannedChange MessageType = "planned_change" MessageChangeSummary MessageType = "change_summary" MessageOutputs MessageType = "outputs" diff --git a/internal/command/views/json_view.go b/internal/command/views/json_view.go index 8f2b166f7..e1c3db6d7 100644 --- a/internal/command/views/json_view.go +++ b/internal/command/views/json_view.go @@ -95,6 +95,14 @@ func (v *JSONView) PlannedChange(c *json.ResourceInstanceChange) { ) } +func (v *JSONView) ResourceDrift(c *json.ResourceInstanceChange) { + v.log.Info( + fmt.Sprintf("%s: Drift detected (%s)", c.Resource.Addr, c.Action), + "type", json.MessageResourceDrift, + "change", c, + ) +} + func (v *JSONView) ChangeSummary(cs *json.ChangeSummary) { v.log.Info( cs.String(), diff --git a/internal/command/views/json_view_test.go b/internal/command/views/json_view_test.go index 698f2952e..a88e50299 100644 --- a/internal/command/views/json_view_test.go +++ b/internal/command/views/json_view_test.go @@ -141,6 +141,46 @@ func TestJSONView_PlannedChange(t *testing.T) { testJSONViewOutputEquals(t, done(t).Stdout(), want) } +func TestJSONView_ResourceDrift(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + jv := NewJSONView(NewView(streams)) + + foo, diags := addrs.ParseModuleInstanceStr("module.foo") + if len(diags) > 0 { + t.Fatal(diags.Err()) + } + managed := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "bar"} + cs := &plans.ResourceInstanceChangeSrc{ + Addr: managed.Instance(addrs.StringKey("boop")).Absolute(foo), + ChangeSrc: plans.ChangeSrc{ + Action: plans.Update, + }, + } + jv.ResourceDrift(viewsjson.NewResourceInstanceChange(cs)) + + want := []map[string]interface{}{ + { + "@level": "info", + "@message": `module.foo.test_instance.bar["boop"]: Drift detected (update)`, + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": `module.foo.test_instance.bar["boop"]`, + "implied_provider": "test", + "module": "module.foo", + "resource": `test_instance.bar["boop"]`, + "resource_key": "boop", + "resource_name": "bar", + "resource_type": "test_instance", + }, + }, + }, + } + testJSONViewOutputEquals(t, done(t).Stdout(), want) +} + func TestJSONView_ChangeSummary(t *testing.T) { streams, done := terminal.StreamsForTesting(t) jv := NewJSONView(NewView(streams)) diff --git a/internal/command/views/operation.go b/internal/command/views/operation.go index 486455324..d564ea9fc 100644 --- a/internal/command/views/operation.go +++ b/internal/command/views/operation.go @@ -10,9 +10,11 @@ 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 { @@ -160,6 +162,12 @@ 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) + } + cs := &json.ChangeSummary{ Operation: json.OperationPlanned, } @@ -188,6 +196,83 @@ func (v *OperationJSON) Plan(plan *plans.Plan, schemas *terraform.Schemas) { v.view.ChangeSummary(cs) } +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 + } + 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, + }, + } + v.view.ResourceDrift(json.NewResourceInstanceChange(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 a3b3392ea..4f9c20e8a 100644 --- a/internal/command/views/operation_test.go +++ b/internal/command/views/operation_test.go @@ -483,8 +483,8 @@ func TestOperationJSON_plan(t *testing.T) { if len(diags) > 0 { t.Fatal(diags.Err()) } - boop := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "boop"} - beep := addrs.Resource{Mode: addrs.ManagedResourceMode, Type: "test_instance", Name: "beep"} + 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.DataResourceMode, Type: "test_source", Name: "derp"} plan := &plans.Plan{ @@ -517,102 +517,195 @@ func TestOperationJSON_plan(t *testing.T) { }, }, }, + PrevRunState: states.BuildState(func(state *states.SyncState) { + // Update + state.SetResourceInstanceCurrent( + boop.Instance(addrs.IntKey(0)).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"bar"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // Delete + state.SetResourceInstanceCurrent( + boop.Instance(addrs.IntKey(1)).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"boop"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // No-op + state.SetResourceInstanceCurrent( + beep.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.IntKey(0)).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"baz"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // Delete + state.SetResourceInstanceCurrent( + boop.Instance(addrs.IntKey(1)).Absolute(root), + nil, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + // No-op + state.SetResourceInstanceCurrent( + beep.Instance(addrs.NoKey).Absolute(root), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"foo":"boop"}`), + }, + root.ProviderConfigDefault(addrs.NewDefaultProvider("test")), + ) + }), } - v.Plan(plan, nil) + v.Plan(plan, testSchemas()) want := []map[string]interface{}{ + // Drift detected: update + { + "@level": "info", + "@message": "test_resource.boop[0]: Drift detected (update)", + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "update", + "resource": map[string]interface{}{ + "addr": "test_resource.boop[0]", + "implied_provider": "test", + "module": "", + "resource": "test_resource.boop[0]", + "resource_key": float64(0), + "resource_name": "boop", + "resource_type": "test_resource", + }, + }, + }, + // Drift detected: delete + { + "@level": "info", + "@message": "test_resource.boop[1]: Drift detected (delete)", + "@module": "terraform.ui", + "type": "resource_drift", + "change": map[string]interface{}{ + "action": "delete", + "resource": map[string]interface{}{ + "addr": "test_resource.boop[1]", + "implied_provider": "test", + "module": "", + "resource": "test_resource.boop[1]", + "resource_key": float64(1), + "resource_name": "boop", + "resource_type": "test_resource", + }, + }, + }, // Create-then-delete should result in replace { "@level": "info", - "@message": "test_instance.boop[0]: Plan to replace", + "@message": "test_resource.boop[0]: Plan to replace", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "replace", "resource": map[string]interface{}{ - "addr": `test_instance.boop[0]`, + "addr": `test_resource.boop[0]`, "implied_provider": "test", "module": "", - "resource": `test_instance.boop[0]`, + "resource": `test_resource.boop[0]`, "resource_key": float64(0), "resource_name": "boop", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Simple create { "@level": "info", - "@message": "test_instance.boop[1]: Plan to create", + "@message": "test_resource.boop[1]: Plan to create", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "create", "resource": map[string]interface{}{ - "addr": `test_instance.boop[1]`, + "addr": `test_resource.boop[1]`, "implied_provider": "test", "module": "", - "resource": `test_instance.boop[1]`, + "resource": `test_resource.boop[1]`, "resource_key": float64(1), "resource_name": "boop", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Simple delete { "@level": "info", - "@message": "module.vpc.test_instance.boop[0]: Plan to delete", + "@message": "module.vpc.test_resource.boop[0]: Plan to delete", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "delete", "resource": map[string]interface{}{ - "addr": `module.vpc.test_instance.boop[0]`, + "addr": `module.vpc.test_resource.boop[0]`, "implied_provider": "test", "module": "module.vpc", - "resource": `test_instance.boop[0]`, + "resource": `test_resource.boop[0]`, "resource_key": float64(0), "resource_name": "boop", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Delete-then-create is also a replace { "@level": "info", - "@message": "test_instance.beep: Plan to replace", + "@message": "test_resource.beep: Plan to replace", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "replace", "resource": map[string]interface{}{ - "addr": `test_instance.beep`, + "addr": `test_resource.beep`, "implied_provider": "test", "module": "", - "resource": `test_instance.beep`, + "resource": `test_resource.beep`, "resource_key": nil, "resource_name": "beep", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, }, // Simple update { "@level": "info", - "@message": "module.vpc.test_instance.beep: Plan to update", + "@message": "module.vpc.test_resource.beep: Plan to update", "@module": "terraform.ui", "type": "planned_change", "change": map[string]interface{}{ "action": "update", "resource": map[string]interface{}{ - "addr": `module.vpc.test_instance.beep`, + "addr": `module.vpc.test_resource.beep`, "implied_provider": "test", "module": "module.vpc", - "resource": `test_instance.beep`, + "resource": `test_resource.beep`, "resource_key": nil, "resource_name": "beep", - "resource_type": "test_instance", + "resource_type": "test_resource", }, }, },