fix(comp): exclude XRs being deleted from impact analysis - #457
Conversation
0fad271 to
e877070
Compare
e877070 to
f8b5b8d
Compare
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>
f8b5b8d to
d507a4b
Compare
|
Note Posted by Claude Code, an AI agent, on behalf of @jcogilvie. Amended ( 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:
I'd over-applied the precedent from #388. That one exists for Verified empirically rather than from memory that the stdlib accessor handles every reachable input correctly — absent, explicit One small behavioral improvement fell out: the timestamp in The Not rebasing #458/#460 onto this yet, per your call — they'd need redoing after this merges anyway. |
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>
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>
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>
Description of your changes
An XR carrying a
metadata.deletionTimestampis on Crossplane's teardown path: the composite reconciler deletes its composed resources rather than composing them. Such an XR will never adopt theCompositionRevisionthe 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✓ unchangedrow (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.classifyXRnow drops these XRs with a newFilterReason"deleting", following the reason-extensible model established for #388:--include-manual— deletion supersedes both, the same way a revision-selector mismatch does.AffectedResourcesSummary.FilteredByDeletion, kept separate from the other filter counters so the breakdown survives in default-discovery mode where individual impacts aren't surfaced.--resourcemode, surfaced as a"filtered"impact entry carrying thedeletionTimestampasfilterDetail, 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; thexrmirror case is deferredPer the repo's triage-across-commands convention I checked
xrtoo. The right behavior there is not an exclusion —xrdiffs 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), sologger.Infois 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-hocFprintfto 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
deletionTimestampreaderunstructured.GetDeletionTimestampsilently 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 — soDeletionTimestamptreats absent / explicitly-null / empty as "not deleting" and a non-string value as an error. (Explicitnullis 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 setsynthetically. That message comes from client-go's dynamicApplyguard, which rejects any object withmetadata.managedFieldspopulated, reached viaCalculateDiff→DryRunApplyfor the XR itself. The only path that can feed an unstripped object there isdiff_calculator.go's nested-XR branch (desiredXR = renderedXR), since the root branch goes throughmergedXR.SetManagedFields(nil); I probed that branch and could not reach it onmaineither.v0.10.0..HEADhas 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
TestDeletionTimestamppins the reader's semantics (absent / null / empty / valid / wrong-type).TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicygains five cases: dropped-as-deleting, deletion-beats-Manual-even-with---include-manual, deletion-beats-a-matching-selector, null-timestamp-kept, and malformed-errors.TestDefaultDiffProcessor_warnIfDeletingcovers thexrwarning including the nested-XR and malformed cases.Test_allFilteredMessagecovers the now-combinatorial all-filtered summary line across all seven reason combinations.CompositionDiffExcludesDeletingXRs(default discovery, JSON — assertsfilteredByDeletion: 1and that only the live XR is evaluated),ResourceFilterSurfacesDeletingXR(--resourcemode — asserts thefilteredentry with reasondeleting).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:
earthly -P +reviewableto ensure this PR is ready for review.README.md; architecture indesign/design-doc-cli-diff.mdand its diagrams).Need help with this checklist? See the cheat sheet.
🤖 Generated with Claude Code