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.
This commit is contained in:
Martin Atkins 2021-05-05 17:35:25 -07:00
parent 745aebeda1
commit ce69c3903f
3 changed files with 268 additions and 0 deletions

View File

@ -143,6 +143,139 @@ func ResourceChange(
return buf.String() 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 // OutputChanges returns a string representation of a set of changes to output
// values for inclusion in user-facing plan output. // values for inclusion in user-facing plan output.
// //

View File

@ -10,6 +10,7 @@ import (
"github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/format" "github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/plans" "github.com/hashicorp/terraform/plans"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/terraform"
"github.com/hashicorp/terraform/tfdiags" "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 // The plan renderer is used by the Operation view (for plan and apply
// commands) and the Show view (for the show command). // commands) and the Show view (for the show command).
func renderPlan(plan *plans.Plan, schemas *terraform.Schemas, view *View) { 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{} counts := map[plans.Action]int{}
var rChanges []*plans.ResourceInstanceChangeSrc var rChanges []*plans.ResourceInstanceChangeSrc
for _, change := range plan.Changes.Resources { 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 = ` const planHeaderIntro = `
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols: Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
` `

View File

@ -2,6 +2,8 @@ package states
import ( import (
"reflect" "reflect"
"github.com/hashicorp/terraform/addrs"
) )
// Equal returns true if the receiver is functionally equivalent to other, // 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. // more sophisticated comparisons.
return reflect.DeepEqual(s, other) 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
}