filter attribute refresh changes from plan UI

Filter the refresh changes from the normal plan UI at the attribute
level. We do this by constructing fake plans.Change records for diff
generation, reverting all attribute changes that do not match any of the
plan's ContributingResourceReferences.
This commit is contained in:
James Bardin 2022-02-04 16:39:31 -05:00
parent 0e7cec83db
commit 25f4c0d3dd
2 changed files with 141 additions and 8 deletions

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/lang/globalref"
"github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states"
"github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statefile"
@ -107,7 +108,7 @@ func TestOperation_planNoChanges(t *testing.T) {
}, },
"No objects need to be destroyed.", "No objects need to be destroyed.",
}, },
"drift detected in normal mode": { "no drift detected in normal noop": {
func(schemas *terraform.Schemas) *plans.Plan { func(schemas *terraform.Schemas) *plans.Plan {
addr := addrs.Resource{ addr := addrs.Resource{
Mode: addrs.ManagedResourceMode, Mode: addrs.ManagedResourceMode,
@ -146,7 +147,54 @@ func TestOperation_planNoChanges(t *testing.T) {
DriftedResources: drs, DriftedResources: drs,
} }
}, },
"to update the Terraform state to match, create and apply a refresh-only plan", "No changes",
},
"drift detected in normal mode": {
func(schemas *terraform.Schemas) *plans.Plan {
addr := addrs.Resource{
Mode: addrs.ManagedResourceMode,
Type: "test_resource",
Name: "somewhere",
}.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance)
schema, _ := schemas.ResourceTypeConfig(
addrs.NewDefaultProvider("test"),
addr.Resource.Resource.Mode,
addr.Resource.Resource.Type,
)
ty := schema.ImpliedType()
rc := &plans.ResourceInstanceChange{
Addr: addr,
PrevRunAddr: addr,
ProviderAddr: addrs.RootModuleInstance.ProviderConfigDefault(
addrs.NewDefaultProvider("test"),
),
Change: plans.Change{
Action: plans.Update,
Before: cty.NullVal(ty),
After: cty.ObjectVal(map[string]cty.Value{
"id": cty.StringVal("1234"),
"foo": cty.StringVal("bar"),
}),
},
}
rcs, err := rc.Encode(ty)
if err != nil {
panic(err)
}
drs := []*plans.ResourceInstanceChangeSrc{rcs}
changes := plans.NewChanges()
changes.Resources = drs
return &plans.Plan{
UIMode: plans.NormalMode,
Changes: changes,
DriftedResources: drs,
RelevantAttributes: []globalref.ResourceAttr{{
Resource: addr.ContainingResource(),
Attr: cty.GetAttrPath("id"),
}},
}
},
"Objects have changed outside of Terraform",
}, },
"drift detected in refresh-only mode": { "drift detected in refresh-only mode": {
func(schemas *terraform.Schemas) *plans.Plan { func(schemas *terraform.Schemas) *plans.Plan {

View File

@ -10,10 +10,12 @@ import (
"github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/arguments"
"github.com/hashicorp/terraform/internal/command/format" "github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/lang/globalref"
"github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/plans/objchange" "github.com/hashicorp/terraform/internal/plans/objchange"
"github.com/hashicorp/terraform/internal/terraform" "github.com/hashicorp/terraform/internal/terraform"
"github.com/hashicorp/terraform/internal/tfdiags" "github.com/hashicorp/terraform/internal/tfdiags"
"github.com/zclconf/go-cty/cty"
) )
// The Plan view is used for the plan command. // The Plan view is used for the plan command.
@ -340,16 +342,36 @@ func renderChangesDetectedByRefresh(plan *plans.Plan, schemas *terraform.Schemas
relevant[r.Resource.String()] = true relevant[r.Resource.String()] = true
} }
var changes []*plans.ResourceInstanceChange
for _, rcs := range plan.DriftedResources {
providerSchema := schemas.ProviderSchema(rcs.ProviderAddr.Provider)
if providerSchema == nil {
// Should never happen
view.streams.Printf("(schema missing for %s)\n\n", rcs.ProviderAddr)
continue
}
rSchema, _ := providerSchema.SchemaForResourceAddr(rcs.Addr.Resource.Resource)
if rSchema == nil {
// Should never happen
view.streams.Printf("(schema missing for %s)\n\n", rcs.Addr)
continue
}
changes = append(changes, decodeChange(rcs, rSchema))
}
// In refresh-only mode, we show all resources marked as drifted, // In refresh-only mode, we show all resources marked as drifted,
// including those which have moved without other changes. In other plan // including those which have moved without other changes. In other plan
// modes, move-only changes will be rendered in the planned changes, so // modes, move-only changes will be rendered in the planned changes, so
// we skip them here. // we skip them here.
var drs []*plans.ResourceInstanceChangeSrc var drs []*plans.ResourceInstanceChange
if plan.UIMode == plans.RefreshOnlyMode { if plan.UIMode == plans.RefreshOnlyMode {
drs = plan.DriftedResources drs = changes
} else { } else {
for _, dr := range plan.DriftedResources { for _, dr := range changes {
if dr.Action != plans.NoOp && relevant[dr.Addr.ContainingResource().String()] { change := filterRefreshChange(dr, plan.RelevantAttributes)
if change.Action != plans.NoOp {
dr.Change = change
drs = append(drs, dr) drs = append(drs, dr)
} }
} }
@ -371,7 +393,7 @@ func renderChangesDetectedByRefresh(plan *plans.Plan, schemas *terraform.Schemas
view.colorize.Color("[reset]\n[bold][cyan]Note:[reset][bold] Objects have changed outside of Terraform[reset]\n\n"), view.colorize.Color("[reset]\n[bold][cyan]Note:[reset][bold] Objects have changed outside of Terraform[reset]\n\n"),
) )
view.streams.Print(format.WordWrap( view.streams.Print(format.WordWrap(
"Terraform detected the following changes made outside of Terraform since the last \"terraform apply\":\n\n", "Terraform detected the following changes made outside of Terraform since the last \"terraform apply\" which may have affected this plan:\n\n",
view.outputColumns(), view.outputColumns(),
)) ))
@ -403,7 +425,7 @@ func renderChangesDetectedByRefresh(plan *plans.Plan, schemas *terraform.Schemas
} }
view.streams.Println(format.ResourceChange( view.streams.Println(format.ResourceChange(
decodeChange(rcs, rSchema), rcs,
rSchema, rSchema,
view.colorize, view.colorize,
format.DiffLanguageDetectedDrift, format.DiffLanguageDetectedDrift,
@ -426,6 +448,69 @@ func renderChangesDetectedByRefresh(plan *plans.Plan, schemas *terraform.Schemas
return true return true
} }
// Filter individual resource changes for display based on the attributes which
// may have contributed to the plan as a whole. In order to continue to use the
// existing diff renderer, we are going to create a fake change for display,
// only showing the attributes we're interested in.
// The resulting change will be a NoOp if it has nothing relevant to the plan.
func filterRefreshChange(change *plans.ResourceInstanceChange, contributing []globalref.ResourceAttr) plans.Change {
if change.Action == plans.NoOp {
return change.Change
}
var relevantAttrs []cty.Path
resAddr := change.Addr.ContainingResource()
for _, attr := range contributing {
if resAddr.Equal(attr.Resource) {
relevantAttrs = append(relevantAttrs, attr.Attr)
}
}
// If no attributes are relevant in this resource, then we can turn this
// onto a NoOp change for display.
if len(relevantAttrs) == 0 {
return plans.Change{
Action: plans.NoOp,
Before: change.Before,
After: change.Before,
}
}
// We have some attributes in this change which were marked as relevant, so
// we are going to take the Before value and add in only those attributes
// from the After value which may have contributed to the plan.
before := change.Before
after, _ := cty.Transform(before, func(path cty.Path, v cty.Value) (cty.Value, error) {
for i, attrPath := range relevantAttrs {
// If the current value is null, but we are only a prefix of the
// affected path, we need to take the value from this point since
// we can't recurse any further into the object. This has the
// possibility of pulling in extra attribute changes we're not
// concerned with, but we can take this as "close enough" for now.
if (v.IsNull() && attrPath.HasPrefix(path)) || attrPath.Equals(path) {
// remove the path from further consideration
relevantAttrs = append(relevantAttrs[:i], relevantAttrs[i+1:]...)
v, err := path.Apply(change.After)
return v, err
}
}
return v, nil
})
action := change.Action
if before.RawEquals(after) {
action = plans.NoOp
}
return plans.Change{
Action: action,
Before: before,
After: after,
}
}
func decodeChange(change *plans.ResourceInstanceChangeSrc, schema *configschema.Block) *plans.ResourceInstanceChange { func decodeChange(change *plans.ResourceInstanceChangeSrc, schema *configschema.Block) *plans.ResourceInstanceChange {
changeV, err := change.Decode(schema.ImpliedType()) changeV, err := change.Decode(schema.ImpliedType())
if err != nil { if err != nil {