diff --git a/internal/command/jsonformat/computed/renderers/primitive.go b/internal/command/jsonformat/computed/renderers/primitive.go index 8eefb63660..b364a1883e 100644 --- a/internal/command/jsonformat/computed/renderers/primitive.go +++ b/internal/command/jsonformat/computed/renderers/primitive.go @@ -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 diff --git a/internal/command/jsonformat/diff.go b/internal/command/jsonformat/diff.go index 9ab789ebc2..3a4a77ee4b 100644 --- a/internal/command/jsonformat/diff.go +++ b/internal/command/jsonformat/diff.go @@ -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 { diff --git a/internal/command/jsonformat/differ/attribute_path/matcher.go b/internal/command/jsonformat/differ/attribute_path/matcher.go new file mode 100644 index 0000000000..8ab5f20695 --- /dev/null +++ b/internal/command/jsonformat/differ/attribute_path/matcher.go @@ -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 +} diff --git a/internal/command/jsonformat/differ/attribute_path/matcher_test.go b/internal/command/jsonformat/differ/attribute_path/matcher_test.go new file mode 100644 index 0000000000..a14c0cad8b --- /dev/null +++ b/internal/command/jsonformat/differ/attribute_path/matcher_test.go @@ -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") + } +} diff --git a/internal/command/jsonformat/differ/block.go b/internal/command/jsonformat/differ/block.go index 463db76331..3e4af7ec1a 100644 --- a/internal/command/jsonformat/differ/block.go +++ b/internal/command/jsonformat/differ/block.go @@ -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()) } diff --git a/internal/command/jsonformat/differ/change.go b/internal/command/jsonformat/differ/change.go index a8221dc1f9..65f163115b 100644 --- a/internal/command/jsonformat/differ/change.go +++ b/internal/command/jsonformat/differ/change.go @@ -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 diff --git a/internal/command/jsonformat/differ/change_map.go b/internal/command/jsonformat/differ/change_map.go index 8e1955a594..36ce500cfd 100644 --- a/internal/command/jsonformat/differ/change_map.go +++ b/internal/command/jsonformat/differ/change_map.go @@ -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), } } diff --git a/internal/command/jsonformat/differ/change_slice.go b/internal/command/jsonformat/differ/change_slice.go index 230d1be1e9..789b3e9844 100644 --- a/internal/command/jsonformat/differ/change_slice.go +++ b/internal/command/jsonformat/differ/change_slice.go @@ -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), } } diff --git a/internal/command/jsonformat/differ/change_test.go b/internal/command/jsonformat/differ/change_test.go index 4440b8a97f..b80e827d72 100644 --- a/internal/command/jsonformat/differ/change_test.go +++ b/internal/command/jsonformat/differ/change_test.go @@ -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, } } diff --git a/internal/command/jsonformat/differ/list.go b/internal/command/jsonformat/differ/list.go index ea2cb2c085..2a10f75345 100644 --- a/internal/command/jsonformat/differ/list.go +++ b/internal/command/jsonformat/differ/list.go @@ -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) } } diff --git a/internal/command/jsonformat/differ/map.go b/internal/command/jsonformat/differ/map.go index e4c29fc4f0..6c7411fe22 100644 --- a/internal/command/jsonformat/differ/map.go +++ b/internal/command/jsonformat/differ/map.go @@ -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) }) } diff --git a/internal/command/jsonformat/differ/object.go b/internal/command/jsonformat/differ/object.go index f6ebbc99c7..1ab2fc54c7 100644 --- a/internal/command/jsonformat/differ/object.go +++ b/internal/command/jsonformat/differ/object.go @@ -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 diff --git a/internal/command/jsonformat/differ/output.go b/internal/command/jsonformat/differ/output.go index 6af7a56318..483ccd78d1 100644 --- a/internal/command/jsonformat/differ/output.go +++ b/internal/command/jsonformat/differ/output.go @@ -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) } diff --git a/internal/command/jsonformat/differ/replace/replace.go b/internal/command/jsonformat/differ/replace/replace.go deleted file mode 100644 index 351ca64b5b..0000000000 --- a/internal/command/jsonformat/differ/replace/replace.go +++ /dev/null @@ -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 -} diff --git a/internal/command/jsonformat/differ/sensitive.go b/internal/command/jsonformat/differ/sensitive.go index 810d719ad7..5f615c5742 100644 --- a/internal/command/jsonformat/differ/sensitive.go +++ b/internal/command/jsonformat/differ/sensitive.go @@ -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 { diff --git a/internal/command/jsonformat/differ/set.go b/internal/command/jsonformat/differ/set.go index 373e888b79..915672ea03 100644 --- a/internal/command/jsonformat/differ/set.go +++ b/internal/command/jsonformat/differ/set.go @@ -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) } } diff --git a/internal/command/jsonformat/differ/tuple.go b/internal/command/jsonformat/differ/tuple.go index 96be443d71..5437120922 100644 --- a/internal/command/jsonformat/differ/tuple.go +++ b/internal/command/jsonformat/differ/tuple.go @@ -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()) } diff --git a/internal/command/jsonformat/differ/unknown.go b/internal/command/jsonformat/differ/unknown.go index 87f5e53ee1..6823e650e2 100644 --- a/internal/command/jsonformat/differ/unknown.go +++ b/internal/command/jsonformat/differ/unknown.go @@ -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 } diff --git a/internal/command/jsonformat/jsondiff/diff.go b/internal/command/jsonformat/jsondiff/diff.go index 711efe30e2..ba5819b78b 100644 --- a/internal/command/jsonformat/jsondiff/diff.go +++ b/internal/command/jsonformat/jsondiff/diff.go @@ -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) })) } diff --git a/internal/command/jsonformat/renderer.go b/internal/command/jsonformat/renderer.go index ddf0c2e8d1..f808441bd7 100644 --- a/internal/command/jsonformat/renderer.go +++ b/internal/command/jsonformat/renderer.go @@ -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 } diff --git a/internal/command/jsonformat/renderer_test.go b/internal/command/jsonformat/renderer_test.go index b1f1c1bed7..e5aa93a598 100644 --- a/internal/command/jsonformat/renderer_test.go +++ b/internal/command/jsonformat/renderer_test.go @@ -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 { diff --git a/internal/command/jsonplan/plan.go b/internal/command/jsonplan/plan.go index 93f53bf964..a69b1b190d 100644 --- a/internal/command/jsonplan/plan.go +++ b/internal/command/jsonplan/plan.go @@ -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 }