Structured Plan Renderer: Remove attributes that do not match the relevant attributes filter (#32509)

* remove attributes that do not match the relevant attributes filter

* fix formatting

* fix renderer function, don't drop irrelevant attributes just mark them as no-ops

* fix imports
This commit is contained in:
Liam Cervante 2023-01-16 15:18:38 +01:00 committed by GitHub
parent 4fd8322802
commit e015b15f12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 1248 additions and 246 deletions

View File

@ -9,6 +9,7 @@ import (
"github.com/hashicorp/terraform/internal/command/format"
"github.com/hashicorp/terraform/internal/command/jsonformat/collections"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/plans"
)
@ -176,7 +177,7 @@ func (renderer primitiveRenderer) renderStringDiff(diff computed.Diff, indent in
}
func (renderer primitiveRenderer) renderStringDiffAsJson(diff computed.Diff, indent int, opts computed.RenderHumanOpts, before evaluatedString, after evaluatedString) string {
jsonDiff := RendererJsonOpts().Transform(before.Json, after.Json)
jsonDiff := RendererJsonOpts().Transform(before.Json, after.Json, attribute_path.AlwaysMatcher())
action := diff.Action

View File

@ -5,20 +5,48 @@ import (
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/plans"
)
func precomputeDiffs(plan Plan) diffs {
func precomputeDiffs(plan Plan, mode plans.Mode) diffs {
diffs := diffs{
outputs: make(map[string]computed.Diff),
}
for _, drift := range plan.ResourceDrift {
var relevantAttrs attribute_path.Matcher
if mode == plans.RefreshOnlyMode {
// For a refresh only plan, we show all the drift.
relevantAttrs = attribute_path.AlwaysMatcher()
} else {
matcher := attribute_path.Empty(true)
// Otherwise we only want to show the drift changes that are
// relevant.
for _, attr := range plan.RelevantAttributes {
if len(attr.Resource) == 0 || attr.Resource == drift.Address {
matcher = attribute_path.AppendSingle(matcher, attr.Attr)
}
}
if len(matcher.Paths) > 0 {
relevantAttrs = matcher
}
}
if relevantAttrs == nil {
// If we couldn't build a relevant attribute matcher, then we are
// not going to show anything for this drift.
continue
}
schema := plan.ProviderSchemas[drift.ProviderName].ResourceSchemas[drift.Type]
diffs.drift = append(diffs.drift, diff{
change: drift,
diff: differ.FromJsonChange(drift.Change).ComputeDiffForBlock(schema.Block),
diff: differ.FromJsonChange(drift.Change, relevantAttrs).ComputeDiffForBlock(schema.Block),
})
}
@ -26,12 +54,12 @@ func precomputeDiffs(plan Plan) diffs {
schema := plan.ProviderSchemas[change.ProviderName].ResourceSchemas[change.Type]
diffs.changes = append(diffs.changes, diff{
change: change,
diff: differ.FromJsonChange(change.Change).ComputeDiffForBlock(schema.Block),
diff: differ.FromJsonChange(change.Change, attribute_path.AlwaysMatcher()).ComputeDiffForBlock(schema.Block),
})
}
for key, output := range plan.OutputChanges {
diffs.outputs[key] = differ.FromJsonChange(output).ComputeDiffForOutput()
diffs.outputs[key] = differ.FromJsonChange(output, attribute_path.AlwaysMatcher()).ComputeDiffForOutput()
}
less := func(drs []diff) func(i, j int) bool {

View File

@ -0,0 +1,201 @@
package attribute_path
import "encoding/json"
// Matcher provides an interface for stepping through changes following an
// attribute path.
//
// GetChildWithKey and GetChildWithIndex will check if any of the internal paths
// match the provided key or index, and return a new Matcher that will match
// that children or potentially it's children.
//
// The caller of the above functions is required to know whether the next value
// in the path is a list type or an object type and call the relevant function,
// otherwise these functions will crash/panic.
//
// The Matches function returns true if the paths you have traversed until now
// ends.
type Matcher interface {
// Matches returns true if we have reached the end of a path and found an
// exact match.
Matches() bool
// MatchesPartial returns true if the current attribute is part of a path
// but not necessarily at the end of the path.
MatchesPartial() bool
GetChildWithKey(key string) Matcher
GetChildWithIndex(index int) Matcher
}
// Parse accepts a json.RawMessage and outputs a formatted Matcher object.
//
// Parse expects the message to be a JSON array of JSON arrays containing
// strings and floats. This function happily accepts a null input representing
// none of the changes in this resource are causing a replacement. The propagate
// argument tells the matcher to propagate any matches to the matched attributes
// children.
//
// In general, this function is designed to accept messages that have been
// produced by the lossy cty.Paths conversion functions within the jsonplan
// package. There is nothing particularly special about that conversion process
// though, it just produces the nested JSON arrays described above.
func Parse(message json.RawMessage, propagate bool) Matcher {
matcher := &PathMatcher{
Propagate: propagate,
}
if message == nil {
return matcher
}
if err := json.Unmarshal(message, &matcher.Paths); err != nil {
panic("failed to unmarshal attribute paths: " + err.Error())
}
return matcher
}
// Empty returns an empty PathMatcher that will by default match nothing.
//
// We give direct access to the PathMatcher struct so a matcher can be built
// in parts with the Append and AppendSingle functions.
func Empty(propagate bool) *PathMatcher {
return &PathMatcher{
Propagate: propagate,
}
}
// Append accepts an existing PathMatcher and returns a new one that attaches
// all the paths from message with the existing paths.
//
// The new PathMatcher is created fresh, and the existing one is unchanged.
func Append(matcher *PathMatcher, message json.RawMessage) *PathMatcher {
var values [][]interface{}
if err := json.Unmarshal(message, &values); err != nil {
panic("failed to unmarshal attribute paths: " + err.Error())
}
return &PathMatcher{
Propagate: matcher.Propagate,
Paths: append(matcher.Paths, values...),
}
}
// AppendSingle accepts an existing PathMatcher and returns a new one that
// attaches the single path from message with the existing paths.
//
// The new PathMatcher is created fresh, and the existing one is unchanged.
func AppendSingle(matcher *PathMatcher, message json.RawMessage) *PathMatcher {
var values []interface{}
if err := json.Unmarshal(message, &values); err != nil {
panic("failed to unmarshal attribute paths: " + err.Error())
}
return &PathMatcher{
Propagate: matcher.Propagate,
Paths: append(matcher.Paths, values),
}
}
// PathMatcher contains a slice of paths that represent paths through the values
// to relevant/tracked attributes.
type PathMatcher struct {
// We represent our internal paths as a [][]interface{} as the cty.Paths
// conversion process is lossy. Since the type information is lost there
// is no (easy) way to reproduce the original cty.Paths object. Instead,
// we simply rely on the external callers to know the type information and
// call the correct GetChild function.
Paths [][]interface{}
// Propagate tells the matcher that it should propagate any matches it finds
// onto the children of that match.
Propagate bool
}
func (p *PathMatcher) Matches() bool {
for _, path := range p.Paths {
if len(path) == 0 {
return true
}
}
return false
}
func (p *PathMatcher) MatchesPartial() bool {
return len(p.Paths) > 0
}
func (p *PathMatcher) GetChildWithKey(key string) Matcher {
child := &PathMatcher{
Propagate: p.Propagate,
}
for _, path := range p.Paths {
if len(path) == 0 {
// This means that the current value matched, but not necessarily
// it's child.
if p.Propagate {
// If propagate is true, then our child match our matches
child.Paths = append(child.Paths, path)
}
// If not we would simply drop this path from our set of paths but
// either way we just continue.
continue
}
if path[0].(string) == key {
child.Paths = append(child.Paths, path[1:])
}
}
return child
}
func (p *PathMatcher) GetChildWithIndex(index int) Matcher {
child := &PathMatcher{
Propagate: p.Propagate,
}
for _, path := range p.Paths {
if len(path) == 0 {
// This means that the current value matched, but not necessarily
// it's child.
if p.Propagate {
// If propagate is true, then our child match our matches
child.Paths = append(child.Paths, path)
}
// If not we would simply drop this path from our set of paths but
// either way we just continue.
continue
}
if int(path[0].(float64)) == index {
child.Paths = append(child.Paths, path[1:])
}
}
return child
}
// AlwaysMatcher returns a matcher that will always match all paths.
func AlwaysMatcher() Matcher {
return &alwaysMatcher{}
}
type alwaysMatcher struct{}
func (a *alwaysMatcher) Matches() bool {
return true
}
func (a *alwaysMatcher) MatchesPartial() bool {
return true
}
func (a *alwaysMatcher) GetChildWithKey(_ string) Matcher {
return a
}
func (a *alwaysMatcher) GetChildWithIndex(_ int) Matcher {
return a
}

View File

@ -0,0 +1,253 @@
package attribute_path
import "testing"
func TestPathMatcher_FollowsPath(t *testing.T) {
var matcher Matcher
matcher = &PathMatcher{
Paths: [][]interface{}{
{
float64(0),
"key",
float64(0),
},
},
}
if matcher.Matches() {
t.Errorf("should not have exact matched at base level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at base level")
}
matcher = matcher.GetChildWithIndex(0)
if matcher.Matches() {
t.Errorf("should not have exact matched at first level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at first level")
}
matcher = matcher.GetChildWithKey("key")
if matcher.Matches() {
t.Errorf("should not have exact matched at second level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at second level")
}
matcher = matcher.GetChildWithIndex(0)
if !matcher.Matches() {
t.Errorf("should have exact matched at leaf level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at leaf level")
}
}
func TestPathMatcher_Propagates(t *testing.T) {
var matcher Matcher
matcher = &PathMatcher{
Paths: [][]interface{}{
{
float64(0),
"key",
},
},
Propagate: true,
}
if matcher.Matches() {
t.Errorf("should not have exact matched at base level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at base level")
}
matcher = matcher.GetChildWithIndex(0)
if matcher.Matches() {
t.Errorf("should not have exact matched at first level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at first level")
}
matcher = matcher.GetChildWithKey("key")
if !matcher.Matches() {
t.Errorf("should have exact matched at second level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at second level")
}
matcher = matcher.GetChildWithIndex(0)
if !matcher.Matches() {
t.Errorf("should have exact matched at leaf level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at leaf level")
}
}
func TestPathMatcher_DoesNotPropagate(t *testing.T) {
var matcher Matcher
matcher = &PathMatcher{
Paths: [][]interface{}{
{
float64(0),
"key",
},
},
}
if matcher.Matches() {
t.Errorf("should not have exact matched at base level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at base level")
}
matcher = matcher.GetChildWithIndex(0)
if matcher.Matches() {
t.Errorf("should not have exact matched at first level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at first level")
}
matcher = matcher.GetChildWithKey("key")
if !matcher.Matches() {
t.Errorf("should have exact matched at second level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at second level")
}
matcher = matcher.GetChildWithIndex(0)
if matcher.Matches() {
t.Errorf("should not have exact matched at leaf level")
}
if matcher.MatchesPartial() {
t.Errorf("should not have partial matched at leaf level")
}
}
func TestPathMatcher_BreaksPath(t *testing.T) {
var matcher Matcher
matcher = &PathMatcher{
Paths: [][]interface{}{
{
float64(0),
"key",
float64(0),
},
},
}
if matcher.Matches() {
t.Errorf("should not have exact matched at base level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at base level")
}
matcher = matcher.GetChildWithIndex(0)
if matcher.Matches() {
t.Errorf("should not have exact matched at first level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at first level")
}
matcher = matcher.GetChildWithKey("invalid")
if matcher.Matches() {
t.Errorf("should not have exact matched at second level")
}
if matcher.MatchesPartial() {
t.Errorf("should not have partial matched at second level")
}
}
func TestPathMatcher_MultiplePaths(t *testing.T) {
var matcher Matcher
matcher = &PathMatcher{
Paths: [][]interface{}{
{
float64(0),
"key",
float64(0),
},
{
float64(0),
"key",
float64(1),
},
},
}
if matcher.Matches() {
t.Errorf("should not have exact matched at base level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at base level")
}
matcher = matcher.GetChildWithIndex(0)
if matcher.Matches() {
t.Errorf("should not have exact matched at first level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at first level")
}
matcher = matcher.GetChildWithKey("key")
if matcher.Matches() {
t.Errorf("should not have exact matched at second level")
}
if !matcher.MatchesPartial() {
t.Errorf("should have partial matched at second level")
}
validZero := matcher.GetChildWithIndex(0)
validOne := matcher.GetChildWithIndex(1)
invalid := matcher.GetChildWithIndex(2)
if !validZero.Matches() {
t.Errorf("should have exact matched at leaf level")
}
if !validZero.MatchesPartial() {
t.Errorf("should have partial matched at leaf level")
}
if !validOne.Matches() {
t.Errorf("should have exact matched at leaf level")
}
if !validOne.MatchesPartial() {
t.Errorf("should have partial matched at leaf level")
}
if invalid.Matches() {
t.Errorf("should not have exact matched at leaf level")
}
if invalid.MatchesPartial() {
t.Errorf("should not have partial matched at leaf level")
}
}

View File

@ -25,6 +25,11 @@ func (change Change) ComputeDiffForBlock(block *jsonprovider.Block) computed.Dif
for key, attr := range block.Attributes {
childValue := blockValue.getChild(key)
if !childValue.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
childValue = childValue.AsNoOp()
}
// Empty strings in blocks should be considered null for legacy reasons.
// The SDK doesn't support null strings yet, so we work around this now.
if before, ok := childValue.Before.(string); ok && len(before) == 0 {
@ -61,9 +66,14 @@ func (change Change) ComputeDiffForBlock(block *jsonprovider.Block) computed.Dif
for key, blockType := range block.BlockTypes {
childValue := blockValue.getChild(key)
if !childValue.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
childValue = childValue.AsNoOp()
}
beforeSensitive := childValue.isBeforeSensitive()
afterSensitive := childValue.isAfterSensitive()
forcesReplacement := childValue.ReplacePaths.ForcesReplacement()
forcesReplacement := childValue.ReplacePaths.Matches()
switch NestingMode(blockType.NestingMode) {
case nestingModeSet:
@ -103,5 +113,5 @@ func (change Change) ComputeDiffForBlock(block *jsonprovider.Block) computed.Dif
}
}
return computed.NewDiff(renderers.Block(attributes, blocks), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.Block(attributes, blocks), current, change.ReplacePaths.Matches())
}

View File

@ -5,7 +5,7 @@ import (
"reflect"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/replace"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonplan"
"github.com/hashicorp/terraform/internal/plans"
)
@ -76,27 +76,33 @@ type Change struct {
// sensitive.
AfterSensitive interface{}
// ReplacePaths generally contains nested slices that describe paths to
// elements or attributes that are causing the overall resource to be
// replaced.
ReplacePaths replace.ForcesReplacement
// ReplacePaths contains a set of paths that point to attributes/elements
// that are causing the overall resource to be replaced rather than simply
// updated.
ReplacePaths attribute_path.Matcher
// RelevantAttributes contains a set of paths that point attributes/elements
// that we should display. Any element/attribute not matched by this Matcher
// should be skipped.
RelevantAttributes attribute_path.Matcher
}
// FromJsonChange unmarshals the raw []byte values in the jsonplan.Change
// structs into generic interface{} types that can be reasoned about.
func FromJsonChange(change jsonplan.Change) Change {
func FromJsonChange(change jsonplan.Change, relevantAttributes attribute_path.Matcher) Change {
return Change{
Before: unmarshalGeneric(change.Before),
After: unmarshalGeneric(change.After),
Unknown: unmarshalGeneric(change.AfterUnknown),
BeforeSensitive: unmarshalGeneric(change.BeforeSensitive),
AfterSensitive: unmarshalGeneric(change.AfterSensitive),
ReplacePaths: replace.Parse(change.ReplacePaths),
Before: unmarshalGeneric(change.Before),
After: unmarshalGeneric(change.After),
Unknown: unmarshalGeneric(change.AfterUnknown),
BeforeSensitive: unmarshalGeneric(change.BeforeSensitive),
AfterSensitive: unmarshalGeneric(change.AfterSensitive),
ReplacePaths: attribute_path.Parse(change.ReplacePaths, false),
RelevantAttributes: relevantAttributes,
}
}
func (change Change) asDiff(renderer computed.DiffRenderer) computed.Diff {
return computed.NewDiff(renderer, change.calculateChange(), change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderer, change.calculateChange(), change.ReplacePaths.Matches())
}
func (change Change) calculateChange() plans.Action {
@ -138,6 +144,23 @@ func (change Change) getDefaultActionForIteration() plans.Action {
return plans.NoOp
}
// AsNoOp returns the current change as if it is a NoOp operation.
//
// Basically it replaces all the after values with the before values.
func (change Change) AsNoOp() Change {
return Change{
BeforeExplicit: change.BeforeExplicit,
AfterExplicit: change.BeforeExplicit,
Before: change.Before,
After: change.Before,
Unknown: false,
BeforeSensitive: change.BeforeSensitive,
AfterSensitive: change.BeforeSensitive,
ReplacePaths: change.ReplacePaths,
RelevantAttributes: change.RelevantAttributes,
}
}
func unmarshalGeneric(raw json.RawMessage) interface{} {
if raw == nil {
return nil

View File

@ -1,6 +1,8 @@
package differ
import "github.com/hashicorp/terraform/internal/command/jsonformat/differ/replace"
import (
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
)
// ChangeMap is a Change that represents a Map or an Object type, and has
// converted the relevant interfaces into maps for easier access.
@ -24,17 +26,21 @@ type ChangeMap struct {
AfterSensitive map[string]interface{}
// ReplacePaths matches the same attributes in Change exactly.
ReplacePaths replace.ForcesReplacement
ReplacePaths attribute_path.Matcher
// RelevantAttributes matches the same attributes in Change exactly.
RelevantAttributes attribute_path.Matcher
}
func (change Change) asMap() ChangeMap {
return ChangeMap{
Before: genericToMap(change.Before),
After: genericToMap(change.After),
Unknown: genericToMap(change.Unknown),
BeforeSensitive: genericToMap(change.BeforeSensitive),
AfterSensitive: genericToMap(change.AfterSensitive),
ReplacePaths: change.ReplacePaths,
Before: genericToMap(change.Before),
After: genericToMap(change.After),
Unknown: genericToMap(change.Unknown),
BeforeSensitive: genericToMap(change.BeforeSensitive),
AfterSensitive: genericToMap(change.AfterSensitive),
ReplacePaths: change.ReplacePaths,
RelevantAttributes: change.RelevantAttributes,
}
}
@ -46,14 +52,15 @@ func (m ChangeMap) getChild(key string) Change {
afterSensitive, _ := getFromGenericMap(m.AfterSensitive, key)
return Change{
BeforeExplicit: beforeExplicit,
AfterExplicit: afterExplicit,
Before: before,
After: after,
Unknown: unknown,
BeforeSensitive: beforeSensitive,
AfterSensitive: afterSensitive,
ReplacePaths: m.ReplacePaths.GetChildWithKey(key),
BeforeExplicit: beforeExplicit,
AfterExplicit: afterExplicit,
Before: before,
After: after,
Unknown: unknown,
BeforeSensitive: beforeSensitive,
AfterSensitive: afterSensitive,
ReplacePaths: m.ReplacePaths.GetChildWithKey(key),
RelevantAttributes: m.RelevantAttributes.GetChildWithKey(key),
}
}

View File

@ -1,6 +1,8 @@
package differ
import "github.com/hashicorp/terraform/internal/command/jsonformat/differ/replace"
import (
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
)
// ChangeSlice is a Change that represents a Tuple, Set, or List type, and has
// converted the relevant interfaces into slices for easier access.
@ -23,17 +25,21 @@ type ChangeSlice struct {
AfterSensitive []interface{}
// ReplacePaths matches the same attributes in Change exactly.
ReplacePaths replace.ForcesReplacement
ReplacePaths attribute_path.Matcher
// RelevantAttributes matches the same attributes in Change exactly.
RelevantAttributes attribute_path.Matcher
}
func (change Change) asSlice() ChangeSlice {
return ChangeSlice{
Before: genericToSlice(change.Before),
After: genericToSlice(change.After),
Unknown: genericToSlice(change.Unknown),
BeforeSensitive: genericToSlice(change.BeforeSensitive),
AfterSensitive: genericToSlice(change.AfterSensitive),
ReplacePaths: change.ReplacePaths,
Before: genericToSlice(change.Before),
After: genericToSlice(change.After),
Unknown: genericToSlice(change.Unknown),
BeforeSensitive: genericToSlice(change.BeforeSensitive),
AfterSensitive: genericToSlice(change.AfterSensitive),
ReplacePaths: change.ReplacePaths,
RelevantAttributes: change.RelevantAttributes,
}
}
@ -44,15 +50,21 @@ func (s ChangeSlice) getChild(beforeIx, afterIx int) Change {
beforeSensitive, _ := getFromGenericSlice(s.BeforeSensitive, beforeIx)
afterSensitive, _ := getFromGenericSlice(s.AfterSensitive, afterIx)
mostRelevantIx := beforeIx
if beforeIx < 0 || beforeIx >= len(s.Before) {
mostRelevantIx = afterIx
}
return Change{
BeforeExplicit: beforeExplicit,
AfterExplicit: afterExplicit,
Before: before,
After: after,
Unknown: unknown,
BeforeSensitive: beforeSensitive,
AfterSensitive: afterSensitive,
ReplacePaths: s.ReplacePaths.GetChildWithIndex(beforeIx),
BeforeExplicit: beforeExplicit,
AfterExplicit: afterExplicit,
Before: before,
After: after,
Unknown: unknown,
BeforeSensitive: beforeSensitive,
AfterSensitive: afterSensitive,
ReplacePaths: s.ReplacePaths.GetChildWithIndex(mostRelevantIx),
RelevantAttributes: s.RelevantAttributes.GetChildWithIndex(mostRelevantIx),
}
}

View File

@ -9,7 +9,7 @@ import (
ctyjson "github.com/zclconf/go-cty/cty/json"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed/renderers"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/replace"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/plans"
)
@ -48,6 +48,7 @@ func TestValue_ObjectAttributes(t *testing.T) {
validateObject renderers.ValidateDiffFunction
validateNestedObject renderers.ValidateDiffFunction
validateDiffs map[string]renderers.ValidateDiffFunction
validateList renderers.ValidateDiffFunction
validateReplace bool
validateAction plans.Action
// Sets break changes out differently to the other collections, so they
@ -510,8 +511,8 @@ func TestValue_ObjectAttributes(t *testing.T) {
After: map[string]interface{}{
"attribute_one": "new",
},
ReplacePaths: replace.ForcesReplacement{
ReplacePaths: [][]interface{}{
ReplacePaths: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{},
},
},
@ -537,7 +538,7 @@ func TestValue_ObjectAttributes(t *testing.T) {
"attribute_one": renderers.ValidatePrimitive(nil, "new", plans.Create, false),
},
Action: plans.Create,
Replace: false,
Replace: true,
},
},
},
@ -549,8 +550,8 @@ func TestValue_ObjectAttributes(t *testing.T) {
After: map[string]interface{}{
"attribute_one": "new",
},
ReplacePaths: replace.ForcesReplacement{
ReplacePaths: [][]interface{}{
ReplacePaths: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{"attribute_one"},
},
},
@ -573,7 +574,62 @@ func TestValue_ObjectAttributes(t *testing.T) {
},
After: SetDiffEntry{
ObjectDiff: map[string]renderers.ValidateDiffFunction{
"attribute_one": renderers.ValidatePrimitive(nil, "new", plans.Create, false),
"attribute_one": renderers.ValidatePrimitive(nil, "new", plans.Create, true),
},
Action: plans.Create,
Replace: false,
},
},
},
"update_includes_relevant_attributes": {
input: Change{
Before: map[string]interface{}{
"attribute_one": "old_one",
"attribute_two": "old_two",
},
After: map[string]interface{}{
"attribute_one": "new_one",
"attribute_two": "new_two",
},
RelevantAttributes: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{"attribute_one"},
},
},
},
attributes: map[string]cty.Type{
"attribute_one": cty.String,
"attribute_two": cty.String,
},
validateDiffs: map[string]renderers.ValidateDiffFunction{
"attribute_one": renderers.ValidatePrimitive("old_one", "new_one", plans.Update, false),
"attribute_two": renderers.ValidatePrimitive("old_two", "old_two", plans.NoOp, false),
},
validateList: renderers.ValidateList([]renderers.ValidateDiffFunction{
renderers.ValidateObject(map[string]renderers.ValidateDiffFunction{
// Lists are a bit special, and in this case is actually
// going to ignore the relevant attributes. This is
// deliberate. See the comments in list.go for an
// explanation.
"attribute_one": renderers.ValidatePrimitive("old_one", "new_one", plans.Update, false),
"attribute_two": renderers.ValidatePrimitive("old_two", "new_two", plans.Update, false),
}, plans.Update, false),
}, plans.Update, false),
validateAction: plans.Update,
validateReplace: false,
validateSetDiffs: &SetDiff{
Before: SetDiffEntry{
ObjectDiff: map[string]renderers.ValidateDiffFunction{
"attribute_one": renderers.ValidatePrimitive("old_one", nil, plans.Delete, false),
"attribute_two": renderers.ValidatePrimitive("old_two", nil, plans.Delete, false),
},
Action: plans.Delete,
Replace: false,
},
After: SetDiffEntry{
ObjectDiff: map[string]renderers.ValidateDiffFunction{
"attribute_one": renderers.ValidatePrimitive(nil, "new_one", plans.Create, false),
"attribute_two": renderers.ValidatePrimitive(nil, "new_two", plans.Create, false),
},
Action: plans.Create,
Replace: false,
@ -585,6 +641,14 @@ func TestValue_ObjectAttributes(t *testing.T) {
for name, tmp := range tcs {
tc := tmp
// Let's set some default values on the input.
if tc.input.RelevantAttributes == nil {
tc.input.RelevantAttributes = attribute_path.AlwaysMatcher()
}
if tc.input.ReplacePaths == nil {
tc.input.ReplacePaths = &attribute_path.PathMatcher{}
}
collectionDefaultAction := plans.Update
if name == "ignores_unset_fields" {
// Special case for this test, as it is the only one that doesn't
@ -648,6 +712,11 @@ func TestValue_ObjectAttributes(t *testing.T) {
input := wrapChangeInSlice(tc.input)
if tc.validateList != nil {
tc.validateList(t, input.ComputeDiffForAttribute(attribute))
return
}
if tc.validateObject != nil {
validate := renderers.ValidateList([]renderers.ValidateDiffFunction{
tc.validateObject,
@ -1084,6 +1153,8 @@ func TestValue_BlockAttributesAndNestedBlocks(t *testing.T) {
After: map[string]interface{}{
"block_type": tc.after,
},
ReplacePaths: &attribute_path.PathMatcher{},
RelevantAttributes: attribute_path.AlwaysMatcher(),
}
block := &jsonprovider.Block{
@ -1112,6 +1183,8 @@ func TestValue_BlockAttributesAndNestedBlocks(t *testing.T) {
"one": tc.after,
},
},
ReplacePaths: &attribute_path.PathMatcher{},
RelevantAttributes: attribute_path.AlwaysMatcher(),
}
block := &jsonprovider.Block{
@ -1142,6 +1215,8 @@ func TestValue_BlockAttributesAndNestedBlocks(t *testing.T) {
tc.after,
},
},
ReplacePaths: &attribute_path.PathMatcher{},
RelevantAttributes: attribute_path.AlwaysMatcher(),
}
block := &jsonprovider.Block{
@ -1172,6 +1247,8 @@ func TestValue_BlockAttributesAndNestedBlocks(t *testing.T) {
tc.after,
},
},
ReplacePaths: &attribute_path.PathMatcher{},
RelevantAttributes: attribute_path.AlwaysMatcher(),
}
block := &jsonprovider.Block{
@ -1416,6 +1493,15 @@ func TestValue_Outputs(t *testing.T) {
}
for name, tc := range tcs {
// Let's set some default values on the input.
if tc.input.RelevantAttributes == nil {
tc.input.RelevantAttributes = attribute_path.AlwaysMatcher()
}
if tc.input.ReplacePaths == nil {
tc.input.ReplacePaths = &attribute_path.PathMatcher{}
}
t.Run(name, func(t *testing.T) {
tc.validateDiff(t, tc.input.ComputeDiffForOutput())
})
@ -1543,8 +1629,8 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
input: Change{
Before: "old",
After: "new",
ReplacePaths: replace.ForcesReplacement{
ReplacePaths: [][]interface{}{
ReplacePaths: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{}, // An empty path suggests replace should be true.
},
},
@ -1553,7 +1639,7 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
validateDiff: renderers.ValidatePrimitive("old", "new", plans.Update, true),
validateSliceDiffs: []renderers.ValidateDiffFunction{
renderers.ValidatePrimitive("old", nil, plans.Delete, true),
renderers.ValidatePrimitive(nil, "new", plans.Create, false),
renderers.ValidatePrimitive(nil, "new", plans.Create, true),
},
},
"noop": {
@ -1595,6 +1681,14 @@ func TestValue_PrimitiveAttributes(t *testing.T) {
for name, tmp := range tcs {
tc := tmp
// Let's set some default values on the input.
if tc.input.RelevantAttributes == nil {
tc.input.RelevantAttributes = attribute_path.AlwaysMatcher()
}
if tc.input.ReplacePaths == nil {
tc.input.ReplacePaths = &attribute_path.PathMatcher{}
}
defaultCollectionsAction := plans.Update
if name == "noop" {
defaultCollectionsAction = plans.NoOp
@ -2014,12 +2108,373 @@ func TestValue_CollectionAttributes(t *testing.T) {
}
for name, tc := range tcs {
// Let's set some default values on the input.
if tc.input.RelevantAttributes == nil {
tc.input.RelevantAttributes = attribute_path.AlwaysMatcher()
}
if tc.input.ReplacePaths == nil {
tc.input.ReplacePaths = &attribute_path.PathMatcher{}
}
t.Run(name, func(t *testing.T) {
tc.validateDiff(t, tc.input.ComputeDiffForAttribute(tc.attribute))
})
}
}
func TestRelevantAttributes(t *testing.T) {
tcs := map[string]struct {
input Change
block *jsonprovider.Block
validate renderers.ValidateDiffFunction
}{
"simple_attributes": {
input: Change{
Before: map[string]interface{}{
"id": "old_id",
"ignore": "doesn't matter",
},
After: map[string]interface{}{
"id": "new_id",
"ignore": "doesn't matter but modified",
},
RelevantAttributes: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{
"id",
},
},
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"id": {
AttributeType: unmarshalType(t, cty.String),
},
"ignore": {
AttributeType: unmarshalType(t, cty.String),
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"id": renderers.ValidatePrimitive("old_id", "new_id", plans.Update, false),
"ignore": renderers.ValidatePrimitive("doesn't matter", "doesn't matter", plans.NoOp, false),
}, nil, nil, nil, nil, plans.Update, false),
},
"nested_attributes": {
input: Change{
Before: map[string]interface{}{
"list_block": []interface{}{
map[string]interface{}{
"id": "old_one",
},
map[string]interface{}{
"id": "ignored",
},
},
},
After: map[string]interface{}{
"list_block": []interface{}{
map[string]interface{}{
"id": "new_one",
},
map[string]interface{}{
"id": "ignored_but_changed",
},
},
},
RelevantAttributes: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{
"list_block",
float64(0),
"id",
},
},
},
},
block: &jsonprovider.Block{
BlockTypes: map[string]*jsonprovider.BlockType{
"list_block": {
Block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"id": {
AttributeType: unmarshalType(t, cty.String),
},
},
},
NestingMode: "list",
},
},
},
validate: renderers.ValidateBlock(nil, nil, map[string][]renderers.ValidateDiffFunction{
"list_block": {
renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"id": renderers.ValidatePrimitive("old_one", "new_one", plans.Update, false),
}, nil, nil, nil, nil, plans.Update, false),
renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"id": renderers.ValidatePrimitive("ignored", "ignored", plans.NoOp, false),
}, nil, nil, nil, nil, plans.NoOp, false),
},
}, nil, nil, plans.Update, false),
},
"nested_attributes_in_object": {
input: Change{
Before: map[string]interface{}{
"object": map[string]interface{}{
"id": "old_id",
},
},
After: map[string]interface{}{
"object": map[string]interface{}{
"id": "new_id",
},
},
RelevantAttributes: &attribute_path.PathMatcher{
Propagate: true,
Paths: [][]interface{}{
{
"object", // Even though we just specify object, it should now include every below object as well.
},
},
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"object": {
AttributeType: unmarshalType(t, cty.Object(map[string]cty.Type{
"id": cty.String,
})),
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"object": renderers.ValidateObject(map[string]renderers.ValidateDiffFunction{
"id": renderers.ValidatePrimitive("old_id", "new_id", plans.Update, false),
}, plans.Update, false),
}, nil, nil, nil, nil, plans.Update, false),
},
"elements_in_list": {
input: Change{
Before: map[string]interface{}{
"list": []interface{}{
0, 1, 2, 3, 4,
},
},
After: map[string]interface{}{
"list": []interface{}{
0, 5, 6, 7, 4,
},
},
RelevantAttributes: &attribute_path.PathMatcher{
Paths: [][]interface{}{ // The list is actually just going to ignore this.
{
"list",
float64(0),
},
{
"list",
float64(2),
},
{
"list",
float64(4),
},
},
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"list": {
AttributeType: unmarshalType(t, cty.List(cty.Number)),
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
// The list validator below just ignores our relevant
// attributes. This is deliberate.
"list": renderers.ValidateList([]renderers.ValidateDiffFunction{
renderers.ValidatePrimitive(0, 0, plans.NoOp, false),
renderers.ValidatePrimitive(1, nil, plans.Delete, false),
renderers.ValidatePrimitive(2, nil, plans.Delete, false),
renderers.ValidatePrimitive(3, nil, plans.Delete, false),
renderers.ValidatePrimitive(nil, 5, plans.Create, false),
renderers.ValidatePrimitive(nil, 6, plans.Create, false),
renderers.ValidatePrimitive(nil, 7, plans.Create, false),
renderers.ValidatePrimitive(4, 4, plans.NoOp, false),
}, plans.Update, false),
}, nil, nil, nil, nil, plans.Update, false),
},
"elements_in_map": {
input: Change{
Before: map[string]interface{}{
"map": map[string]interface{}{
"key_one": "value_one",
"key_two": "value_two",
"key_three": "value_three",
},
},
After: map[string]interface{}{
"map": map[string]interface{}{
"key_one": "value_three",
"key_two": "value_seven",
"key_four": "value_four",
},
},
RelevantAttributes: &attribute_path.PathMatcher{
Paths: [][]interface{}{
{
"map",
"key_one",
},
{
"map",
"key_three",
},
{
"map",
"key_four",
},
},
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"map": {
AttributeType: unmarshalType(t, cty.Map(cty.String)),
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"map": renderers.ValidateMap(map[string]renderers.ValidateDiffFunction{
"key_one": renderers.ValidatePrimitive("value_one", "value_three", plans.Update, false),
"key_two": renderers.ValidatePrimitive("value_two", "value_two", plans.NoOp, false),
"key_three": renderers.ValidatePrimitive("value_three", nil, plans.Delete, false),
"key_four": renderers.ValidatePrimitive(nil, "value_four", plans.Create, false),
}, plans.Update, false),
}, nil, nil, nil, nil, plans.Update, false),
},
"elements_in_set": {
input: Change{
Before: map[string]interface{}{
"set": []interface{}{
0, 1, 2, 3, 4,
},
},
After: map[string]interface{}{
"set": []interface{}{
0, 2, 4, 5, 6,
},
},
RelevantAttributes: &attribute_path.PathMatcher{
Propagate: true,
Paths: [][]interface{}{
{
"set",
},
},
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"set": {
AttributeType: unmarshalType(t, cty.Set(cty.Number)),
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"set": renderers.ValidateSet([]renderers.ValidateDiffFunction{
renderers.ValidatePrimitive(0, 0, plans.NoOp, false),
renderers.ValidatePrimitive(1, nil, plans.Delete, false),
renderers.ValidatePrimitive(2, 2, plans.NoOp, false),
renderers.ValidatePrimitive(3, nil, plans.Delete, false),
renderers.ValidatePrimitive(4, 4, plans.NoOp, false),
renderers.ValidatePrimitive(nil, 5, plans.Create, false),
renderers.ValidatePrimitive(nil, 6, plans.Create, false),
}, plans.Update, false),
}, nil, nil, nil, nil, plans.Update, false),
},
"dynamic_types": {
input: Change{
Before: map[string]interface{}{
"dynamic_nested_type": map[string]interface{}{
"nested_id": "nomatch",
"nested_object": map[string]interface{}{
"nested_nested_id": "matched",
},
},
"dynamic_nested_type_match": map[string]interface{}{
"nested_id": "allmatch",
"nested_object": map[string]interface{}{
"nested_nested_id": "allmatch",
},
},
},
After: map[string]interface{}{
"dynamic_nested_type": map[string]interface{}{
"nested_id": "nomatch_changed",
"nested_object": map[string]interface{}{
"nested_nested_id": "matched",
},
},
"dynamic_nested_type_match": map[string]interface{}{
"nested_id": "allmatch",
"nested_object": map[string]interface{}{
"nested_nested_id": "allmatch",
},
},
},
RelevantAttributes: &attribute_path.PathMatcher{
Propagate: true,
Paths: [][]interface{}{
{
"dynamic_nested_type",
"nested_object",
"nested_nested_id",
},
{
"dynamic_nested_type_match",
},
},
},
},
block: &jsonprovider.Block{
Attributes: map[string]*jsonprovider.Attribute{
"dynamic_nested_type": {
AttributeType: unmarshalType(t, cty.DynamicPseudoType),
},
"dynamic_nested_type_match": {
AttributeType: unmarshalType(t, cty.DynamicPseudoType),
},
},
},
validate: renderers.ValidateBlock(map[string]renderers.ValidateDiffFunction{
"dynamic_nested_type": renderers.ValidateObject(map[string]renderers.ValidateDiffFunction{
"nested_id": renderers.ValidatePrimitive("nomatch", "nomatch", plans.NoOp, false),
"nested_object": renderers.ValidateObject(map[string]renderers.ValidateDiffFunction{
"nested_nested_id": renderers.ValidatePrimitive("matched", "matched", plans.NoOp, false),
}, plans.NoOp, false),
}, plans.NoOp, false),
"dynamic_nested_type_match": renderers.ValidateObject(map[string]renderers.ValidateDiffFunction{
"nested_id": renderers.ValidatePrimitive("allmatch", "allmatch", plans.NoOp, false),
"nested_object": renderers.ValidateObject(map[string]renderers.ValidateDiffFunction{
"nested_nested_id": renderers.ValidatePrimitive("allmatch", "allmatch", plans.NoOp, false),
}, plans.NoOp, false),
}, plans.NoOp, false),
}, nil, nil, nil, nil, plans.NoOp, false),
},
}
for name, tc := range tcs {
if tc.input.ReplacePaths == nil {
tc.input.ReplacePaths = &attribute_path.PathMatcher{}
}
t.Run(name, func(t *testing.T) {
tc.validate(t, tc.input.ComputeDiffForBlock(tc.block))
})
}
}
// unmarshalType converts a cty.Type into a json.RawMessage understood by the
// schema. It also lets the testing framework handle any errors to keep the API
// clean.
@ -2070,20 +2525,37 @@ func wrapChangeInMap(input Change) Change {
func wrapChange(input Change, step interface{}, wrap func(interface{}, interface{}, bool) interface{}) Change {
replacePaths := replace.ForcesReplacement{}
for _, path := range input.ReplacePaths.ReplacePaths {
replacePaths := &attribute_path.PathMatcher{}
for _, path := range input.ReplacePaths.(*attribute_path.PathMatcher).Paths {
var updated []interface{}
updated = append(updated, step)
updated = append(updated, path...)
replacePaths.ReplacePaths = append(replacePaths.ReplacePaths, updated)
replacePaths.Paths = append(replacePaths.Paths, updated)
}
// relevantAttributes usually default to AlwaysMatcher, which means we can
// just ignore it. But if we have had some paths specified we need to wrap
// those as well.
relevantAttributes := input.RelevantAttributes
if concrete, ok := relevantAttributes.(*attribute_path.PathMatcher); ok {
newRelevantAttributes := &attribute_path.PathMatcher{}
for _, path := range concrete.Paths {
var updated []interface{}
updated = append(updated, step)
updated = append(updated, path...)
newRelevantAttributes.Paths = append(newRelevantAttributes.Paths, updated)
}
relevantAttributes = newRelevantAttributes
}
return Change{
Before: wrap(input.Before, nil, input.BeforeExplicit),
After: wrap(input.After, input.Unknown, input.AfterExplicit),
Unknown: wrap(input.Unknown, nil, false),
BeforeSensitive: wrap(input.BeforeSensitive, nil, false),
AfterSensitive: wrap(input.AfterSensitive, nil, false),
ReplacePaths: replacePaths,
Before: wrap(input.Before, nil, input.BeforeExplicit),
After: wrap(input.After, input.Unknown, input.AfterExplicit),
Unknown: wrap(input.Unknown, nil, false),
BeforeSensitive: wrap(input.BeforeSensitive, nil, false),
AfterSensitive: wrap(input.AfterSensitive, nil, false),
ReplacePaths: replacePaths,
RelevantAttributes: relevantAttributes,
}
}

View File

@ -6,6 +6,7 @@ import (
"github.com/hashicorp/terraform/internal/command/jsonformat/collections"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed/renderers"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/plans"
)
@ -14,7 +15,29 @@ func (change Change) computeAttributeDiffAsList(elementType cty.Type) computed.D
sliceValue := change.asSlice()
processIndices := func(beforeIx, afterIx int) computed.Diff {
return sliceValue.getChild(beforeIx, afterIx).computeDiffForType(elementType)
value := sliceValue.getChild(beforeIx, afterIx)
// It's actually really difficult to render the diffs when some indices
// within a slice are relevant and others aren't. To make this simpler
// we just treat all children of a relevant list or set as also
// relevant.
//
// Interestingly the terraform plan builder also agrees with this, and
// never sets relevant attributes beneath lists or sets. We're just
// going to enforce this logic here as well. If the collection is
// relevant (decided elsewhere), then every element in the collection is
// also relevant. To be clear, in practice even if we didn't do the
// following explicitly the effect would be the same. It's just nicer
// for us to be clear about the behaviour we expect.
//
// What makes this difficult is the fact that the beforeIx and afterIx
// can be different, and it's quite difficult to work out which one is
// the relevant one. For nested lists, block lists, and tuples it's much
// easier because we always process the same indices in the before and
// after.
value.RelevantAttributes = attribute_path.AlwaysMatcher()
return value.computeDiffForType(elementType)
}
isObjType := func(_ interface{}) bool {
@ -22,7 +45,7 @@ func (change Change) computeAttributeDiffAsList(elementType cty.Type) computed.D
}
elements, current := collections.TransformSlice(sliceValue.Before, sliceValue.After, processIndices, isObjType)
return computed.NewDiff(renderers.List(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.List(elements), current, change.ReplacePaths.Matches())
}
func (change Change) computeAttributeDiffAsNestedList(attributes map[string]*jsonprovider.Attribute) computed.Diff {
@ -36,7 +59,7 @@ func (change Change) computeAttributeDiffAsNestedList(attributes map[string]*jso
elements = append(elements, element)
current = collections.CompareActions(current, element.Action)
})
return computed.NewDiff(renderers.NestedList(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.NestedList(elements), current, change.ReplacePaths.Matches())
}
func (change Change) computeBlockDiffsAsList(block *jsonprovider.Block) ([]computed.Diff, plans.Action) {
@ -53,6 +76,11 @@ func (change Change) computeBlockDiffsAsList(block *jsonprovider.Block) ([]compu
func (change Change) processNestedList(process func(value Change)) {
sliceValue := change.asSlice()
for ix := 0; ix < len(sliceValue.Before) || ix < len(sliceValue.After); ix++ {
process(sliceValue.getChild(ix, ix))
value := sliceValue.getChild(ix, ix)
if !value.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
value = value.AsNoOp()
}
process(value)
}
}

View File

@ -13,25 +13,40 @@ import (
func (change Change) computeAttributeDiffAsMap(elementType cty.Type) computed.Diff {
mapValue := change.asMap()
elements, current := collections.TransformMap(mapValue.Before, mapValue.After, func(key string) computed.Diff {
return mapValue.getChild(key).computeDiffForType(elementType)
value := mapValue.getChild(key)
if !value.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
value = value.AsNoOp()
}
return value.computeDiffForType(elementType)
})
return computed.NewDiff(renderers.Map(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.Map(elements), current, change.ReplacePaths.Matches())
}
func (change Change) computeAttributeDiffAsNestedMap(attributes map[string]*jsonprovider.Attribute) computed.Diff {
mapValue := change.asMap()
elements, current := collections.TransformMap(mapValue.Before, mapValue.After, func(key string) computed.Diff {
return mapValue.getChild(key).computeDiffForNestedAttribute(&jsonprovider.NestedType{
value := mapValue.getChild(key)
if !value.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
value = value.AsNoOp()
}
return value.computeDiffForNestedAttribute(&jsonprovider.NestedType{
Attributes: attributes,
NestingMode: "single",
})
})
return computed.NewDiff(renderers.NestedMap(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.NestedMap(elements), current, change.ReplacePaths.Matches())
}
func (change Change) computeBlockDiffsAsMap(block *jsonprovider.Block) (map[string]computed.Diff, plans.Action) {
mapValue := change.asMap()
return collections.TransformMap(mapValue.Before, mapValue.After, func(key string) computed.Diff {
return mapValue.getChild(key).ComputeDiffForBlock(block)
value := mapValue.getChild(key)
if !value.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
value = value.AsNoOp()
}
return value.ComputeDiffForBlock(block)
})
}

View File

@ -14,14 +14,14 @@ func (change Change) computeAttributeDiffAsObject(attributes map[string]cty.Type
attributeDiffs, action := processObject(change, attributes, func(value Change, ctype cty.Type) computed.Diff {
return value.computeDiffForType(ctype)
})
return computed.NewDiff(renderers.Object(attributeDiffs), action, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.Object(attributeDiffs), action, change.ReplacePaths.Matches())
}
func (change Change) computeAttributeDiffAsNestedObject(attributes map[string]*jsonprovider.Attribute) computed.Diff {
attributeDiffs, action := processObject(change, attributes, func(value Change, attribute *jsonprovider.Attribute) computed.Diff {
return value.ComputeDiffForAttribute(attribute)
})
return computed.NewDiff(renderers.NestedObject(attributeDiffs), action, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.NestedObject(attributeDiffs), action, change.ReplacePaths.Matches())
}
// processObject steps through the children of value as if it is an object and
@ -43,6 +43,11 @@ func processObject[T any](v Change, attributes map[string]T, computeDiff func(Ch
for key, attribute := range attributes {
attributeValue := mapValue.getChild(key)
if !attributeValue.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
attributeValue = attributeValue.AsNoOp()
}
// We always assume changes to object are implicit.
attributeValue.BeforeExplicit = false
attributeValue.AfterExplicit = false

View File

@ -17,5 +17,5 @@ func (change Change) ComputeDiffForOutput() computed.Diff {
}
jsonOpts := renderers.RendererJsonOpts()
return jsonOpts.Transform(change.Before, change.After)
return jsonOpts.Transform(change.Before, change.After, change.RelevantAttributes)
}

View File

@ -1,114 +0,0 @@
package replace
import "encoding/json"
// ForcesReplacement encapsulates the ReplacePaths logic from the Terraform
// change object.
//
// It is possible for a change to a deeply nested attribute or block to result
// in an entire resource being replaced (deleted then recreated) instead of
// simply updated. In this case, we want to attach some additional context to
// say, this resource is being replaced because of these changes to its
// internal values.
//
// The ReplacePaths field is a slice of paths that point to the values causing
// the replace operation. It's a slice of paths because you can have multiple
// internal values causing a replacement.
//
// Each path is a slice of indices, where an index can be a string or an
// integer. We represent this a slice of generic interfaces: []interface{}. This
// is because we actually parse this field from JSON and have no way to easily
// represent a value that can be a string or an integer in Go. Luckily, this
// doesn't matter too much from an implementation point of view because we
// always know what type to expect as we know whether we are currently looking
// at a list type (which means an integer) or a map type (which means a string).
//
// The GetChildWithKey and GetChildWithIndex return additional but modified
// ForcesReplacement objects, where a path is simply dropped if the index
// doesn't match or included with the first entry removed if the index did
// match. These functions are called as the outside Change objects are being
// created for a complex change's children.
//
// The ForcesReplacement function actually tells you whether the current value
// is causing a replacement operation as one of the paths will be empty since
// we removed an entry every time the path matched, and the last entry will have
// been removed when the change was created.
type ForcesReplacement struct {
ReplacePaths [][]interface{}
}
// Parse accepts a json.RawMessage and outputs a formatted ForcesReplacement
// object.
//
// Parse expects the message to be a JSON array of JSON arrays containing
// strings and floats. This function happily accepts a null input representing
// none of the changes in this resource are causing a replacement.
func Parse(message json.RawMessage) ForcesReplacement {
replace := ForcesReplacement{}
if message == nil {
return replace
}
if err := json.Unmarshal(message, &replace.ReplacePaths); err != nil {
panic("failed to unmarshal replace paths: " + err.Error())
}
return replace
}
// ForcesReplacement returns true if this ForcesReplacement object represents
// a change that is causing the entire resource to be replaced.
func (replace ForcesReplacement) ForcesReplacement() bool {
for _, path := range replace.ReplacePaths {
if len(path) == 0 {
return true
}
}
return false
}
// GetChildWithKey steps through the paths in this ForcesReplacement and checks
// if any match the specified key.
//
// This function assumes the index will all be strings, so callers have to be
// sure they have navigated through previous paths accurately to this point or
// this function is liable to panic.
func (replace ForcesReplacement) GetChildWithKey(key string) ForcesReplacement {
child := ForcesReplacement{}
for _, path := range replace.ReplacePaths {
if len(path) == 0 {
// This means that the current value is causing a replacement but
// not its children, so we skip as we are returning the child's
// value.
continue
}
if path[0].(string) == key {
child.ReplacePaths = append(child.ReplacePaths, path[1:])
}
}
return child
}
// GetChildWithIndex steps through the paths in this ForcesReplacement and
// checks if any match the specified index.
//
// This function assumes the index will all be integers, so callers have to be
// sure they have navigated through previous paths accurately to this point or
// this function is liable to panic.
func (replace ForcesReplacement) GetChildWithIndex(index int) ForcesReplacement {
child := ForcesReplacement{}
for _, path := range replace.ReplacePaths {
if len(path) == 0 {
// This means that the current value is causing a replacement but
// not its children, so we skip as we are returning the child's
// value.
continue
}
if int(path[0].(float64)) == index {
child.ReplacePaths = append(child.ReplacePaths, path[1:])
}
}
return child
}

View File

@ -45,14 +45,15 @@ func (change Change) checkForSensitive(create CreateSensitiveRenderer, computedD
// it will just be ignored in favour of printing `(sensitive value)`.
value := Change{
BeforeExplicit: change.BeforeExplicit,
AfterExplicit: change.AfterExplicit,
Before: change.Before,
After: change.After,
Unknown: change.Unknown,
BeforeSensitive: false,
AfterSensitive: false,
ReplacePaths: change.ReplacePaths,
BeforeExplicit: change.BeforeExplicit,
AfterExplicit: change.AfterExplicit,
Before: change.Before,
After: change.After,
Unknown: change.Unknown,
BeforeSensitive: false,
AfterSensitive: false,
ReplacePaths: change.ReplacePaths,
RelevantAttributes: change.RelevantAttributes,
}
inner := computedDiff(value)
@ -64,7 +65,7 @@ func (change Change) checkForSensitive(create CreateSensitiveRenderer, computedD
action = plans.Update
}
return computed.NewDiff(create(inner, beforeSensitive, afterSensitive), action, change.ReplacePaths.ForcesReplacement()), true
return computed.NewDiff(create(inner, beforeSensitive, afterSensitive), action, change.ReplacePaths.Matches()), true
}
func (change Change) isBeforeSensitive() bool {

View File

@ -8,6 +8,7 @@ import (
"github.com/hashicorp/terraform/internal/command/jsonformat/collections"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed/renderers"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonprovider"
"github.com/hashicorp/terraform/internal/plans"
)
@ -20,7 +21,7 @@ func (change Change) computeAttributeDiffAsSet(elementType cty.Type) computed.Di
elements = append(elements, element)
current = collections.CompareActions(current, element.Action)
})
return computed.NewDiff(renderers.Set(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.Set(elements), current, change.ReplacePaths.Matches())
}
func (change Change) computeAttributeDiffAsNestedSet(attributes map[string]*jsonprovider.Attribute) computed.Diff {
@ -34,7 +35,7 @@ func (change Change) computeAttributeDiffAsNestedSet(attributes map[string]*json
elements = append(elements, element)
current = collections.CompareActions(current, element.Action)
})
return computed.NewDiff(renderers.NestedSet(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.NestedSet(elements), current, change.ReplacePaths.Matches())
}
func (change Change) computeBlockDiffsAsSet(block *jsonprovider.Block) ([]computed.Diff, plans.Action) {
@ -79,6 +80,29 @@ func (change Change) processSet(process func(value Change)) {
}
}
clearRelevantStatus := func(change Change) Change {
// It's actually really difficult to render the diffs when some indices
// within a slice are relevant and others aren't. To make this simpler
// we just treat all children of a relevant list or set as also
// relevant.
//
// Interestingly the terraform plan builder also agrees with this, and
// never sets relevant attributes beneath lists or sets. We're just
// going to enforce this logic here as well. If the collection is
// relevant (decided elsewhere), then every element in the collection is
// also relevant. To be clear, in practice even if we didn't do the
// following explicitly the effect would be the same. It's just nicer
// for us to be clear about the behaviour we expect.
//
// What makes this difficult is the fact that the beforeIx and afterIx
// can be different, and it's quite difficult to work out which one is
// the relevant one. For nested lists, block lists, and tuples it's much
// easier because we always process the same indices in the before and
// after.
change.RelevantAttributes = attribute_path.AlwaysMatcher()
return change
}
// Now everything in before should be a key in foundInBefore and a value
// in foundInAfter. If a key is mapped to -1 in foundInBefore it means it
// does not have an equivalent in foundInAfter and so has been deleted.
@ -88,11 +112,11 @@ func (change Change) processSet(process func(value Change)) {
for ix := 0; ix < len(sliceValue.Before); ix++ {
if jx := foundInBefore[ix]; jx >= 0 {
child := sliceValue.getChild(ix, jx)
child := clearRelevantStatus(sliceValue.getChild(ix, jx))
process(child)
continue
}
child := sliceValue.getChild(ix, len(sliceValue.After))
child := clearRelevantStatus(sliceValue.getChild(ix, len(sliceValue.After)))
process(child)
}
@ -101,7 +125,7 @@ func (change Change) processSet(process func(value Change)) {
// Then this value was handled in the previous for loop.
continue
}
child := sliceValue.getChild(len(sliceValue.Before), jx)
child := clearRelevantStatus(sliceValue.getChild(len(sliceValue.Before), jx))
process(child)
}
}

View File

@ -14,9 +14,13 @@ func (change Change) computeAttributeDiffAsTuple(elementTypes []cty.Type) comput
sliceValue := change.asSlice()
for ix, elementType := range elementTypes {
childValue := sliceValue.getChild(ix, ix)
if !childValue.RelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
childValue = childValue.AsNoOp()
}
element := childValue.computeDiffForType(elementType)
elements = append(elements, element)
current = collections.CompareActions(current, element.Action)
}
return computed.NewDiff(renderers.List(elements), current, change.ReplacePaths.ForcesReplacement())
return computed.NewDiff(renderers.List(elements), current, change.ReplacePaths.Matches())
}

View File

@ -64,9 +64,11 @@ func (change Change) checkForUnknown(childUnknown interface{}, computeDiff func(
// accurately.
beforeValue := Change{
Before: change.Before,
BeforeSensitive: change.BeforeSensitive,
Unknown: childUnknown,
Before: change.Before,
BeforeSensitive: change.BeforeSensitive,
Unknown: childUnknown,
ReplacePaths: change.ReplacePaths,
RelevantAttributes: change.RelevantAttributes,
}
return change.asDiff(renderers.Unknown(computeDiff(beforeValue))), true
}

View File

@ -3,6 +3,8 @@ package jsondiff
import (
"reflect"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/hashicorp/terraform/internal/command/jsonformat/collections"
"github.com/hashicorp/terraform/internal/command/jsonformat/computed"
@ -28,7 +30,7 @@ type JsonOpts struct {
// Transform accepts a generic before and after value that is assumed to be JSON
// formatted and transforms it into a computed.Diff, using the callbacks
// supplied in the JsonOpts class.
func (opts JsonOpts) Transform(before, after interface{}) computed.Diff {
func (opts JsonOpts) Transform(before, after interface{}, relevantAttributes attribute_path.Matcher) computed.Diff {
beforeType := GetType(before)
afterType := GetType(after)
@ -37,15 +39,15 @@ func (opts JsonOpts) Transform(before, after interface{}) computed.Diff {
if targetType == Null {
targetType = afterType
}
return opts.processUpdate(before, after, targetType)
return opts.processUpdate(before, after, targetType, relevantAttributes)
}
b := opts.processUpdate(before, nil, beforeType)
a := opts.processUpdate(nil, after, afterType)
b := opts.processUpdate(before, nil, beforeType, relevantAttributes)
a := opts.processUpdate(nil, after, afterType, relevantAttributes)
return opts.TypeChange(b, a, plans.Update)
}
func (opts JsonOpts) processUpdate(before, after interface{}, jtype Type) computed.Diff {
func (opts JsonOpts) processUpdate(before, after interface{}, jtype Type, relevantAttributes attribute_path.Matcher) computed.Diff {
switch jtype {
case Null:
return opts.processPrimitive(before, after, cty.NilType)
@ -66,7 +68,7 @@ func (opts JsonOpts) processUpdate(before, after interface{}, jtype Type) comput
a = after.(map[string]interface{})
}
return opts.processObject(b, a)
return opts.processObject(b, a, relevantAttributes)
case Array:
var b, a []interface{}
@ -107,12 +109,19 @@ func (opts JsonOpts) processArray(before, after []interface{}) computed.Diff {
if beforeIx >= 0 && beforeIx < len(before) {
b = before[beforeIx]
}
if afterIx >= 0 && afterIx < len(after) {
a = after[afterIx]
}
return opts.Transform(b, a)
// It's actually really difficult to render the diffs when some indices
// within a list are relevant and others aren't. To make this simpler
// we just treat all children of a relevant list as also relevant.
//
// Interestingly the terraform plan builder also agrees with this, and
// never sets relevant attributes beneath lists or sets. We're just
// going to enforce this logic here as well. If the list is relevant
// (decided elsewhere), then every element in the list is also relevant.
return opts.Transform(b, a, attribute_path.AlwaysMatcher())
}
isObjType := func(value interface{}) bool {
@ -122,8 +131,18 @@ func (opts JsonOpts) processArray(before, after []interface{}) computed.Diff {
return opts.Array(collections.TransformSlice(before, after, processIndices, isObjType))
}
func (opts JsonOpts) processObject(before, after map[string]interface{}) computed.Diff {
func (opts JsonOpts) processObject(before, after map[string]interface{}, relevantAttributes attribute_path.Matcher) computed.Diff {
return opts.Object(collections.TransformMap(before, after, func(key string) computed.Diff {
return opts.Transform(before[key], after[key])
childRelevantAttributes := relevantAttributes.GetChildWithKey(key)
beforeChild := before[key]
afterChild := after[key]
if !childRelevantAttributes.MatchesPartial() {
// Mark non-relevant attributes as unchanged.
afterChild = beforeChild
}
return opts.Transform(beforeChild, afterChild, childRelevantAttributes)
}))
}

View File

@ -28,10 +28,11 @@ const (
)
type Plan struct {
PlanFormatVersion string `json:"plan_format_version"`
OutputChanges map[string]jsonplan.Change `json:"output_changes"`
ResourceChanges []jsonplan.ResourceChange `json:"resource_changes"`
ResourceDrift []jsonplan.ResourceChange `json:"resource_drift"`
PlanFormatVersion string `json:"plan_format_version"`
OutputChanges map[string]jsonplan.Change `json:"output_changes"`
ResourceChanges []jsonplan.ResourceChange `json:"resource_changes"`
ResourceDrift []jsonplan.ResourceChange `json:"resource_drift"`
RelevantAttributes []jsonplan.ResourceAttr `json:"relevant_attributes"`
ProviderFormatVersion string `json:"provider_format_version"`
ProviderSchemas map[string]*jsonprovider.Provider `json:"provider_schemas"`
@ -64,7 +65,7 @@ func (r Renderer) RenderHumanPlan(plan Plan, mode plans.Mode, opts ...RendererOp
return false
}
diffs := precomputeDiffs(plan)
diffs := precomputeDiffs(plan, mode)
haveRefreshChanges := r.renderHumanDiffDrift(diffs, mode)
willPrintResourceChanges := false
@ -260,16 +261,9 @@ func (r Renderer) renderHumanDiffOutputs(outputs map[string]computed.Diff) strin
func (r Renderer) renderHumanDiffDrift(diffs diffs, mode plans.Mode) bool {
var drs []diff
if mode == plans.RefreshOnlyMode {
drs = diffs.drift
} else {
for _, dr := range diffs.drift {
// TODO(liamcervante): Look into if we have to keep filtering resource changes.
// For now we still want to remove the moved resources from here as
// they will show up in the regular changes.
if dr.diff.Action != plans.NoOp {
drs = append(drs, dr)
}
for _, dr := range diffs.drift {
if dr.diff.Action != plans.NoOp {
drs = append(drs, dr)
}
}
@ -277,6 +271,8 @@ func (r Renderer) renderHumanDiffDrift(diffs diffs, mode plans.Mode) bool {
return false
}
// If the overall plan is empty, and it's not a refresh only plan then we
// won't show any drift changes.
if diffs.Empty() && mode != plans.RefreshOnlyMode {
return false
}
@ -295,6 +291,19 @@ func (r Renderer) renderHumanDiffDrift(diffs diffs, mode plans.Mode) bool {
}
}
switch mode {
case plans.RefreshOnlyMode:
r.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.",
r.Streams.Stdout.Columns(),
))
default:
r.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.",
r.Streams.Stdout.Columns(),
))
}
return true
}

View File

@ -4,6 +4,8 @@ import (
"fmt"
"testing"
"github.com/hashicorp/terraform/internal/command/jsonformat/differ/attribute_path"
"github.com/google/go-cmp/cmp"
"github.com/mitchellh/colorstring"
"github.com/zclconf/go-cty/cty"
@ -6602,7 +6604,7 @@ func runTestCases(t *testing.T, testCases map[string]testCase) {
diff := diff{
change: jsonchanges[0],
diff: differ.
FromJsonChange(jsonchanges[0].Change).
FromJsonChange(jsonchanges[0].Change, attribute_path.AlwaysMatcher()).
ComputeDiffForBlock(jsonschemas[jsonchanges[0].ProviderName].ResourceSchemas[jsonchanges[0].Type].Block),
}
output, _ := renderer.renderHumanDiff(diff, proposedChange)
@ -6722,7 +6724,7 @@ func TestOutputChanges(t *testing.T) {
renderer := Renderer{Colorize: color}
diffs := precomputeDiffs(Plan{
OutputChanges: outputs,
})
}, plans.NormalMode)
output := renderer.renderHumanDiffOutputs(diffs.outputs)
if output != tc.output {

View File

@ -55,7 +55,7 @@ type plan struct {
OutputChanges map[string]Change `json:"output_changes,omitempty"`
PriorState json.RawMessage `json:"prior_state,omitempty"`
Config json.RawMessage `json:"configuration,omitempty"`
RelevantAttributes []resourceAttr `json:"relevant_attributes,omitempty"`
RelevantAttributes []ResourceAttr `json:"relevant_attributes,omitempty"`
Checks json.RawMessage `json:"checks,omitempty"`
}
@ -65,9 +65,9 @@ func newPlan() *plan {
}
}
// resourceAttr contains the address and attribute of an external for the
// ResourceAttr contains the address and attribute of an external for the
// RelevantAttributes in the plan.
type resourceAttr struct {
type ResourceAttr struct {
Resource string `json:"resource"`
Attr json.RawMessage `json:"attribute"`
}
@ -568,7 +568,7 @@ func (p *plan) marshalRelevantAttrs(plan *plans.Plan) error {
return err
}
p.RelevantAttributes = append(p.RelevantAttributes, resourceAttr{addr, path})
p.RelevantAttributes = append(p.RelevantAttributes, ResourceAttr{addr, path})
}
return nil
}