Skip to content

fix(comp): exclude XRs being deleted from impact analysis - #457

Merged
jcogilvie merged 1 commit into
mainfrom
fix/452-exclude-deleting-xrs
Sep 11, 2026
Merged

fix(comp): exclude XRs being deleted from impact analysis#457
jcogilvie merged 1 commit into
mainfrom
fix/452-exclude-deleting-xrs

Conversation

@jcogilvie

@jcogilvie jcogilvie commented Sep 10, 2026

Copy link
Copy Markdown
Collaborator

Description of your changes

An XR carrying a metadata.deletionTimestamp is on Crossplane's teardown path: the composite reconciler deletes its composed resources rather than composing them. Such an XR will never adopt the CompositionRevision the diffed composition would produce, and rendering it yields zero composed resources to diff against — so including it in impact analysis is meaningless by construction. At best it shows up as a misleading ✓ unchanged row (plus a wasted function render); at worst, as reported in #452, it fails outright and aborts the whole run with a non-zero exit code.

classifyXR now drops these XRs with a new FilterReason "deleting", following the reason-extensible model established for #388:

  • Evaluated before the update-policy rules, and not re-included by --include-manual — deletion supersedes both, the same way a revision-selector mismatch does.
  • Counted in a new AffectedResourcesSummary.FilteredByDeletion, kept separate from the other filter counters so the breakdown survives in default-discovery mode where individual impacts aren't surfaced.
  • In --resource mode, surfaced as a "filtered" impact entry carrying the deletionTimestamp as filterDetail, so a user who explicitly names a deleting composite is told why it was skipped instead of getting silence. A long-stuck timestamp is also the diagnostic signal for a wedged deletion.

Scoped to comp; the xr mirror case is deferred

Per the repo's triage-across-commands convention I checked xr too. The right behavior there is not an exclusion — xr diffs exactly the resource the user named, so filtering it out would leave them with no output — but a user-visible advisory that the cluster copy is terminating.

crossplane-diff has no advisory channel to carry that. The default logger is logging.NewNopLogger() (main.go:177), so logger.Info is invisible without --verbose — which is why the one existing warning in the codebase, "Resource already belongs to another composite. Applying this diff will assume ownership!" (resource_manager.go:163), is in practice never seen. Structured output has no non-error equivalent either. An ad-hoc Fprintf to stderr would sit outside the dual-emission contract the project documents for errors, so it is deferred to a follow-up that adds a real channel rather than invented here. Tracked in #459.

Accuracy-first deletionTimestamp reader

unstructured.GetDeletionTimestamp silently reads a present-but-wrong-typed value as absent. Since this decides whether a resource is evaluated at all, a wrong answer would quietly change the diff — so DeletionTimestamp treats absent / explicitly-null / empty as "not deleting" and a non-string value as an error. (Explicit null is how round-tripped Kubernetes YAML spells "unset" for timestamp fields, hence the distinction.)

A note on the reported error message

I was not able to reproduce the reporter's exact cannot apply an object with managed fields already set synthetically. That message comes from client-go's dynamic Apply guard, which rejects any object with metadata.managedFields populated, reached via CalculateDiffDryRunApply for the XR itself. The only path that can feed an unstripped object there is diff_calculator.go's nested-XR branch (desiredXR = renderedXR), since the root branch goes through mergedXR.SetManagedFields(nil); I probed that branch and could not reach it on main either. v0.10.0..HEAD has no changes to the four relevant files, so the reporter is running exactly this logic — the difference is likely their crossplane render version.

