Skip to content

feat(cli): show reason and property diff when a resource is replaced#425

Merged
JeroenSoeters merged 2 commits into
mainfrom
fix/cli-show-replacement-reason
Apr 22, 2026
Merged

feat(cli): show reason and property diff when a resource is replaced#425
JeroenSoeters merged 2 commits into
mainfrom
fix/cli-show-replacement-reason

Conversation

@naxty
Copy link
Copy Markdown
Contributor

@naxty naxty commented Apr 22, 2026

Summary

When formae apply plans a replacement (destroy + recreate) because a createOnly property changed, the plan preview now shows which immutable property(ies) triggered the replacement and their old→new values — rendered with the same diff tree used for in-place updates.

Before:

~ replace resource my-queue
    of type FakeAWS::SQS::Queue

After:

~ replace resource my-queue
    of type FakeAWS::SQS::Queue
    because these immutable properties changed:
      - FifoQueue: false → true

Approach

The triggering patch ops were previously computed and then discarded by filterCreateOnlyFields after needsReplacement was set. They're now preserved separately and plumbed through to the CLI:

  • patch.GeneratePatch now returns the stripped createOnly ops as a second json.RawMessage.
  • ResourceUpdate (internal) and apimodel.ResourceUpdate (API) gain ReplacementPatchDocument as operation-level metadata.
  • The factory attaches it to the delete half of the replace pair; the renderer's group coalescer merges it onto the display update and adds an OperationReplace branch that calls the existing FormatPatchDocument.

Scope / Non-scope

  • Scoped to plan-preview output (formatSimulatedResourceUpdate). Live apply progress (formatResourceUpdate) is unchanged — it's deliberately terse.
  • Plugin boundary untouched. Plugins never see the new field; they still receive a plain delete + create.
  • Core Resource model untouched. The field lives on ResourceUpdate, not on pkg/model/resource.go, because the replacement reason is operation metadata, not resource state. This keeps datastore resource persistence and secret-redaction paths untouched.
  • The 6 existing replacement call sites in resource_update_generator.go (target-replace / dependency-driven replace) pass nil — they're not property-triggered, so there's no diff to show. The renderer's empty-check guard keeps their output identical to today.

Test Plan

  • Unit: patch_document_test.goTestGeneratePatch asserts the returned replacement ops contain exactly the createOnly paths
  • Unit: resource_update_generator_patch_test.go — extended the existing VPC-CIDR replace test to assert the delete half carries ReplacementPatchDocument, create half and implicit-delete do not
  • Unit: renderer_test.go — new TestRenderSimulation_Replacement_ShowsReason renders a replace and asserts the "because these immutable properties changed:" header + property name appear
  • Existing TestFormatHumanReadableStatus_Replacement continues to pass (no replacement patch → bare header, as today)
  • E2E: tests/e2e/go/replace_test.go (AWS path) — simulate before apply, assert ReplacementPatchDocument on the delete half references RoleName (the createOnly field in the fixture). Runs against real AWS; not executed locally.
  • Full build + targeted test packages pass: patch, resource_update, resource_persister, metastructure, cli/renderer.

When formae plans to replace a resource because a createOnly (immutable)
property changed, the plan preview now shows *which* property(ies)
triggered the replacement, with old→new values, rendered with the same
diff tree used for in-place updates:

    ~ replace resource my-queue
        of type FakeAWS::SQS::Queue
        because these immutable properties changed:
          - FifoQueue: false → true

The triggering patch ops — previously discarded by filterCreateOnlyFields
after the needsReplacement decision — are now preserved on the delete
half of the replace pair as ResourceUpdate.ReplacementPatchDocument
(operation-level metadata, NOT a field on Resource). The renderer
group-coalescer merges it onto the display update.

Scope is the plan-preview path only. Live apply output is unchanged.
The plugin boundary, Resource model, and datastore resource persistence
are untouched.
Comment thread pkg/api/model/types.go Outdated
OldStackName string `json:"OldStackName,omitempty"`
Operation string `json:"Operation"`
PatchDocument json.RawMessage `json:"PatchDocument,omitempty"`
ReplacementPatchDocument json.RawMessage `json:"ReplacementPatchDocument,omitempty"`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we use the existing PatchDocument field?

Address review feedback on PR #425: the second patch field's name —
ReplacementPatchDocument — read as a near-duplicate of PatchDocument and
obscured what it actually carries. Rename to CreateOnlyPatch, which
mirrors PatchDocument and describes the content (createOnly-field ops
that force a replacement).

GeneratePatch now returns (mutableOps, createOnlyOps, err); the caller
checks len(createOnlyOps) > 0 to decide on a replace, which drops the
redundant needsReplacement bool. Also drops the now-unused
containsCreateOnlyFields helper and fixes the gofmt issue on
ResourceUpdate that was tripping golangci-lint.
@JeroenSoeters JeroenSoeters merged commit a2a5277 into main Apr 22, 2026
42 of 43 checks passed
@JeroenSoeters JeroenSoeters deleted the fix/cli-show-replacement-reason branch April 22, 2026 23:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants