From ce69c3903ff713ca3b1bcfbcf4583ce9d9ac7a58 Mon Sep 17 00:00:00 2001 From: Martin Atkins Date: Wed, 5 May 2021 17:35:25 -0700 Subject: [PATCH] command/views: Show refresh-detected changes as part of a plan We've always had a mechanism to synchronize the Terraform state with remote objects before creating a plan, but we previously kept the result of that to ourselves, and so it would sometimes lead to Terraform generating a planned action to undo some upstream drift, but Terraform would give no context as to why that action was planned even though the relevant part of the configuration hadn't changed. Now we'll detect any differences between the previous run state and the refreshed state and, if any managed resources now look different, show an additional note about it as extra context for the planned changes that follow. This appears as an optional extra block of information before the normal plan output. It'll appear the same way in all of the contexts where we render plans, including "terraform show" for saved plans. --- command/format/diff.go | 133 +++++++++++++++++++++++++++++++++++++++++ command/views/plan.go | 85 ++++++++++++++++++++++++++ states/state_equal.go | 50 ++++++++++++++++ 3 files changed, 268 insertions(+) diff --git a/command/format/diff.go b/command/format/diff.go index 23b8d095f..cfa63041e 100644 --- a/command/format/diff.go +++ b/command/format/diff.go @@ -143,6 +143,139 @@ func ResourceChange( return buf.String() } +// ResourceInstanceDrift returns a string representation of a change to a +// particular resource instance that was made outside of Terraform, for +// reporting a change that has already happened rather than one that is planned. +// +// The the two resource instances have equal current objects then the result +// will be an empty string to indicate that there is no drift to render. +// +// The resource schema must be provided along with the change so that the +// formatted change can reflect the configuration structure for the associated +// resource. +// +// If "color" is non-nil, it will be used to color the result. Otherwise, +// no color codes will be included. +func ResourceInstanceDrift( + addr addrs.AbsResourceInstance, + before, after *states.ResourceInstance, + schema *configschema.Block, + color *colorstring.Colorize, +) string { + var buf bytes.Buffer + + if color == nil { + color = &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: true, + Reset: false, + } + } + + dispAddr := addr.String() + action := plans.Update + + switch { + case after == nil || after.Current == nil: + // The object was deleted + buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] has been deleted", dispAddr))) + action = plans.Delete + default: + // The object was changed + buf.WriteString(color.Color(fmt.Sprintf("[bold] # %s[reset] has been changed", dispAddr))) + } + + buf.WriteString(color.Color("[reset]\n")) + + buf.WriteString(color.Color(DiffActionSymbol(action)) + " ") + + switch addr.Resource.Resource.Mode { + case addrs.ManagedResourceMode: + buf.WriteString(fmt.Sprintf( + "resource %q %q", + addr.Resource.Resource.Type, + addr.Resource.Resource.Name, + )) + case addrs.DataResourceMode: + buf.WriteString(fmt.Sprintf( + "data %q %q ", + addr.Resource.Resource.Type, + addr.Resource.Resource.Name, + )) + default: + // should never happen, since the above is exhaustive + buf.WriteString(addr.String()) + } + + buf.WriteString(" {") + + p := blockBodyDiffPrinter{ + buf: &buf, + color: color, + action: action, + } + + // Most commonly-used resources have nested blocks that result in us + // going at least three traversals deep while we recurse here, so we'll + // start with that much capacity and then grow as needed for deeper + // structures. + path := make(cty.Path, 0, 3) + + ty := schema.ImpliedType() + + var err error + var oldObj, newObj *states.ResourceInstanceObject + oldObj, err = before.Current.Decode(ty) + if err != nil { + // We shouldn't encounter errors here because Terraform Core should've + // made sure that the previous run object conforms to the current + // schema by having the provider upgrade it, but we'll be robust here + // in case there are some edges we didn't find yet. + return fmt.Sprintf(" # %s previous run state doesn't conform to current schema; this is a Terraform bug\n # %s\n", addr, err) + } + if after != nil && after.Current != nil { + newObj, err = after.Current.Decode(ty) + // We shouldn't encounter errors here because Terraform Core should've + // made sure that the prior state object conforms to the current + // schema by having the provider upgrade it, even if we skipped + // refreshing on this run, but we'll be robust here in case there are + // some edges we didn't find yet. + return fmt.Sprintf(" # %s refreshed state doesn't conform to current schema; this is a Terraform bug\n # %s\n", addr, err) + } + + oldVal := oldObj.Value + var newVal cty.Value + if newObj != nil { + newVal = newObj.Value + } else { + newVal = cty.NullVal(ty) + } + + if newVal.RawEquals(oldVal) { + // Nothing to show, then. + return "" + } + + // We currently have an opt-out that permits the legacy SDK to return values + // that defy our usual conventions around handling of nesting blocks. To + // avoid the rendering code from needing to handle all of these, we'll + // normalize first. + // (Ideally we'd do this as part of the SDK opt-out implementation in core, + // but we've added it here for now to reduce risk of unexpected impacts + // on other code in core.) + oldVal = objchange.NormalizeObjectFromLegacySDK(oldVal, schema) + newVal = objchange.NormalizeObjectFromLegacySDK(newVal, schema) + + result := p.writeBlockBodyDiff(schema, oldVal, newVal, 6, path) + if result.bodyWritten { + buf.WriteString("\n") + buf.WriteString(strings.Repeat(" ", 4)) + } + buf.WriteString("}\n") + + return buf.String() +} + // OutputChanges returns a string representation of a set of changes to output // values for inclusion in user-facing plan output. // diff --git a/command/views/plan.go b/command/views/plan.go index fca9f0ec6..5097f71df 100644 --- a/command/views/plan.go +++ b/command/views/plan.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/plans" + "github.com/hashicorp/terraform/states" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" ) @@ -96,6 +97,24 @@ func (v *PlanJSON) HelpPrompt() { // The plan renderer is used by the Operation view (for plan and apply // commands) and the Show view (for the show command). func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) { + haveRefreshChanges := renderChangesDetectedByRefresh(plan.PrevRunState, plan.PriorState, schemas, view) + if haveRefreshChanges { + switch plan.UIMode { + case plans.RefreshOnlyMode: + view.streams.Println(format.WordWrap( + "\nThis is a refresh-only plan, so Terraform will not take any actions to undo these. If you were expecting these changes then you can apply this plan to record the updated values in the Terraform state without changing any remote objects.", + view.outputColumns(), + )) + default: + view.streams.Println(format.WordWrap( + "\nUnless you have made equivalent changes to your configuration, or ignored the relevant attributes using ignore_changes, the following plan may include actions to undo or respond to these changes.", + view.outputColumns(), + )) + } + view.streams.Print(format.HorizontalRule(view.colorize, view.outputColumns())) + view.streams.Println("") + } + counts := map[plans.Action]int{} var rChanges []*plans.ResourceInstanceChangeSrc for _, change := range plan.Changes.Resources { @@ -209,6 +228,72 @@ func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) { } } +// renderChangesDetectedByRefresh is a part of renderPlan that generates +// the note about changes detected by refresh (sometimes considered as "drift"). +// +// It will only generate output if there's at least one difference detected. +// Otherwise, it will produce nothing at all. To help the caller recognize +// those two situations incase subsequent output must accommodate it, +// renderChangesDetectedByRefresh returns true if it produced at least one +// line of output, and guarantees to always produce whole lines terminated +// by newline characters. +func renderChangesDetectedByRefresh(before, after *states.State, schemas *terraform.Schemas, view *View) bool { + if after.ManagedResourcesEqual(before) { + return false + } + + view.streams.Print( + view.colorize.Color("[reset]\n[bold][cyan]Note:[reset][bold] Objects have changed outside of Terraform[reset]\n\n"), + ) + view.streams.Print(format.WordWrap( + "Terraform detected the following changes made outside of Terraform since the last \"terraform apply\":\n\n", + view.outputColumns(), + )) + + for _, bms := range before.Modules { + for _, brs := range bms.Resources { + if brs.Addr.Resource.Mode != addrs.ManagedResourceMode { + continue // only managed resources can "drift" + } + addr := brs.Addr + prs := after.Resource(brs.Addr) + + provider := brs.ProviderConfig.Provider + providerSchema := schemas.ProviderSchema(provider) + if providerSchema == nil { + // Should never happen + view.streams.Printf("(schema missing for %s)\n", provider) + continue + } + rSchema, _ := providerSchema.SchemaForResourceAddr(addr.Resource) + if rSchema == nil { + // Should never happen + view.streams.Printf("(schema missing for %s)\n", addr) + continue + } + + for key, bis := range brs.Instances { + var pis *states.ResourceInstance + if prs != nil { + pis = prs.Instance(key) + } + + diff := format.ResourceInstanceDrift( + addr.Instance(key), + bis, pis, + rSchema, + view.colorize, + ) + if diff != "" { + view.streams.Print(diff) + } + } + } + } + + return true +} + const planHeaderIntro = ` Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: ` diff --git a/states/state_equal.go b/states/state_equal.go index ea20967e5..9b251252f 100644 --- a/states/state_equal.go +++ b/states/state_equal.go @@ -2,6 +2,8 @@ package states import ( "reflect" + + "github.com/hashicorp/terraform/addrs" ) // Equal returns true if the receiver is functionally equivalent to other, @@ -16,3 +18,51 @@ func (s *State) Equal(other *State) bool { // more sophisticated comparisons. return reflect.DeepEqual(s, other) } + +// ManagedResourcesEqual returns true if all of the managed resources tracked +// in the reciever are functionally equivalent to the same tracked in the +// other given state. +// +// This is a more constrained version of Equal that disregards other +// differences, including but not limited to changes to data resources and +// changes to output values. +func (s *State) ManagedResourcesEqual(other *State) bool { + // First, some accommodations for situations where one of the objects is + // nil, for robustness since we sometimes use a nil state to represent + // a prior state being entirely absent. + if s == nil && other == nil { + return true + } + if s == nil { + return !other.HasResources() + } + if other == nil { + return !s.HasResources() + } + + // If we get here then both states are non-nil. + + // sameManagedResources tests that its second argument has all the + // resources that the first one does, so we'll call it twice with the + // arguments inverted to ensure that we'll also catch situations where + // the second has resources that the first does not. + return sameManagedResources(s, other) && sameManagedResources(other, s) +} + +func sameManagedResources(s1, s2 *State) bool { + for _, ms := range s1.Modules { + for _, rs := range ms.Resources { + addr := rs.Addr + if addr.Resource.Mode != addrs.ManagedResourceMode { + continue + } + otherRS := s2.Resource(addr) + if !reflect.DeepEqual(rs, otherRS) { + return false + } + } + } + + return true + +}