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) +}