From b229573c7ee42e8d9c3d89cf50a32372deb7aa3f Mon Sep 17 00:00:00 2001 From: George K Date: Wed, 20 May 2026 09:25:34 -0700 Subject: [PATCH] feat(provisioner/terraform): log resource replacement paths (#24935) feat(provisioner/terraform): log resource replacement details Log compact Terraform resource replacement warnings for non-prebuild claim builds. The warnings include Terraform-reported replacement paths and before/after values when Terraform does not mark the value, or a descendant of the value, sensitive. Preserve Terraform sensitivity and unknown handling, and fall back to path-only or pathless messages when value details are unavailable. Prebuild claims continue using the existing full drift log path, including for pathless replacement actions that were previously skipped. Continue sending PlanComplete.ResourceReplacements only when Terraform reports replacement paths. Ref: https://linear.app/codercom/issue/PLAT-135/bug-build-terraform-logs-dont-include-which-fields-have-changed Ref: https://github.com/coder/coder/issues/16999 --- provisioner/terraform/executor.go | 44 +- .../terraform/resource_replacements.go | 373 +++++++++++- .../resource_replacements_internal_test.go | 545 +++++++++++++++++- 3 files changed, 897 insertions(+), 65 deletions(-) diff --git a/provisioner/terraform/executor.go b/provisioner/terraform/executor.go index 4a1c6021c6..dbd3d98a5b 100644 --- a/provisioner/terraform/executor.go +++ b/provisioner/terraform/executor.go @@ -338,30 +338,21 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l return nil, xerrors.Errorf("marshal plan: %w", err) } - // When a prebuild claim attempt is made, log a warning if a resource is due to be replaced, since this will obviate - // the point of prebuilding if the expensive resource is replaced once claimed! - var ( - isPrebuildClaimAttempt = !destroy && metadata.GetPrebuiltWorkspaceBuildStage().IsPrebuiltWorkspaceClaim() - resReps []*proto.ResourceReplacement - ) - if repsFromPlan := findResourceReplacements(plan); len(repsFromPlan) > 0 { - if isPrebuildClaimAttempt { - // TODO(dannyk): we should log drift always (not just during prebuild claim attempts); we're validating that this output - // will not be overwhelming for end-users, but it'll certainly be super valuable for template admins - // to diagnose this resource replacement issue, at least. - // Once prebuilds moves out of beta, consider deleting this condition. - + isPrebuildClaimAttempt := !destroy && + metadata.GetPrebuiltWorkspaceBuildStage().IsPrebuiltWorkspaceClaim() + if isPrebuildClaimAttempt { + // When a prebuild claim attempt is made, log a warning if a + // resource is due to be replaced, since this will obviate the + // point of prebuilding if the expensive resource is replaced + // once claimed! + if hasResourceReplacement(plan) { // Lock held before calling (see top of method). e.logDrift(ctx, killCtx, planfilePath, logr) } - - resReps = make([]*proto.ResourceReplacement, 0, len(repsFromPlan)) - for n, p := range repsFromPlan { - resReps = append(resReps, &proto.ResourceReplacement{ - Resource: n, - Paths: p, - }) - } + } else if reps := findAllResourceReplacements(plan); len(reps) > 0 { + // Non-prebuild-claim builds use compact replacement warnings + // to avoid overwhelming users with the full plan output. + logResourceReplacements(reps, logr) } state, err := ConvertPlanState(plan) @@ -369,6 +360,17 @@ func (e *executor) plan(ctx, killCtx context.Context, env, vars []string, logr l return nil, xerrors.Errorf("convert plan state: %w", err) } + var resReps []*proto.ResourceReplacement + if reps := findResourceReplacementsWithPaths(plan); len(reps) > 0 { + resReps = make([]*proto.ResourceReplacement, 0, len(reps)) + for n, p := range reps { + resReps = append(resReps, &proto.ResourceReplacement{ + Resource: n, + Paths: p, + }) + } + } + msg := &proto.PlanComplete{ Plan: planJSON, DailyCost: state.DailyCost, diff --git a/provisioner/terraform/resource_replacements.go b/provisioner/terraform/resource_replacements.go index a2bbbb1802..34f014fc64 100644 --- a/provisioner/terraform/resource_replacements.go +++ b/provisioner/terraform/resource_replacements.go @@ -1,20 +1,51 @@ package terraform import ( + "encoding/json" "fmt" + "sort" "strings" tfjson "github.com/hashicorp/terraform-json" + + "github.com/coder/coder/v2/provisionersdk/proto" ) -type resourceReplacements map[string][]string +type resourceReplacementPaths map[string][]string -// resourceReplacements finds all resources which would be replaced by the current plan, and the attribute paths which -// caused the replacement. +type replacementLogEntry struct { + resource string + paths []string + values map[string]replacementValues +} + +type replacementValues struct { + before replacementValue + after replacementValue +} + +type replacementValue struct { + text string + valid bool +} + +// findResourceReplacementsWithPaths returns a map from Terraform resource +// address to replacement-causing paths for resources that Terraform will +// replace and for which Terraform reported ReplacePaths. // -// NOTE: "replacement" in terraform terms means that a resource will have to be destroyed and replaced with a new resource -// since one of its immutable attributes was modified, which cannot be updated in-place. -func findResourceReplacements(plan *tfjson.Plan) resourceReplacements { +// "Replacement" in Terraform means that a resource will be destroyed +// and recreated rather than updated in place. This can happen because +// an immutable attribute changed or because Terraform requires +// replacement for some other reason. +// +// This helper intentionally skips replacements with empty ReplacePaths. +// Terraform can plan a replacement without attribute paths, for example +// when a prior failed apply left a resource tainted in state. Those +// replacements are still logged via findAllResourceReplacements, but we +// do not synthesize fake paths for PlanComplete.ResourceReplacements. +// Downstream prebuild metrics and notifications therefore only receive +// replacements with Terraform-reported paths. +func findResourceReplacementsWithPaths(plan *tfjson.Plan) resourceReplacementPaths { if plan == nil { return nil } @@ -24,7 +55,7 @@ func findResourceReplacements(plan *tfjson.Plan) resourceReplacements { return nil } - replacements := make(resourceReplacements, len(plan.ResourceChanges)) + replacements := make(resourceReplacementPaths, len(plan.ResourceChanges)) for _, ch := range plan.ResourceChanges { // No change, no problem! @@ -58,24 +89,10 @@ func findResourceReplacements(plan *tfjson.Plan) resourceReplacements { continue } - // Replacements found, problem! - for _, val := range ch.Change.ReplacePaths { - var pathStr string - // Each path needs to be coerced into a string. All types except []interface{} can be coerced using fmt.Sprintf. - switch path := val.(type) { - case []interface{}: - // Found a slice of paths; coerce to string and join by ".". - segments := make([]string, 0, len(path)) - for _, seg := range path { - segments = append(segments, fmt.Sprintf("%v", seg)) - } - pathStr = strings.Join(segments, ".") - default: - pathStr = fmt.Sprintf("%v", path) - } - - replacements[ch.Address] = append(replacements[ch.Address], pathStr) - } + replacements[ch.Address] = append( + replacements[ch.Address], + replacePathsToStrings(ch.Change.ReplacePaths)..., + ) } if len(replacements) == 0 { @@ -84,3 +101,309 @@ func findResourceReplacements(plan *tfjson.Plan) resourceReplacements { return replacements } + +// findAllResourceReplacements returns all non-coder resources Terraform +// will replace in the form used by compact replacement logging, including +// replacements without Terraform-reported paths. +// +// See findResourceReplacementsWithPaths for why pathless replacements +// are handled differently. +func findAllResourceReplacements(plan *tfjson.Plan) []replacementLogEntry { + if plan == nil { + return nil + } + + replacements := make([]replacementLogEntry, 0, len(plan.ResourceChanges)) + + for _, ch := range plan.ResourceChanges { + if !isNonCoderResourceReplacement(ch) { + continue + } + paths, values := replacementPathsAndValues(ch.Change) + replacements = append(replacements, replacementLogEntry{ + resource: ch.Address, + paths: paths, + values: values, + }) + } + + return replacements +} + +func hasResourceReplacement(plan *tfjson.Plan) bool { + if plan == nil { + return false + } + + for _, ch := range plan.ResourceChanges { + if isNonCoderResourceReplacement(ch) { + return true + } + } + return false +} + +func isNonCoderResourceReplacement(ch *tfjson.ResourceChange) bool { + if ch == nil || ch.Change == nil || !ch.Change.Actions.Replace() { + return false + } + return strings.Index(ch.Type, "coder_") != 0 +} + +func replacePathsToStrings(in []any) []string { + out := make([]string, 0, len(in)) + for _, path := range in { + out = append(out, replacePathToString(path)) + } + return out +} + +// replacePathToString formats a Terraform ReplacePaths entry. +// Terraform represents each replacement path as a slice of string or +// numeric path segments, which we format as a dotted string: +// +// ["root_block_device", 0, "volume_size"] -> "root_block_device.0.volume_size" +// +// Terraform is expected to provide the documented shape. The fallback +// preserves best-effort logging if an unexpected shape appears. +func replacePathToString(path any) string { + switch path := path.(type) { + case []any: + segments := make([]string, 0, len(path)) + for _, seg := range path { + segments = append(segments, fmt.Sprintf("%v", seg)) + } + return strings.Join(segments, ".") + default: + return fmt.Sprintf("%v", path) + } +} + +// replacementPathsAndValues returns formatted replacement paths and +// any printable before/after values for those paths. If Terraform +// does not provide ReplacePaths, both return values are nil, so +// logResourceReplacements will render a pathless fallback message. +func replacementPathsAndValues(change *tfjson.Change) ([]string, map[string]replacementValues) { + if change == nil || len(change.ReplacePaths) == 0 { + return nil, nil + } + + paths := make([]string, 0, len(change.ReplacePaths)) + values := make(map[string]replacementValues, len(change.ReplacePaths)) + + for _, rawPath := range change.ReplacePaths { + path := replacePathToString(rawPath) + paths = append(paths, path) + before := replacementValueAtPath( + change.Before, + change.BeforeSensitive, + nil, + rawPath, + ) + after := replacementValueAtPath( + change.After, + change.AfterSensitive, + change.AfterUnknown, + rawPath, + ) + if !before.valid && !after.valid { + // Keep the path, but omit value details when neither side + // has a printable value. The logger will still render the + // path-only replacement reason. + continue + } + + values[path] = replacementValues{ + before: before, + after: after, + } + } + + if len(values) == 0 { + return paths, nil + } + return paths, values +} + +func replacementValueAtPath(resourceValue, sensitive, unknown, path any) replacementValue { + r := replacementPathResolver{} + + if r.isMarkedAtPath(sensitive, path) { + return replacementValue{text: "(sensitive value)", valid: true} + } + if r.isMarkedAtPath(unknown, path) { + return replacementValue{text: "(known after apply)", valid: true} + } + + value, ok := r.valueAtPath(resourceValue, path) + if !ok { + // Terraform can omit one side of a replacement value, for + // example when a value is created, deleted, or unavailable in + // the plan. + return replacementValue{} + } + + // JSON formatting keeps arbitrary Terraform values unambiguous in + // logs: strings stay quoted, null stays null, and lists/maps do + // not use Go syntax. + formatted, err := json.Marshal(value) + if err != nil { + return replacementValue{} + } + return replacementValue{text: string(formatted), valid: true} +} + +// replacementPathResolver groups helpers for traversing Terraform +// JSON value and marker trees by replacement path. +type replacementPathResolver struct{} + +func (r replacementPathResolver) valueAtPath(valueTree, path any) (any, bool) { + current := valueTree + for _, segment := range r.pathSegments(path) { + var ok bool + current, ok = r.childAtPathSegment(current, segment) + if !ok { + return nil, false + } + } + return current, true +} + +func (r replacementPathResolver) isMarkedAtPath(markerTree, path any) bool { + current := markerTree + for _, segment := range r.pathSegments(path) { + if isMarked, ok := current.(bool); ok { + return isMarked + } + + next, ok := r.childAtPathSegment(current, segment) + if !ok { + return false + } + current = next + } + + // A parent path is sensitive if any descendant is sensitive. Terraform + // can report both "subject" and "subject.0.common_name" as replacement + // paths, while only marking the nested value sensitive. + return r.containsMarkedValue(current) +} + +func (r replacementPathResolver) containsMarkedValue(value any) bool { + switch value := value.(type) { + case bool: + return value + case map[string]any: + for _, child := range value { + if r.containsMarkedValue(child) { + return true + } + } + case []any: + for _, child := range value { + if r.containsMarkedValue(child) { + return true + } + } + } + return false +} + +func (replacementPathResolver) pathSegments(path any) []any { + switch path := path.(type) { + case []any: + return path + default: + return []any{path} + } +} + +func (r replacementPathResolver) childAtPathSegment(node, segment any) (any, bool) { + switch node := node.(type) { + case map[string]any: + key, ok := segment.(string) + if !ok { + return nil, false + } + child, ok := node[key] + return child, ok + case []any: + index, ok := r.pathIndex(segment) + if !ok || index < 0 || index >= len(node) { + return nil, false + } + return node[index], true + default: + return nil, false + } +} + +// pathIndex accepts both JSON-decoded (float64) numeric path segments +// and hand-built integer segments that may be used in tests. +func (replacementPathResolver) pathIndex(segment any) (int, bool) { + switch segment := segment.(type) { + case int: + return segment, true + case float64: + index := int(segment) + return index, float64(index) == segment + default: + return 0, false + } +} + +func logResourceReplacements(replacements []replacementLogEntry, sink logSink) { + if len(replacements) == 0 { + return + } + + // Sort a copy so the log output is deterministic without mutating + // the caller's slice. + logs := make([]replacementLogEntry, len(replacements)) + copy(logs, replacements) + sort.Slice(logs, func(i, j int) bool { + return logs[i].resource < logs[j].resource + }) + + sink.ProvisionLog(proto.LogLevel_WARN, "Resource replacements:") + for _, replacement := range logs { + sink.ProvisionLog( + proto.LogLevel_WARN, fmt.Sprintf(" -/+ %s (replace)", replacement.resource)) + + if len(replacement.paths) == 0 { + sink.ProvisionLog( + proto.LogLevel_WARN, " ~ replacement reason unavailable") + continue + } + + // Use a copy so we don't mutate the replacement entry. + paths := make([]string, len(replacement.paths)) + copy(paths, replacement.paths) + sort.Strings(paths) + + for _, path := range paths { + vals, ok := replacement.values[path] + if ok { + sink.ProvisionLog( + proto.LogLevel_WARN, fmt.Sprintf(" ~ %s: %s -> %s (forces replacement)", + path, + formatReplacementValue(vals.before), + formatReplacementValue(vals.after), + ), + ) + continue + } + + sink.ProvisionLog( + proto.LogLevel_WARN, fmt.Sprintf(" ~ %s (forces replacement)", path), + ) + } + } +} + +func formatReplacementValue(value replacementValue) string { + if !value.valid { + return "(unavailable)" + } + return value.text +} diff --git a/provisioner/terraform/resource_replacements_internal_test.go b/provisioner/terraform/resource_replacements_internal_test.go index 4cca4ed396..a0a2f3deba 100644 --- a/provisioner/terraform/resource_replacements_internal_test.go +++ b/provisioner/terraform/resource_replacements_internal_test.go @@ -5,15 +5,17 @@ import ( tfjson "github.com/hashicorp/terraform-json" "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/provisionersdk/proto" ) -func TestFindResourceReplacements(t *testing.T) { +func TestFindResourceReplacementsWithPaths(t *testing.T) { t.Parallel() cases := []struct { name string plan *tfjson.Plan - expected resourceReplacements + expected resourceReplacementPaths }{ { name: "nil plan", @@ -66,8 +68,10 @@ func TestFindResourceReplacements(t *testing.T) { Address: "resource1", Type: "coder_resource", Change: &tfjson.Change{ - Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, - ReplacePaths: []interface{}{"path1"}, + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []interface{}{ + []interface{}{"path1"}, + }, }, }, }, @@ -81,13 +85,15 @@ func TestFindResourceReplacements(t *testing.T) { Address: "resource1", Type: "example_resource", Change: &tfjson.Change{ - Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, - ReplacePaths: []interface{}{"path1"}, + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []interface{}{ + []interface{}{"path1"}, + }, }, }, }, }, - expected: resourceReplacements{ + expected: resourceReplacementPaths{ "resource1": {"path1"}, }, }, @@ -99,13 +105,16 @@ func TestFindResourceReplacements(t *testing.T) { Address: "resource1", Type: "example_resource", Change: &tfjson.Change{ - Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, - ReplacePaths: []interface{}{"path1", "path2"}, + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []interface{}{ + []interface{}{"path1"}, + []interface{}{"path2"}, + }, }, }, }, }, - expected: resourceReplacements{ + expected: resourceReplacementPaths{ "resource1": {"path1", "path2"}, }, }, @@ -125,7 +134,7 @@ func TestFindResourceReplacements(t *testing.T) { }, }, }, - expected: resourceReplacements{ + expected: resourceReplacementPaths{ "resource1": {"path.to.key"}, }, }, @@ -137,29 +146,36 @@ func TestFindResourceReplacements(t *testing.T) { Address: "resource1", Type: "example_resource", Change: &tfjson.Change{ - Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, - ReplacePaths: []interface{}{"path1"}, + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []interface{}{ + []interface{}{"path1"}, + }, }, }, { Address: "resource2", Type: "example_resource", Change: &tfjson.Change{ - Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, - ReplacePaths: []interface{}{"path2", "path3"}, + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []interface{}{ + []interface{}{"path2"}, + []interface{}{"path3"}, + }, }, }, { Address: "resource3", Type: "coder_example", Change: &tfjson.Change{ - Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, - ReplacePaths: []interface{}{"ignored_path"}, + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []interface{}{ + []interface{}{"ignored_path"}, + }, }, }, }, }, - expected: resourceReplacements{ + expected: resourceReplacementPaths{ "resource1": {"path1"}, "resource2": {"path2", "path3"}, }, @@ -170,7 +186,498 @@ func TestFindResourceReplacements(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - require.EqualValues(t, tc.expected, findResourceReplacements(tc.plan)) + require.EqualValues(t, tc.expected, findResourceReplacementsWithPaths(tc.plan)) }) } } + +func TestFindAllResourceReplacements(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + plan *tfjson.Plan + expected []replacementLogEntry + }{ + { + name: "nil plan", + }, + { + name: "no resource changes", + plan: &tfjson.Plan{}, + }, + { + name: "resource change with nil change", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + }, + }, + }, + }, + { + name: "non-replacement action", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionUpdate}, + }, + }, + }, + }, + }, + { + name: "coder_* types are ignored", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "coder_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + }, + }, + }, + }, + }, + { + name: "pathless replacement is included", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + }, + }, + }, + }, + expected: []replacementLogEntry{ + {resource: "resource1"}, + }, + }, + { + name: "replacement paths are formatted", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + ReplacePaths: []any{ + []any{"ami"}, + []any{"root_block_device", 0, "volume_size"}, + }, + }, + }, + }, + }, + expected: []replacementLogEntry{ + { + resource: "resource1", + paths: []string{ + "ami", + "root_block_device.0.volume_size", + }, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + actual := findAllResourceReplacements(tc.plan) + if tc.expected == nil { + require.Empty(t, actual) + return + } + + require.EqualValues(t, tc.expected, actual) + }) + } +} + +func TestHasResourceReplacement(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + plan *tfjson.Plan + expected bool + }{ + { + name: "nil plan", + }, + { + name: "pathless replacement", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + }, + }, + }, + }, + expected: true, + }, + { + name: "coder replacement is ignored", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "coder_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + }, + }, + }, + }, + }, + { + name: "non-replacement action", + plan: &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "resource1", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionUpdate}, + }, + }, + }, + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + require.Equal(t, tc.expected, hasResourceReplacement(tc.plan)) + }) + } +} + +func TestLogResourceReplacements(t *testing.T) { + t.Parallel() + + logr := &mockLogger{} + logResourceReplacements([]replacementLogEntry{ + {resource: "z_resource", paths: []string{"name"}}, + {resource: "a_resource", paths: []string{"root_block_device.0.volume_size", "ami"}}, + }, logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ a_resource (replace)"}, + {Level: proto.LogLevel_WARN, Output: " ~ ami (forces replacement)"}, + {Level: proto.LogLevel_WARN, Output: " ~ root_block_device.0.volume_size (forces replacement)"}, + {Level: proto.LogLevel_WARN, Output: " -/+ z_resource (replace)"}, + {Level: proto.LogLevel_WARN, Output: " ~ name (forces replacement)"}, + }, logr.logs) +} + +func TestLogResourceReplacementsIncludesValues(t *testing.T) { + t.Parallel() + + plan := &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "example_resource.changed", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + Before: map[string]any{ + "ami": "ami-old", + "ebs_block_device": []any{ + map[string]any{ + "volume_size": float64(100), + }, + }, + "root_block_device": []any{ + map[string]any{ + "volume_size": float64(30), + }, + }, + }, + After: map[string]any{ + "ami": "ami-new", + "ebs_block_device": []any{ + map[string]any{ + "volume_size": float64(200), + }, + }, + "root_block_device": []any{ + map[string]any{ + "volume_size": float64(60), + }, + }, + }, + ReplacePaths: []any{ + []any{"ami"}, + []any{"ebs_block_device", float64(0), "volume_size"}, + []any{"root_block_device", 0, "volume_size"}, + }, + }, + }, + }, + } + + logr := &mockLogger{} + logResourceReplacements(findAllResourceReplacements(plan), logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.changed (replace)"}, + {Level: proto.LogLevel_WARN, Output: ` ~ ami: "ami-old" -> "ami-new" (forces replacement)`}, + {Level: proto.LogLevel_WARN, Output: " ~ ebs_block_device.0.volume_size: 100 -> 200 (forces replacement)"}, + {Level: proto.LogLevel_WARN, Output: " ~ root_block_device.0.volume_size: 30 -> 60 (forces replacement)"}, + }, logr.logs) +} + +func TestLogResourceReplacementsFormatsComplexValuesAsJSON(t *testing.T) { + t.Parallel() + + plan := &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "example_resource.complex", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + Before: map[string]any{ + "subject": []any{ + map[string]any{ + "common_name": "old", + "country": nil, + }, + }, + }, + After: map[string]any{ + "subject": []any{ + map[string]any{ + "common_name": "new", + "country": nil, + }, + }, + }, + ReplacePaths: []any{ + []any{"subject"}, + }, + }, + }, + }, + } + + logr := &mockLogger{} + logResourceReplacements(findAllResourceReplacements(plan), logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.complex (replace)"}, + {Level: proto.LogLevel_WARN, Output: ` ~ subject: [{"common_name":"old","country":null}] -> [{"common_name":"new","country":null}] (forces replacement)`}, + }, logr.logs) +} + +func TestLogResourceReplacementsIncludesPartialValues(t *testing.T) { + t.Parallel() + + plan := &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "example_resource.partial", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + Before: map[string]any{ + "before_only": "old-value", + }, + After: map[string]any{ + "after_only": "new-value", + }, + ReplacePaths: []any{ + []any{"before_only"}, + []any{"after_only"}, + }, + }, + }, + }, + } + + logr := &mockLogger{} + logResourceReplacements(findAllResourceReplacements(plan), logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.partial (replace)"}, + {Level: proto.LogLevel_WARN, Output: ` ~ after_only: (unavailable) -> "new-value" (forces replacement)`}, + {Level: proto.LogLevel_WARN, Output: ` ~ before_only: "old-value" -> (unavailable) (forces replacement)`}, + }, logr.logs) +} + +func TestLogResourceReplacementsIncludesPathlessReplacements(t *testing.T) { + t.Parallel() + + logr := &mockLogger{} + logResourceReplacements([]replacementLogEntry{ + {resource: "example_resource.pathless"}, + }, logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.pathless (replace)"}, + {Level: proto.LogLevel_WARN, Output: " ~ replacement reason unavailable"}, + }, logr.logs) +} + +func TestLogResourceReplacementsRedactsSensitiveValues(t *testing.T) { + t.Parallel() + + plan := &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "example_resource.sensitive", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + Before: map[string]any{ + "secret": "old-secret-value", + }, + After: map[string]any{ + "secret": "new-secret-value", + }, + BeforeSensitive: map[string]any{ + "secret": true, + }, + AfterSensitive: map[string]any{ + "secret": true, + }, + ReplacePaths: []any{ + []any{"secret"}, + }, + }, + }, + }, + } + + logr := &mockLogger{} + logResourceReplacements(findAllResourceReplacements(plan), logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.sensitive (replace)"}, + {Level: proto.LogLevel_WARN, Output: " ~ secret: (sensitive value) -> (sensitive value) (forces replacement)"}, + }, logr.logs) +} + +func TestLogResourceReplacementsRedactsParentPathsWithSensitiveChildren(t *testing.T) { + t.Parallel() + + plan := &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "example_resource.sensitive_child", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + Before: map[string]any{ + "subject": []any{ + map[string]any{ + "common_name": "old-secret-value", + "organization": "Coder", + }, + }, + }, + After: map[string]any{ + "subject": []any{ + map[string]any{ + "common_name": "new-secret-value", + "organization": "Coder", + }, + }, + }, + BeforeSensitive: map[string]any{ + "subject": []any{ + map[string]any{ + "common_name": true, + }, + }, + }, + AfterSensitive: map[string]any{ + "subject": []any{ + map[string]any{ + "common_name": true, + }, + }, + }, + // Terraform can report both a parent path and a nested + // child path as replacement-causing, while only marking + // the child value sensitive. + ReplacePaths: []any{ + []any{"subject"}, + []any{"subject", 0, "common_name"}, + }, + }, + }, + }, + } + + logr := &mockLogger{} + logResourceReplacements(findAllResourceReplacements(plan), logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.sensitive_child (replace)"}, + {Level: proto.LogLevel_WARN, Output: " ~ subject: (sensitive value) -> (sensitive value) (forces replacement)"}, + {Level: proto.LogLevel_WARN, Output: " ~ subject.0.common_name: (sensitive value) -> (sensitive value) (forces replacement)"}, + }, logr.logs) +} + +func TestLogResourceReplacementsIncludesUnknownValues(t *testing.T) { + t.Parallel() + + plan := &tfjson.Plan{ + ResourceChanges: []*tfjson.ResourceChange{ + { + Address: "example_resource.unknown", + Type: "example_resource", + Change: &tfjson.Change{ + Actions: tfjson.Actions{tfjson.ActionDelete, tfjson.ActionCreate}, + Before: map[string]any{ + "id": "old-id", + }, + After: map[string]any{ + "id": nil, + }, + AfterUnknown: map[string]any{ + "id": true, + }, + ReplacePaths: []any{ + []any{"id"}, + }, + }, + }, + }, + } + + logr := &mockLogger{} + logResourceReplacements(findAllResourceReplacements(plan), logr) + + require.Equal(t, []*proto.Log{ + {Level: proto.LogLevel_WARN, Output: "Resource replacements:"}, + {Level: proto.LogLevel_WARN, Output: " -/+ example_resource.unknown (replace)"}, + {Level: proto.LogLevel_WARN, Output: ` ~ id: "old-id" -> (known after apply) (forces replacement)`}, + }, logr.logs) +}