This change is justified on its own merits regardless (a deleting XR's impact analysis is meaningless), and it makes that code path unreachable for deleting XRs, which is the reported trigger. If the error resurfaces for a non-deleting XR, that's a separate managed-fields-stripping bug worth its own issue.

Testing

  • Unit: TestDeletionTimestamp pins the reader's semantics (absent / null / empty / valid / wrong-type). TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy gains five cases: dropped-as-deleting, deletion-beats-Manual-even-with---include-manual, deletion-beats-a-matching-selector, null-timestamp-kept, and malformed-errors. TestDefaultDiffProcessor_warnIfDeleting covers the xr warning including the nested-XR and malformed cases. Test_allFilteredMessage covers the now-combinatorial all-filtered summary line across all seven reason combinations.
  • Integration: CompositionDiffExcludesDeletingXRs (default discovery, JSON — asserts filteredByDeletion: 1 and that only the live XR is evaluated), ResourceFilterSurfacesDeletingXR (--resource mode — asserts the filtered entry with reason deleting).
  • Both integration cases and the three deletion unit cases were mutation-tested: reverting the production branch makes each fail.
  • Test-harness addition: deleteAfterSetup (issues a delete after setup; fixtures carry a finalizer so envtest leaves them Terminating, which is how a mid-deletion resource is observed with no controllers running).

Fixes #452

I have:

  • Read and followed Crossplane's contribution process.
  • Run earthly -P +reviewable to ensure this PR is ready for review.
  • Added or updated unit tests.
  • Added or updated integration or e2e tests.
  • Updated documentation as needed (user-facing behavior in README.md; architecture in design/design-doc-cli-diff.md and its diagrams).

Need help with this checklist? See the cheat sheet.

🤖 Generated with Claude Code

An XR carrying a metadata.deletionTimestamp is on Crossplane's teardown
path: the composite reconciler deletes its composed resources rather than
composing them. Such an XR will never adopt the CompositionRevision the
diffed composition would produce, and rendering it yields no composed
resources to diff against — so including it in impact analysis produces a
misleading "unchanged" row at best, and at worst fails outright and aborts
the whole run with a non-zero exit code.

classifyXR now drops these XRs with a new FilterReason "deleting",
evaluated before the update-policy rules and not re-included by
--include-manual (deletion supersedes both, the same way a
revision-selector mismatch does). They are counted in a new
AffectedResourcesSummary.FilteredByDeletion and, in --resource mode,
surfaced as a "filtered" impact entry carrying the deletionTimestamp so a
user who names a deleting composite is told why it was skipped. The
timestamp is normalized to UTC/RFC3339 for display, since metav1.Time
renders machine-local by default.

Note this reads deletionTimestamp with the standard unstructured accessor,
unlike compositionUpdatePolicy and compositionRevisionSelector, which use
bespoke readers that hard-error on a present-but-wrong-typed value. Those
are spec fields — user-authored, and only as well-formed as the XRD schema
requires — so a malformed value is reachable there and must not be silently
defaulted. deletionTimestamp is apiserver-owned core metadata, not settable
on create and always serialized as RFC3339 or absent, and both call sites
receive only apiserver-sourced objects. A defensive reader would add an
unreachable error path threaded through three functions.

This is scoped to comp. The mirror case in `xr` — where the cluster copy
of the named XR is terminating — wants a user-visible advisory rather than
an exclusion, since `xr` diffs exactly the resource the user asked about.
crossplane-diff has no advisory channel today (the default logger is a
no-op logger, so the one existing logger.Info warning is invisible, and
structured output has no non-error equivalent), and inventing an ad-hoc
stderr path here would sit outside the dual-emission contract the project
documents for errors. Deferred to a follow-up that adds a real one.

Fixes #452

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
@jcogilvie
jcogilvie force-pushed the fix/452-exclude-deleting-xrs branch from f8b5b8d to d507a4b Compare September 11, 2026 20:04
@jcogilvie

Copy link
Copy Markdown
Collaborator Author

Note

Posted by Claude Code, an AI agent, on behalf of @jcogilvie.

Amended (d507a4b, on top of your rebase onto main): replaced the bespoke DeletionTimestamp reader with the standard unstructured.GetDeletionTimestamp().

The reader was over-defensive and I couldn't justify it on review. Its stated purpose was to hard-error on a present-but-wrong-typed value rather than silently reading it as absent — but that case isn't reachable:

  • Both call sites receive only apiserver-sourced objects: classifyXR gets XRs from FindComposites listings, and the xr-side advisory (deferred to Add a warning channel: non-fatal advisories are invisible today #459) gets existingXRFromCluster from FetchCurrentObject.
  • metadata.deletionTimestamp is apiserver-owned core metadata — not settable on create, always serialized as RFC3339 or absent. No user-supplied manifest reaches either site.

I'd over-applied the precedent from #388. That one exists for compositionUpdatePolicy and compositionRevisionSelector, and it's right there — those are spec fields, user-authored and only as well-formed as the XRD's schema requires, so a malformed value is genuinely reachable and must not be silently defaulted. deletionTimestamp isn't in that category, and the difference is now recorded in the design doc so the two readers don't look inconsistent.

Verified empirically rather than from memory that the stdlib accessor handles every reachable input correctly — absent, explicit null, and empty string all yield nil; only the unreachable wrong-type case differs. So the net change is -111/+25: the reader, its test, an unreachable table case, and an error return that was threaded through classifyXRpartitionXRsByUpdatePolicy all go away.

One small behavioral improvement fell out: the timestamp in filterDetail is now normalized to UTC/RFC3339. metav1.Time renders machine-local by default, which would have made the surfaced detail differ between runs on differently-configured machines.

The --include-manual interaction and the null-timestamp case are still covered; only the unreachable wrong-type case was dropped.

Not rebasing #458/#460 onto this yet, per your call — they'd need redoing after this merges anyway.

jcogilvie added a commit that referenced this pull request Sep 11, 2026
crossplane-diff had no way to tell a user something important that was not
a failure. OutputError is dual-emitted correctly but is semantically an
error and drives a non-zero exit; the diff body cannot say anything *about*
the diff; and the logger defaulted to logging.NewNopLogger(), replaced only
by --verbose.

Because of that last point, four warnings the code already tried to emit
were invisible in every normal run, and each one changes what a user should
conclude:

  - a composed resource belonging to a different composite (applying the
    diff would take ownership)
  - a nested XR whose composition could not be found (it will compose
    nothing — often intentional, but also what a typo'd compositeTypeRef
    or an unapplied composition looks like)
  - function credentials that could not be fetched (the render may not
    reflect reality)
  - leftover function containers

WarningLogger is a logging.Logger decorator that treats every Info call as
a user-facing advisory: it writes a WARNING: line to stderr immediately and
collects an OutputWarning for structured output, while Debug passes through
untouched. It rides on the logger because a warning must be raisable
wherever it is discovered — a client, the resource manager, the render loop
— and Logger is the only cross-cutting dependency already threaded to all
of them. Every ComponentFactories entry takes its collaborators plus
exactly one Logger, so a parallel sink parameter would churn every
constructor, factory signature, and With*Factory option for no semantic
gain.

Reading Info as "advisory" is a formalization, not an invention: it is what
the surviving Info sites already mean, the three routine ones having been
demoted to Debug here (their messages were already duplicated on stdout, in
an OutputError, or were routine lifecycle).

Structured output gains a top-level warnings[] on both commands, carrying a
message plus the emitting call site's key/value pairs as a context map, so
consumers read individual values rather than parsing prose. Warnings
deliberately do not affect the exit code — gate on errors[]. Their stderr
half is written when raised rather than at render time, so a warning
survives a run that fails before rendering and appears in step with the
work that produced it; DiffRenderer.RenderDiffs therefore takes warnings
for structured output only and the human renderer ignores them.

The logger is wrapped at both binding sites. --verbose rebinds it, so
wrapping only in main() would silently drop the channel at exactly the
verbosity where a user is asking for more output.

This also restores the `xr` deleting-XR advisory that was removed from
PR #457 for want of a channel: unlike comp, which excludes deleting XRs
from impact analysis, xr diffs exactly the resource the user named, so it
emits the diff and warns that the comparison is against a resource that is
going away.

Fixes #459

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
@jcogilvie
jcogilvie merged commit 7340867 into main Sep 11, 2026
13 checks passed
@jcogilvie
jcogilvie deleted the fix/452-exclude-deleting-xrs branch September 11, 2026 20:14
jcogilvie added a commit that referenced this pull request Sep 11, 2026
crossplane-diff had no way to tell a user something important that was not
a failure. OutputError is dual-emitted correctly but is semantically an
error and drives a non-zero exit; the diff body cannot say anything *about*
the diff; and the logger defaulted to logging.NewNopLogger(), replaced only
by --verbose.

Because of that last point, four warnings the code already tried to emit
were invisible in every normal run, and each one changes what a user should
conclude:

  - a composed resource belonging to a different composite (applying the
    diff would take ownership)
  - a nested XR whose composition could not be found (it will compose
    nothing — often intentional, but also what a typo'd compositeTypeRef
    or an unapplied composition looks like)
  - function credentials that could not be fetched (the render may not
    reflect reality)
  - leftover function containers

WarningLogger is a logging.Logger decorator that treats every Info call as
a user-facing advisory: it writes a WARNING: line to stderr immediately and
collects an OutputWarning for structured output, while Debug passes through
untouched. It rides on the logger because a warning must be raisable
wherever it is discovered — a client, the resource manager, the render loop
— and Logger is the only cross-cutting dependency already threaded to all
of them. Every ComponentFactories entry takes its collaborators plus
exactly one Logger, so a parallel sink parameter would churn every
constructor, factory signature, and With*Factory option for no semantic
gain.

Reading Info as "advisory" is a formalization, not an invention: it is what
the surviving Info sites already mean, the three routine ones having been
demoted to Debug here (their messages were already duplicated on stdout, in
an OutputError, or were routine lifecycle).

Structured output gains a top-level warnings[] on both commands, carrying a
message plus the emitting call site's key/value pairs as a context map, so
consumers read individual values rather than parsing prose. Warnings
deliberately do not affect the exit code — gate on errors[]. Their stderr
half is written when raised rather than at render time, so a warning
survives a run that fails before rendering and appears in step with the
work that produced it; DiffRenderer.RenderDiffs therefore takes warnings
for structured output only and the human renderer ignores them.

The logger is wrapped at both binding sites. --verbose rebinds it, so
wrapping only in main() would silently drop the channel at exactly the
verbosity where a user is asking for more output.

This also restores the `xr` deleting-XR advisory that was removed from
PR #457 for want of a channel: unlike comp, which excludes deleting XRs
from impact analysis, xr diffs exactly the resource the user named, so it
emits the diff and warns that the comparison is against a resource that is
going away.

Fixes #459

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
jcogilvie added a commit that referenced this pull request Sep 11, 2026
crossplane-diff had no way to tell a user something important that was not
a failure. OutputError is dual-emitted correctly but is semantically an
error and drives a non-zero exit; the diff body cannot say anything *about*
the diff; and the logger defaulted to logging.NewNopLogger(), replaced only
by --verbose.

Because of that last point, four warnings the code already tried to emit
were invisible in every normal run, and each one changes what a user should
conclude:

  - a composed resource belonging to a different composite (applying the
    diff would take ownership)
  - a nested XR whose composition could not be found (it will compose
    nothing — often intentional, but also what a typo'd compositeTypeRef
    or an unapplied composition looks like)
  - function credentials that could not be fetched (the render may not
    reflect reality)
  - leftover function containers

WarningLogger is a logging.Logger decorator that treats every Info call as
a user-facing advisory: it writes a WARNING: line to stderr immediately and
collects an OutputWarning for structured output, while Debug passes through
untouched. It rides on the logger because a warning must be raisable
wherever it is discovered — a client, the resource manager, the render loop
— and Logger is the only cross-cutting dependency already threaded to all
of them. Every ComponentFactories entry takes its collaborators plus
exactly one Logger, so a parallel sink parameter would churn every
constructor, factory signature, and With*Factory option for no semantic
gain.

Reading Info as "advisory" is a formalization, not an invention: it is what
the surviving Info sites already mean, the three routine ones having been
demoted to Debug here (their messages were already duplicated on stdout, in
an OutputError, or were routine lifecycle).

Structured output gains a top-level warnings[] on both commands, carrying a
message plus the emitting call site's key/value pairs as a context map, so
consumers read individual values rather than parsing prose. Warnings
deliberately do not affect the exit code — gate on errors[]. Their stderr
half is written when raised rather than at render time, so a warning
survives a run that fails before rendering and appears in step with the
work that produced it; DiffRenderer.RenderDiffs therefore takes warnings
for structured output only and the human renderer ignores them.

The logger is wrapped at both binding sites. --verbose rebinds it, so
wrapping only in main() would silently drop the channel at exactly the
verbosity where a user is asking for more output.

This also restores the `xr` deleting-XR advisory that was removed from
PR #457 for want of a channel: unlike comp, which excludes deleting XRs
from impact analysis, xr diffs exactly the resource the user named, so it
emits the diff and warns that the comparison is against a resource that is
going away.

Fixes #459

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
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.

XRs in deleting state are included in impact analysis

1 participant