A standalone tool for visualizing the differences between Crossplane resources in YAML files and their resulting state
when applied to a live Kubernetes cluster. Similar to kubectl diff but with specific enhancements for Crossplane
resources, particularly Composite Resources (XRs) and their composed resources.
The crossplane-diff tool helps you:
- Preview changes before applying Crossplane resources to a cluster
- Visualize differences at multiple levels: both the XR itself and all downstream composed resources
- Analyze composition impact showing how composition changes affect existing XRs in the cluster
- Support complex compositions including functions, requirements, and environment configurations
- Handle both Crossplane v1 and v2 resource definitions, including namespaced composite resources
- Detect resources that would be removed by your changes, by the rule Crossplane's garbage collector applies: a
composed resource is removed only when the composite that controls it no longer renders a resource under its
crossplane.io/composition-resource-name. A resource a new revision renders at a newapiVersionis updated in place, not removed
You can install the latest version of crossplane-diff automatically using the provided install script:
curl -sL "https://raw.githubusercontent.com/crossplane-contrib/crossplane-diff/main/install.sh" | shThe script detects your operating system and CPU architecture and downloads the latest release from GitHub.
You can set the VERSION environment variable before running the script:
curl -sL "https://raw.githubusercontent.com/crossplane-contrib/crossplane-diff/main/install.sh" | VERSION=v0.3.1 shgit clone https://github.com/crossplane-contrib/crossplane-diff.git
cd crossplane-diff
earthly -P +buildThe xr command supports both Composite Resources (XRs) and Claims. You can pass either type directly as input.
# Show changes that would result from applying an XR from a file
crossplane-diff xr xr.yaml
# Show changes for a Claim
crossplane-diff xr claim.yaml
# Show changes from stdin
cat xr.yaml | crossplane-diff xr -
# Process multiple files (can mix XRs and Claims)
crossplane-diff xr xr1.yaml claim1.yaml xr2.yaml
# Use a specific kubeconfig context
crossplane-diff xr xr.yaml --context staging
# Show changes in a compact format with minimal context
crossplane-diff xr xr.yaml --compact
# Disable color output
crossplane-diff xr xr.yaml --no-color
# Output in JSON format (for CI/CD pipelines or programmatic processing)
crossplane-diff xr xr.yaml --output json
# Output in YAML format
crossplane-diff xr xr.yaml -o yaml
# Ignore specific fields in diffs (useful for filtering out metadata like ArgoCD annotations)
crossplane-diff xr xr.yaml \
--ignore-paths 'metadata.annotations[argocd.argoproj.io/tracking-id]' \
--ignore-paths 'metadata.labels[argocd.argoproj.io/instance]'
# Show eventual state with function-sequencer (all stages, not just first)
crossplane-diff xr xr.yaml --eventual-state
# Don't dry-run create added resources, so no 'create' permission is needed. Their +++ diffs
# then show the rendered output rather than what the apiserver would store (CRD defaults are
# predicted locally; built-in defaults and mutating admission are missing), and say so via
# dryRun.skipReason in JSON/YAML output.
# Also available on comp. See Required Permissions.
crossplane-diff xr xr.yaml --dry-run-on=existingIf the XR's counterpart in the cluster is being deleted (it has a metadata.deletionTimestamp),
xr still emits the diff — you asked about that specific resource — but raises a warning noting that
the comparison is against a resource that is going away. comp takes the opposite approach and
excludes such composites from impact analysis entirely, since a deleting composite can never adopt
the composition change being diffed.
Crossplane, not you, writes an XR's compositionRef, compositionRevisionRef and (by defaulting)
compositionUpdatePolicy, so your manifest normally leaves them out, and applying a manifest that
leaves a field out does not remove it. xr therefore diffs an existing XR, or nested XR, whose input
omits one of them with the value the cluster copy holds, and then applies the XR's CRD defaults. So:
- A composite with a
Manualupdate policy is rendered against the revision it is pinned to, not the latest one. To preview moving it to another revision, setcompositionRevisionRefin your input. If the pinned revision no longer exists, the diff fails, as the reconcile would. - A composite that selects its composition with
compositionSelectorkeeps the composition Crossplane already chose; Crossplane does not re-select. - A new composite whose XRD sets
defaultCompositionUpdatePolicy: Manualis rendered asManual, so acompositionRevisionRefin its input is honoured. - A composite is rendered with its
compositionRevisionRefnaming the revision it is rendered from, because Crossplane's composite reconciler writes that ref before it composes. So anAutomaticcomposite whose ref still names an older revision is rendered from the latest revision and sees that revision's name, and a new composite gets the ref Crossplane would give it. Only the render sees this; the XR's own diff is still computed from your manifest as written.
A claim is rendered as its backing XR with the claim's spec synced in, the way Crossplane's claim
controller syncs it. Under Automatic the backing XR keeps its own compositionRevisionRef,
whatever the claim carries; under Manual the claim's ref, if it sets one, is propagated, and
otherwise the backing XR's pin is kept. A claim whose spec.resourceRef names an XR that does not
exist is diffed as though Crossplane will create that XR, with a warning. Any other failure to fetch
a claim's backing XR fails the diff.
Some conditions are worth telling you about without invalidating the diff or stopping the run. These
are written to stderr as WARNING: lines, and — in --output json/yaml — also carried in a
top-level warnings[] array so CI tooling can read them:
WARNING: Resource already belongs to another composite. Applying this diff will assume ownership! (currentOwner=other-xr, newOwner=my-xr, resource=Bucket/my-bucket)
{
"warnings": [
{
"message": "Some function credential secrets could not be fetched from cluster",
"context": {
"composition": "xbuckets.example.org",
"missing": "crossplane-system/graph-creds",
"hint": "Use --function-credentials to provide secrets that don't exist on cluster"
}
}
]
}Warnings deliberately do not affect the exit code — gate CI on errors[], not warnings[]. They
are emitted when raised rather than at the end of the run, so a warning is still reported if a later
step fails, and appears in step with the work that produced it. Today they cover: a composed resource
that belongs to a different composite (applying would take ownership; for a claim, the owner it is
compared against is the claim's backing XR, whose name Crossplane puts on a claim's composed
resources), a nested XR whose composition
could not be found (it will compose nothing), function credentials that could not be fetched (the
render may not reflect reality), leftover function containers, a CompositionRevision whose name could
not be predicted (see predictedRevisionName under
Structured Output), an added resource that could not be verified against the
apiserver (see Required Permissions), a composed resource the XR still references
but which no longer exists in the cluster (see below), the deleting-XR case above, and a claim whose backing
XR does not exist (its composed resources are diffed as though Crossplane will create that XR).
A warning identical to one already raised — same message and same context — is reported once,
not once per occurrence. That matters for conditions that are a property of a composition rather than
of an individual resource: an unfetchable credential secret on a composition affecting thirty XRs is
one line and one warnings[] entry, not thirty. Warnings that genuinely differ per occurrence carry
the distinguishing detail in context (the ownership warning names the resource, for instance) and so
are still reported for every occurrence.
The unverified-addition warning is the exception to "emitted when raised". It is a summary, built
once the diffs are complete from each added resource's dryRun object: one warning per GVK, namespace
and distinct cluster cause, with context carrying gvk, namespace, the cause (under cause, or
reason for an authorization denial) and count, the number of resources behind it as a string. So
forty unverifiable ConfigMaps in one namespace are one warning with "count": "40", while a second,
different cause is a second warning rather than being hidden behind the first. Which resources were
affected is in each change's dryRun object. An unverified addition's defaults are predicted against
the version it was requested at only. When its CRD stores it at a different version, that version's
defaults and any conversion through it may change what the cluster stores, and the diff shows neither.
Its warning then adds "they are stored at a version other than the one requested, whose defaults and
conversion the cluster may apply but their diffs do not", with a versions context key such as
"requested v1alpha1, stored v1". This holds even under --dry-run-on=existing, whose additions are
otherwise not warned about. Its message then begins "did not verify added resources against the
apiserver (--dry-run-on=existing)" and has no cause key. Built-in types, single-version CRDs and
resources requested at their storage version never get this note. A failed XR's diffs are withheld, so no summary is shown
for it: the summary says how far to trust the diffs that are shown, and the XR's error is reported in
errors[] and on stderr; re-running after fixing it gives the full summary. (Before this was derived
from the diffs, a failed XR's warning could appear incidentally, because it was raised mid-calculation.)
For comp the summary is run-wide, in the
top-level warnings[], not per composition.
Because the summary is built at the end, these warnings follow all the others: on stderr they are written
just before any errors, and in warnings[] they come after the warnings raised during the run, grouped
and sorted by GVK, namespace, reason and cause, rather than in the order the resources were diffed.
Two things narrow when the credential warning fires:
- It reports only the secrets still missing after
--function-credentialsis taken into account, so supplying the absent secret yourself is not warned about. - Only a genuinely absent (
NotFound) secret is a warning. If a referenced secret cannot be read — RBAC denial, a transport failure, an undecodable payload — the run fails instead. The tool cannot know whether that credential would have changed the render, and emitting a diff that might not reflect reality is worse than refusing to emit one.
The same split applies to the composed resources an existing XR references. One that is NotFound
(deleted out of band since the XR recorded it) is genuinely absent, so it is left out of the observed
state the render is given and a warning names it; a composition that still produces it will show it as
an addition. One that cannot be read — RBAC denial, a timeout, a transport failure — fails that XR's
diff, naming the resource and the cause: the resource may well exist, and reporting it as an addition
would be a confidently wrong answer. This applies to xr and to every XR comp analyzes; with several
XRs, the others are still diffed.
The comp command analyzes how composition changes affect all Composite Resources (both XRs and Claims) that use the composition. The tool automatically discovers and displays impacts on both direct XRs and any Claims that reference them.
# Show impact of updated composition on all XRs and Claims using it
crossplane-diff comp updated-composition.yaml
# Show impact of multiple composition changes
crossplane-diff comp comp1.yaml comp2.yaml comp3.yaml
# Use a specific kubeconfig context
crossplane-diff comp updated-composition.yaml --context production
# Show impact only on XRs in a specific namespace
crossplane-diff comp updated-composition.yaml -n production
# Limit impact analysis to specific composites — useful for fast PR-time validation
# against a representative subset of XRs/Claims, or for debugging against a single composite.
# Format is [namespace/]name; bare name means cluster-scoped (v1 XRs and v2 cluster-scoped XRs).
crossplane-diff comp updated-composition.yaml --resource=default/my-claim
crossplane-diff comp updated-composition.yaml --resource=default/xr-1,default/xr-2
# Note: --resource cannot be combined with --namespace. Composites that would not adopt the diffed
# composition are surfaced with status "filtered" and a "filterReason": Manual update policy
# ("manual_policy") unless --include-manual is passed, a compositionRevisionSelector that does
# not match the composition's labels ("revision_selector_mismatch"), or the composite being
# deleted ("deleting").
# Include XRs with Manual update policy (pinned revisions).
# Note: --include-manual only affects Manual-policy XRs. An Automatic XR whose
# compositionRevisionSelector does not match the composition's labels stays filtered even with this
# flag, because it would not select the resulting revision. To preview against a different revision
# label (e.g. a "preview" channel), edit the composition's labels in your CI runner (jq/yq) and run
# comp again — the composition file's labels are the authoritative prediction of the new revision.
crossplane-diff comp updated-composition.yaml --include-manual
# Choose the smallest composition change that triggers per-composite impact analysis. The default is
# any-change: the composites are evaluated whenever anything Crossplane hashes into the composition's
# identity differs — labels and annotations as well as spec.
#
# Evaluate affected composites even when the composition is identical to the cluster's.
# By default that analysis is skipped: applying an unchanged composition creates no new
# CompositionRevision, so nothing would adopt it, and any downstream delta found would be caused
# by something else (drift, convergence lag, or a modeling artifact of this tool) while being
# presented as this composition's impact. Opt in for a pre-edit "is my cluster converged?" baseline.
crossplane-diff comp unchanged-composition.yaml --analyze-on=always
# Only evaluate composites when the composition's *spec* changes, skipping the render-per-composite
# cost for a metadata-only edit. Note that such an edit still creates a new CompositionRevision that
# Automatic composites re-point to, so this asserts none of your compositions can observe a revision's
# identity (via the XR's compositionRevisionRef, or a compositionRevisionSelector matching revision
# labels). Either way revisionImpact in JSON/YAML output names the revision it would create
# (predictedRevisionName). Note that at the default setting the composites are rendered with that
# revision seeded, so a template reading its name produces a real diff rather than a stale value.
crossplane-diff comp updated-composition.yaml --analyze-on=spec-change
# Collapse each changed composition to a single change-marker line (human output only;
# JSON/YAML keeps full detail), keeping the affected XRs and their downstream diffs
crossplane-diff comp updated-composition.yaml --minimize-composition
# Ignore specific fields in diffs (useful for filtering out metadata like ArgoCD annotations)
crossplane-diff comp updated-composition.yaml \
--ignore-paths 'metadata.annotations[argocd.argoproj.io/tracking-id]' \
--ignore-paths 'metadata.labels[argocd.argoproj.io/instance]'
# Output in JSON format (for CI/CD pipelines or programmatic processing)
crossplane-diff comp updated-composition.yaml --output json
# Output in YAML format
crossplane-diff comp updated-composition.yaml -o yaml
# Show eventual state with function-sequencer (all stages, not just first)
crossplane-diff comp updated-composition.yaml --eventual-statecrossplane-diff xr [<files> ...] [flags]
Arguments:
[<files> ...] YAML files containing Composite Resources (XRs) or Claims to diff.
Flags:
-h, --help Show context-sensitive help.
--verbose Print verbose logging statements.
--context=STRING Kubernetes context to use (defaults to current context).
-o, --output=diff Output format: diff (human-readable), json, or yaml.
--no-color Disable colorized output.
--compact Show compact diffs with minimal context.
--max-nested-depth=10 Maximum depth for nested XR recursion.
--max-iterations=20 Maximum render iterations for requirements resolution
or eventual-state simulation. Increase for complex
pipelines that need more cycles to converge.
--timeout=1m How long to run before timing out.
--ignore-paths=STRING,... Paths to ignore in diffs. Supports simple paths
(e.g., 'metadata.annotations') and map key paths with
bracket notation (e.g., 'metadata.annotations[key]').
Can be specified multiple times.
--function-credentials=PATH Path to YAML file or directory containing Secret
resources to pass as function credentials. Overrides
auto-fetched credentials from cluster.
--function-registry-override=STRING
Override the registry for all function images
(e.g., 'my-company.registry.io'). Useful when
pulling functions from a mirror or private
registry.
--eventual-state Show eventual state after all reconciliation cycles
complete. Useful with function-sequencer which hides
later stage resources until earlier stages become Ready.
--max-recv-message-size=INT Max gRPC message size (MB) for render function
containers (4MB if undefined) ($MAX_RECV_MESSAGE_SIZE).
--dry-run-on=all Which resources to verify against the apiserver with a
dry run: "all" also dry-run creates added resources so
their diffs include server-side defaulting and admission
(needs the 'create' verb; degrades per-resource without
it), "existing" only resources already in the cluster.
--crossplane-version=VERSION
Pin the crossplane render version; the docker engine
pulls xpkg.crossplane.io/crossplane/crossplane:<version>.
Minimum v2.3.4 (older renders drop cluster-observed
resources; see Prerequisites). Mutually exclusive with
--crossplane-image.
--crossplane-image=IMAGE Override the full crossplane render image reference
(e.g. for a private mirror). Held to the same v2.3.4
minimum when its tag is a semantic version; a
reference with no comparable version (a digest, a
floating tag, no tag at all) is accepted with a
warning. Mutually exclusive with
--crossplane-version.
Note: XR namespaces are read directly from the YAML files being diffed, not from command-line flags.
Large composites: crossplane render starts functions with the function-sdk-go default 4MB gRPC receive limit and does not apply the cluster's DeploymentRuntimeConfig. Very large XRs (many/large observed resources) can exceed this and fail with ResourceExhausted: received message larger than max. Set --max-recv-message-size (or MAX_RECV_MESSAGE_SIZE) to raise it; crossplane-diff injects the value as the MAX_RECV_MESSAGE_SIZE container env var. This only takes effect on functions whose image reads that variable.
Render version: When neither --crossplane-version nor --crossplane-image is set, rendering uses the floating xpkg.crossplane.io/crossplane/crossplane:stable tag. Pin --crossplane-version for reproducible diffs or to hold a known-good version; --crossplane-image targets a mirrored/air-gapped registry. Both are floor-checked against the v2.3.4 minimum as far as they can be: a pinned version always, and an image reference whenever its tag parses as a semantic version (so …/crossplane:v2.3.3 is rejected). A reference that carries no comparable version — pinned by digest, tagged stable or latest, or with no tag at all — cannot be checked and is accepted with a warning rather than refused, since that is exactly the shape a private mirror or a digest pin takes. A bare --crossplane-version 2.3.4 is accepted and normalized to the v-prefixed tag that upstream actually publishes.
Ignored Paths: By default, metadata.annotations[kubectl.kubernetes.io/last-applied-configuration] is always hidden from the diff, since it is just a serialization of the object itself. Note that it is hidden from the output only: for comp, a composition differing solely in that annotation still counts as changed, because applying it creates a new CompositionRevision — unless the cluster's annotation already describes the file being diffed (and the file does not carry the annotation itself), in which case re-applying it writes nothing and the composition is unchanged. Additional paths can be specified with --ignore-paths. This is useful for filtering out metadata added by tools like ArgoCD (e.g., tracking IDs, sync waves) that shouldn't affect diff results. The --ignore-paths flag applies uniformly across all output modes: the human diff, JSON, and YAML output all strip ignored fields, and summary counts are computed after ignore-filtering so a resource whose only changes are in ignored fields is not counted as modified.
crossplane-diff comp [<files> ...] [flags]
Arguments:
[<files> ...] YAML files containing updated Composition(s).
Flags:
-h, --help Show context-sensitive help.
--verbose Print verbose logging statements.
--context=STRING Kubernetes context to use (defaults to current context).
-o, --output=diff Output format: diff (human-readable), json, or yaml.
--no-color Disable colorized output.
--compact Show compact diffs with minimal context.
--max-nested-depth=10 Maximum depth for nested XR recursion.
--max-iterations=20 Maximum render iterations for requirements resolution
or eventual-state simulation. Increase for complex
pipelines that need more cycles to converge.
--timeout=1m How long to run before timing out.
-n, --namespace="" Namespace to find Composites (empty = all namespaces).
--include-manual Include Composites with Manual update policy (default:
only Automatic policy Composites)
--minimize-composition Collapse each changed composition to a single
change-marker line instead of the full YAML diff.
Affects human-readable output only; JSON/YAML keeps
full detail. Errors and no-change compositions still
print in full.
--ignore-paths=STRING,... Paths to ignore in diffs. Supports simple paths
(e.g., 'metadata.annotations') and map key paths with
bracket notation (e.g., 'metadata.annotations[key]').
Can be specified multiple times.
--function-credentials=PATH Path to YAML file or directory containing Secret
resources to pass as function credentials. Overrides
auto-fetched credentials from cluster.
--function-registry-override=STRING
Override the registry for all function images
(e.g., 'my-company.registry.io'). Useful when
pulling functions from a mirror or private
registry.
--eventual-state Show eventual state after all reconciliation cycles
complete. Useful with function-sequencer which hides
later stage resources until earlier stages become Ready.
--max-recv-message-size=INT Max gRPC message size (MB) for render function
containers (4MB if undefined) ($MAX_RECV_MESSAGE_SIZE).
--resource=STRING,... Limit impact analysis to specific composites in
[namespace/]name format. Repeatable or comma-separated.
Bare name means cluster-scoped. Mutually exclusive with
--namespace. Composites matched by --resource but excluded
(because they would not adopt the diffed composition) are
reported in the impact analysis with status "filtered" and a
"filterReason": "manual_policy" (use --include-manual to
evaluate them instead), "revision_selector_mismatch" (their
compositionRevisionSelector does not match the composition's
labels), or "deleting" (the composite is being deleted).
--include-manual does not re-include the latter two.
--analyze-on=STRING Smallest composition change that triggers per-composite impact
analysis: "spec-change", "any-change", or "always". Defaults to
"any-change", which evaluates the composites whenever anything
Crossplane hashes into the composition's identity differs —
labels and annotations as well as spec. "spec-change" skips the
render-per-composite cost for a metadata-only edit, which still
creates a new CompositionRevision that Automatic composites
re-point to; choosing it asserts none of your compositions can
observe a revision's identity. "always" also evaluates a
composition identical to its in-cluster version, which creates no
revision and so cannot change anything — useful for a pre-edit
convergence baseline. This is a cost knob, not a correctness mode:
"revisionImpact" is reported at every setting, and composites left
unevaluated are marked with "impactAnalysisSkipped": true in
structured output, so "we did not look" stays distinguishable from
"we looked and found nothing".
--analyze-unchanged Deprecated: equivalent to --analyze-on=always. Still
honoured, but passing it together with a conflicting --analyze-on
value is an error.
--dry-run-on=all Which resources to verify against the apiserver with a
dry run: "all" also dry-run creates added resources so
their diffs include server-side defaulting and admission
(needs the 'create' verb; degrades per-resource without
it), "existing" only resources already in the cluster.
--crossplane-version=VERSION
Pin the crossplane render version; the docker engine
pulls xpkg.crossplane.io/crossplane/crossplane:<version>.
Minimum v2.3.4 (older renders drop cluster-observed
resources; see Prerequisites). Mutually exclusive with
--crossplane-image.
--crossplane-image=IMAGE Override the full crossplane render image reference
(e.g. for a private mirror). Held to the same v2.3.4
minimum when its tag is a semantic version; a
reference with no comparable version (a digest, a
floating tag, no tag at all) is accepted with a
warning. Mutually exclusive with
--crossplane-version.
Note: The diff subcommand is deprecated. Use xr instead.
Ignored Paths: By default, metadata.annotations[kubectl.kubernetes.io/last-applied-configuration] is always hidden from the diff, since it is just a serialization of the object itself. Note that it is hidden from the output only: for comp, a composition differing solely in that annotation still counts as changed, because applying it creates a new CompositionRevision — unless the cluster's annotation already describes the file being diffed (and the file does not carry the annotation itself), in which case re-applying it writes nothing and the composition is unchanged. Additional paths can be specified with --ignore-paths. This is useful for filtering out metadata added by tools like ArgoCD (e.g., tracking IDs, sync waves) that shouldn't affect diff results. The --ignore-paths flag applies uniformly across all output modes: the human diff, JSON, and YAML output all strip ignored fields, and summary counts are computed after ignore-filtering so a resource whose only changes are in ignored fields is not counted as modified.
-
A running Kubernetes cluster with Crossplane installed
-
kubectlconfigured to access your cluster -
Appropriate RBAC permissions (see Required Permissions)
-
A
crossplanerender image/binary of v2.3.4 or newer (the tool renders viaxpkg.crossplane.io/crossplane/crossplane:stableby default, which satisfies this when freshly pulled — see below). Older images silently drop cluster-observed composed resources from the render pipeline. Because removal detection walks the live resource tree independently and marks every composed resource the render no longer desires as removed, dropping observed resources produces spurious removals: resources that exist and will keep existing are reported as going away. An image older still — anything predating thecrossplane internal rendersubcommand — fails outright withcrossplane: error: unexpected argument internal;crossplane-diffappends a re-pull hint to that error.A locally cached
:stabledoes not refresh on its own: the render engine pulls the image only when it is absent, so whatever was first pulled on a machine stays pinned there indefinitely. If diffs show removals you cannot explain, rundocker pull xpkg.crossplane.io/crossplane/crossplane:stable.
The tool performs the following steps:
- Load resources from YAML files or stdin
- Find matching compositions for each Composite Resource
- Render resources using the same composition pipeline as Crossplane
- Resolve requirements iteratively (environment configs, external resources)
- Propagate namespaces from XRs to managed resources (for Crossplane v2)
- Validate resources against their schemas and enforce scope constraints
- Calculate diffs by comparing rendered resources against current cluster state
- Display formatted output showing what would change
When a composed resource is itself a Composite Resource, the tool recurses into it and diffs its own composed
resources too, so a single xr or comp invocation shows the whole tree.
--max-nested-depth (default 10) bounds that recursion: it is the number of levels of nesting permitted
below the Composite Resource you named. A composed XR one level deeper than the bound fails the diff with
maximum nesting depth exceeded rather than silently truncating the tree — an incomplete tree would report
the omitted resources as unchanged, which is worse than an error. Raise the flag if you legitimately nest
more deeply than the default. A composition cycle (XR-A composes XR-B, which composes XR-A) has no natural
stopping point, so this bound is what makes it terminate.
Note: up to and including v0.10.0 the depth counter was never incremented, so
--max-nested-depthhad no effect and a cyclic composition recursed until the process ran out of stack. If you had lowered the flag expecting it to limit descent, it now actually does.
Some Crossplane functions require credentials to operate (e.g., function-msgraph for Microsoft Graph API access). These credentials are typically referenced in composition pipelines via credentials[].secretRef.
By default, crossplane-diff automatically fetches credentials referenced in compositions from the cluster:
# In your composition
spec:
pipeline:
- step: call-graph-api
functionRef:
name: function-msgraph
credentials:
- name: azure-creds
source: Secret
secretRef:
namespace: crossplane-system
name: msgraph-credentialsWhen diffing an XR that uses this composition, the tool will automatically fetch msgraph-credentials from the cluster and pass it to the function.
For cases where credentials don't exist in the cluster (e.g., when using workload identity that's injected at runtime, or testing locally), you can provide credentials via the --function-credentials flag:
# Provide credentials from a file
crossplane-diff xr xr.yaml --function-credentials ./secrets/credentials.yaml
# Provide credentials from a directory (all YAML files containing Secrets)
crossplane-diff xr xr.yaml --function-credentials ./secrets/The credentials file should contain Kubernetes Secret resources:
apiVersion: v1
kind: Secret
metadata:
name: msgraph-credentials
namespace: crossplane-system
type: Opaque
data:
credentials: <base64-encoded-credentials>Note: CLI-provided credentials take precedence over auto-fetched credentials from the cluster. This allows you to override cluster secrets for testing or development purposes.
This tool fully supports both Crossplane v1 and v2, including:
- Cluster-scoped XRDs: Traditional cluster-wide resources
- Namespaced XRDs: Resources confined to specific namespaces for better isolation
- Automatic scope detection: Determines whether XRDs are cluster or namespace scoped
- Namespace propagation: Ensures managed resources inherit appropriate namespaces
- Scope validation: Enforces rules like "namespaced XRs cannot own cluster-scoped managed resources"
Note: Namespace information is read directly from the XR definitions in your YAML files, not from command-line flags.
The tool reads from the cluster to gather definitions and current state, and round-trips every resource it is about to diff through the apiserver with dryRun=All to compute the post-apply form — picking up server-side defaulting and mutating-admission output, and surfacing any rejection as a diff-time error. Two different requests are used, because they are two different operations:
- Resources that already exist are server-side applied (
patch). - Additions are created (
create), which is what--dry-run-on=all(the default) does. Server-side apply cannot serve this path: it is a PUT-shaped request to a named path, and an addition that relies onmetadata.generateNamehas no name yet.
Either request carries exactly what the composition rendered (or, for the XR or claim, your manifest as written), never defaults the tool predicted itself. Under server-side apply every field in a request claims ownership of it, so sending a locally predicted default would report a field another manager owns as about to change when applying would leave it alone. The apiserver applies the defaults itself.
Although nothing is ever persisted, the apiserver authorizes a dry run exactly as it would the real request, so read-only access is not sufficient.
For the definition and configuration plane, which is only fetched:
apiextensions.k8s.io:customresourcedefinitionsapiextensions.crossplane.io:compositeresourcedefinitions,compositions,compositionrevisions,environmentconfigspkg.crossplane.io:functions
On every API group containing resources you want to diff — XRs, Claims, and any managed resource GVKs the compositions render. The patch verb is what authorizes the SSA dry-run against a resource that already exists.
This one is not optional and does not degrade. An existing resource's diff depends on the apiserver's merge result — that is how field removals under server-side apply are detected — so a diff computed without it would be wrong rather than merely less detailed. Without patch, the run fails with a message naming the resource and the missing verb.
On the same API groups. The create verb authorizes the dry-run create behind --dry-run-on=all, which is what makes a +++ diff show the resource as the apiserver would store it rather than as the render pipeline emitted it. Two things are only visible this way:
- Defaults on built-in Kubernetes types. For CRD-backed types the CRD's
default:values appear either way: from the apiserver, or — where the dry run is skipped or degrades — predicted locally from the CRD. A built-in type (aConfigMap, aDeployment) has no CRD to read them from, so its defaults —spec.strategy.type,spec.template.spec.restartPolicy,imagePullPolicy, and so on — only appear once the apiserver has seen the object. - Mutating admission output, for any type. Nothing local can predict what a mutating webhook will do.
Unlike patch, this one degrades per-resource rather than failing the run. A resource that could not be verified falls back to the rendered output, with its CRD's default: values predicted locally, and is marked in structured output with a dryRun object saying why (see Structured Output); they are summarised as one warning per GVK + namespace + distinct cause, counting the resources behind it, and a second, different cause in the same namespace is reported too rather than hidden behind the first (see Warnings). The reasons are:
dryRun.skipReason |
Cause |
|---|---|
forbidden |
The authorizer says these credentials may not create this kind here. Nothing was learned about the resource; this is a property of the credentials, not a finding about the resource. |
webhookUnavailable |
The apiserver could not complete the admission chain — classically an unreachable webhook with failurePolicy: Fail. dryRun.detail carries the apiserver's own message. |
namespaceNotFound |
The resource's target namespace does not exist yet, so the apiserver would not admit it. |
disabled |
You passed --dry-run-on=existing. No warning is raised, because you asked for it. The exception is additions stored at a version other than the one requested (dryRun.storageVersion); see Warnings. |
namespaceNotFound is a degradation rather than a finding on purpose. A Namespace and the resources inside it are routinely applied together, so the namespace being absent when you diff says nothing about whether the apply will succeed — crossplane-diff cannot know whether that Namespace is part of the same apply, and refusing to diff would break previewing a bootstrap. If you do want to treat it as a failure, gate on that skipReason value.
If the cluster rejects an addition — a validating webhook, a ValidatingAdmissionPolicy, a ResourceQuota — that is a finding, not a degradation, and it is reported as one: exit code 2 with a typed validationFailures[] entry (see Exit Codes). The difference is whether the verdict describes the object: a refusal based on the object's content will still hold when you apply, whereas a missing namespace is a precondition you may be about to satisfy.
A rejection means the cluster refused the request as crossplane-diff made it. Every dry run, for additions and modifications alike, is sent under the identity running crossplane-diff. That is often not the identity that makes the real request. Crossplane creates and updates composed resources under its own service account, and the XR or claim itself is frequently applied by something else, such as a GitOps controller. Admission policies keyed on who is asking rather than what is being asked for (for example, a rule that only Crossplane may modify managed resources) can therefore reject a dry run that the real apply would pass, and that reports as an exit-2 rejection the apply would not hit. The apiserver's message is shown with the resource, so such a rejection is recognisable. If it affects you, scope the policy to let the identity that runs your diffs through, or exempt dry-run requests: the admission request carries dryRun: true, which ValidatingAdmissionPolicy CEL, Kyverno and Gatekeeper policies can all match on. Exempting dry runs means previews skip that policy entirely, so prefer the identity-scoped fix where you can.
A 403 alone cannot tell those two apart: the apiserver returns it both when RBAC denies the verb and when quota or a webhook refuses the object. Rather than pattern-match the apiserver's prose, the tool resolves the ambiguity with a SelfSubjectAccessReview, consulted only after a 403 has actually come back. create on selfsubjectaccessreviews is granted to system:authenticated by default through the built-in system:basic-user ClusterRole, so this normally needs no rule of its own.
--dry-run-on is available on both xr and comp:
all(default) — also dry-run creates additions, as described above.existing— only dry-runs resources already in the cluster, which is the behaviour of releases before this flag existed. Needs nocreatepermission and costs one apiserver round-trip fewer per added resource. Additions carrydryRun.skipReason: "disabled".
This is a depth/cost knob, not an accuracy toggle: at either setting every unverified resource says so in structured output, so "we did not check" stays distinguishable from "we checked and this is what the cluster would store".
Only required if you use the auto-fetch credentials feature. Skip this if you always supply credentials via --function-credentials or your compositions don't use credentialed functions.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: crossplane-diff-runner
rules:
# Definition plane — read-only
- apiGroups: [apiextensions.k8s.io]
resources: [customresourcedefinitions]
verbs: [get, list, watch]
- apiGroups: [apiextensions.crossplane.io]
resources: [compositeresourcedefinitions, compositions, compositionrevisions, environmentconfigs]
verbs: [get, list, watch]
- apiGroups: [pkg.crossplane.io]
resources: [functions]
verbs: [get, list, watch]
# Diffable resources — read + patch + create. List the provider/XR API groups you
# use; you can combine them in one rule (as below) or split them into separate rules.
# patch authorizes the dry-run apply against existing resources and is required.
# create authorizes the dry-run create behind --dry-run-on=all; drop it and
# additions degrade per-resource (dryRun.skipReason: "forbidden") instead of failing.
- apiGroups:
- example.org # your XR groups
- s3.aws.upbound.io # provider groups
- s3.aws.m.upbound.io # namespaced variants (Crossplane v2)
resources: ['*']
verbs: [get, list, watch, patch, create]
# Optional: function credential auto-fetch
- apiGroups: ['']
resources: [secrets]
verbs: [get]If your compositions render built-in Kubernetes types (a ConfigMap, a Deployment, a Service), add a rule
covering those groups too — the core group is '', apps is apps, and so on. Built-in types are exactly the case where
the dry-run create earns its keep, since their defaults cannot be read from a CRD.
All crossplane-diff commands (xr, comp, version) resolve their target
cluster the same way, following the standard CLI convention:
$KUBECONFIGenv var, if set.~/.kube/config, if present.- Otherwise, fall back to the pod's in-cluster ServiceAccount (with a one-line warning on stderr).
The --context flag overrides the kubeconfig's current-context.
A common pattern is to run crossplane-diff inside a pod (for example, a
GitHub Actions runner) to post PR comments using the pod's existing RBAC.
Two supported modes:
- Use the pod's ServiceAccount — simplest: build an image without
~/.kube/config, don't setKUBECONFIG. The tool falls back to in-cluster automatically. - Target a different cluster from inside the pod — set up a kubeconfig
(e.g. via
kubectl config use-context <arn>) andcrossplane-diffwill honor it, including--contextoverrides. This matches the behavior ofkubectland other Kubernetes CLIs, and is why a~/.kube/configthat exists in the pod always takes precedence over the pod's ServiceAccount.
crossplane-diff runs composition functions as Docker containers via the host's Docker socket. When crossplane-diff
itself runs inside a container (a GitHub Actions container job, a dind setup, etc.), the function containers it spawns
land on the default Docker bridge network and are unreachable from the caller's network. Set
CROSSPLANE_DIFF_DOCKER_NETWORK to the network the caller is on, and the tool will stamp every function package with
the render.crossplane.io/runtime-docker-network annotation so the Crossplane render runtime joins each function
container to that network.
GitHub Actions example:
jobs:
diff:
runs-on: ubuntu-latest
container:
image: my-org/crossplane-diff:latest
env:
CROSSPLANE_DIFF_DOCKER_NETWORK: ${{ job.container.network }}
steps:
- run: crossplane-diff xr xr.yamlThe env var is read on every GetFunctionsForComposition call, so it works correctly even with the cached function
provider (cached function packages are re-annotated on cache hits). If the env var is unset, the tool leaves the
annotation alone — appropriate for the common case of running crossplane-diff directly on a host with Docker
installed. Any value the user has already set on a function package is preserved.
The default output follows familiar diff conventions with colorized output (unless disabled):
+++ Resource/new-resource-(generated)
+ apiVersion: nop.crossplane.io/v1alpha1
+ kind: NopResource
+ metadata:
+ name: new-resource
+ spec:
+ forProvider:
+ field: value
---
--- XNopResource/removed-resource
- apiVersion: diff.example.org/v1alpha1
- kind: XNopResource
- spec:
- coolField: goodbye!
~~~
~~~ Resource/modified-resource
metadata:
name: modified-resource
- spec:
- oldValue: something
+ spec:
+ newValue: something-else
---
Summary: 1 added, 1 modified, 1 removedFor CI/CD pipelines or programmatic processing, use --output json or --output yaml:
XR Diff JSON output (crossplane-diff xr xr.yaml -o json):
{
"summary": {
"added": 1,
"modified": 1,
"removed": 0
},
"changes": [
{
"type": "added",
"apiVersion": "nop.crossplane.io/v1alpha1",
"kind": "NopResource",
"name": "new-resource",
"diff": { "spec": { "apiVersion": "...", "kind": "...", "metadata": { ... }, "spec": { ... } } }
},
{
"type": "modified",
"apiVersion": "nop.crossplane.io/v1alpha1",
"kind": "NopResource",
"name": "modified-resource",
"diff": { "old": { ... }, "new": { ... } }
}
],
"xrs": [
{
"xr": { "apiVersion": "example.org/v1", "kind": "XNopResource", "name": "my-xr", "namespace": "default" },
"status": "changed",
"summary": { "added": 1, "modified": 1, "removed": 0 },
"changes": [
{
"type": "added",
"apiVersion": "nop.crossplane.io/v1alpha1",
"kind": "NopResource",
"name": "new-resource",
"diff": { "spec": { ... } }
},
{
"type": "modified",
"apiVersion": "nop.crossplane.io/v1alpha1",
"kind": "NopResource",
"name": "modified-resource",
"diff": { "old": { ... }, "new": { ... } }
}
]
}
]
}Per-input-XR grouping (xrs[]). When you diff more than one XR/claim in a
single invocation, the flat changes[] list gives no indication of which input
XR produced each change. The xrs[] array solves this: it carries one entry per
input XR/claim (in input order), each with the input XR's identity, a status
("changed", "unchanged", or "error"), its own summary, its own
changes[], and — for a failed XR — its own errors[]. This is the
authoritative grouping the tool already computes while rendering each XR's
composed tree, so wrappers no longer need to invoke xr once per resource to
get per-XR output. Unchanged XRs appear with status: "unchanged" and an empty
changes[]; errored XRs appear with status: "error" and their error in the
entry's errors[] (and also in the top-level errors[]).
Deprecation: the top-level flat
changes[]is deprecated in favor ofxrs[]and will be removed in a future major release. The aggregatesummaryand top-levelerrors[]remain. New consumers should readxrs[]; existing consumers ofchanges[]keep working during the deprecation window.
The aggregate summary counts exactly what changes[] lists: each distinct
change once, however many of the XRs you supply reach it, so the same change
reached through two inputs is listed and counted once. Each xrs[] entry
is complete for its own input, so where two inputs' renders reach the same
resource through one controller, it appears under each, and the xrs[].summary
values can sum to more than the top-level summary.
When inputs overlap. The XRs you pass are one change set, each rendered
against the live cluster. xr checks the input set itself first:
- An input identical to an earlier one — the same file passed twice, or CI enumerating one file twice — is diffed once, with a warning. The intent is clear, so the run is not failed.
- The same object twice with different content fails both inputs. Applying both would leave whichever is applied last, and the order inputs are given in (often a glob's) is not a statement of intent. Pass only one of them.
- An input that another input manages fails that input. A claim's backing XR is written by Crossplane's claim controller, and a nested XR by its parent's composition, so passing either alongside its manager would give it a second writer. Pass the claim, or the parent, not the object it manages.
Then, among what the inputs render, a change is identified by
apiVersion/kind/namespace/name, which says nothing about which input produced
it, so xr decides by who would control the resource in the cluster:
- The same resource reached through one controller, rendered identically, is one change reached twice. It is counted once.
- Two XRs that would both control it — a shared
Namespaceor a fixed-name object composed by two XRs — genuinely conflict. Crossplane gives the resource to whichever XR creates it first, and the other fails to reconcile it, however alike their renderings are. No diff predicts that, so the run fails. - Renderings that differ for one resource mean no single diff is correct for both, so the run fails.
- Two XRs sharing a
metadata.generateNamefail too. The API server would give them different names, so in the cluster they would not collide; but they have no names yet, andxrrenders both under the same placeholder, so it cannot tell their resources apart.
These checks treat one object rendered at two API versions (say v1beta1 and
v1) as the same resource, as the API server does.
Within a single XR, a composition that renders the same object twice — under
two composition resource names, or at two API versions — fails that XR, in both
xr and comp: Crossplane applies both renderings in no fixed order, so no
single diff predicts the result.
A rejected input, or a failing overlap, is reported in errors[] (and on
stderr) with the reason that applies, and xr exits with the tool error code,
in every output format. Inputs that are unaffected still get their diffs, and
the structured document is still written, so the error is machine-readable.
An XR supplied with only metadata.generateName has no name yet; it appears
throughout the output — including its xrs[] identity and its human-readable
section header — as <generateName>(generated).
The human-readable (diff) output likewise groups by input XR when more than
one is supplied: each XR gets a === Kind/name === section with its own diffs
and summary, followed by an aggregate Total: … across N XRs (…) footer. A
single-XR invocation renders flat, exactly as before.
Composition Diff JSON output (crossplane-diff comp composition.yaml -o json):
{
"compositions": [
{
"name": "xbuckets.example.org",
"compositionChanges": {
"type": "modified",
"apiVersion": "apiextensions.crossplane.io/v1",
"kind": "Composition",
"name": "xbuckets.example.org",
"diff": { "old": { ... }, "new": { ... } }
},
"revisionImpact": {
"changeScope": "spec",
"createsRevision": true,
"repointedComposites": 4,
"predictedRevisionName": "xbuckets.example.org-a1b2c3d"
},
"affectedResources": {
"total": 6,
"withChanges": 2,
"unchanged": 1,
"withErrors": 1,
"filteredBySelector": 1,
"filteredByDeletion": 1
},
"impactAnalysis": [
{
"apiVersion": "example.org/v1",
"kind": "XBucket",
"name": "bucket-1",
"status": "changed",
"downstreamChanges": {
"summary": { "added": 1, "modified": 1, "removed": 0 },
"changes": [ ... ]
}
},
{
"apiVersion": "example.org/v1",
"kind": "XBucket",
"name": "bucket-2",
"status": "unchanged"
},
{
"apiVersion": "example.org/v1",
"kind": "XBucket",
"name": "bucket-3",
"status": "error",
"error": "render failed: ..."
},
{
"apiVersion": "example.org/v1",
"kind": "XBucket",
"name": "bucket-4",
"status": "filtered",
"filterReason": "revision_selector_mismatch",
"filterDetail": "compositionRevisionSelector {version: 0.0.1} does not match composition labels {version: 0.0.2}"
},
{
"apiVersion": "example.org/v1",
"kind": "XBucket",
"name": "bucket-5",
"status": "filtered",
"filterReason": "deleting",
"filterDetail": "deletionTimestamp: 2026-09-07T11:25:03Z"
}
]
}
]
}The structured output includes:
-
Change types: each entry's
typefield carries the word form — one of"added","modified", or"removed". (Unchanged resources are filtered out of structured output and never appear inchanges[]. The+/~/-symbols appear only in the human-readable diff format described above.) -
Full resource details: apiVersion, kind, name, namespace
-
Diff content: for modifications,
diff.oldanddiff.newcarry the full current/desired resource objects (apiVersion/kind/metadata/spec/status, etc.) — not just the diffing subset. For additions/removals, the full resource object lives underdiff.spec(the JSON key is literallyspecbut the value is the entire resource, not its spec subtree). -
Dry-run fidelity: a
dryRunobject on a change entry says that this resource's desired state did not go through the apiserver, and why. It is absent whenever the resource was verified — that is the success case and the common one, so a consumer checks for the key's presence, not for a value inside it. Removal diffs never carry it either: they are computed straight from cluster state and have no desired state to preview, so read absence as "nothing was skipped" rather than as a positive fidelity guarantee. The fields areperformed(alwaysfalsewhen the object is present — the redundancy keeps the emitted JSON self-describing),skipReason(one of"disabled","forbidden","webhookUnavailable","namespaceNotFound"— see Required Permissions for what each means), anddetail, the cluster's own explanation (theSelfSubjectAccessReview's reason, or the apiserver's error message).storageVersionis set only when the resource's CRD stores it at a version other than the one in itsapiVersion. The locally predicted diff defaults against the requested version only, so it may omit that storage version's defaults and any conversion through it.dryRunlives on the shared per-resource change shape, so it appears inxr'schanges[]andxrs[].changes[]and incomp'simpactAnalysis[].downstreamChanges.changes[]alike:{ "type": "added", "apiVersion": "v1", "kind": "ConfigMap", "name": "new-config", "namespace": "default", "diff": { "spec": { ... } }, "dryRun": { "performed": false, "skipReason": "forbidden", "detail": "not authorized to create configmaps in namespace \"default\"" } } -
Impact analysis (comp only): which XRs are affected by composition changes and their status. When the composition change is smaller than
--analyze-onasked to analyse — by default, a composition identical to its in-cluster version — its composites are not evaluated and the entry carries"impactAnalysisSkipped": true, so a consumer can tell "not evaluated" from "no affected composites found". Raise--analyze-onto evaluate them anyway.A skip withholds only the per-composite renders, and therefore only the changed/unchanged/errored verdict. Composites excluded because they would not adopt the resulting revision (Manual update policy, a non-matching
compositionRevisionSelector, or being deleted) are identified without rendering anything, so they are still reported:affectedResourceskeeps itstotaland itsfilteredByPolicy/filteredBySelector/filteredByDeletionbreakdown, and under--resourcethe named composites still appear inimpactAnalysisas"status": "filtered"entries with theirfilterReason. So"impactAnalysisSkipped": truedoes not implyimpactAnalysisis empty. -
Revision impact (comp only): a
revisionImpactobject per composition, recording what applying it does to CompositionRevisions regardless of whether anything renders differently. It carrieschangeScope("none","metadata"or"spec"— how much of the composition differs, in the terms Crossplane'sComposition.Hash()uses, which covers labels and annotations as well as spec),createsRevision(whether applying the composition produces a new CompositionRevision),repointedComposites(how many composites would adopt that revision and reconcile as a result), andpredictedRevisionName(what that revision would be called). A composite is counted only if it genuinely re-points: it has an Automatic (or defaulted)compositionUpdatePolicy, itscompositionRevisionSelector(if any) selects the resulting revision, and it is not being deleted. A Manual-policy composite is never counted, because it stays pinned by itscompositionRevisionRef—--include-manualkeeps it in the analysis without making it adopt the new revision. Note too that a composite re-pointing is not the same as its rendered output changing; re-pointing alone usually renders identically.revisionImpactis present whenever the composition was compared, including whenimpactAnalysisSkippedis true: that is the point of it, and it is what keeps--analyze-ona cost knob rather than a correctness mode. It is absent for a composition that failed to process, whoseerrorfield says why — the comparison never completed, so neither its scope nor whether it creates a revision was ever determined, and reporting"changeScope": ""with"createsRevision": falsewould assert otherwise. -
Predicted revision name (comp only):
revisionImpact.predictedRevisionNameis<composition>-<hash[:7]>, derived from Crossplane's ownComposition.Hash()— the same value the cluster would produce. Composites are rendered with it seeded onto theircompositionRevisionRef(a claim's onto its backing XR's, which is the ref Crossplane re-points), so a composition template that reads the revision name (templates receive the whole composite, with no field stripping) renders the value it would really get, and a change it causes is detected rather than assumed away. The seed reaches only the render, never the composite's own dry run, so the composites' owncompositionRevisionRefis not shown changing:revisionImpactalready carries that fact once per composition rather than once per composite. A composed resource deriving a value from the revision name is shown, and is precisely the signal this exists to surface.The field is absent when
createsRevisionis true only if the name could not be predicted, which happens when the composition differs from the cluster's copy by nothing but thekubectl.kubernetes.io/last-applied-configurationannotation, and that annotation is stale (it does not describe the file being diffed; when it does, re-applying is a no-op andcreatesRevisionis false). A client-sidekubectl applyderives that annotation's value from the file being applied rather than leaving it alone, so its post-apply value — and therefore the hash and the name — depends on how you apply, not on what you are applying. In that case nothing is seeded, a warning says so, and a revision-observing change would go undetected; apply with--server-side(or via Argo/Flux) to make it predictable. For a composition applied client-side with other edits the name is predicted from the file as supplied, so the suffix may differ from the eventual one — the change is still detected, its predicted value is just imprecise. -
Masked changes (comp only):
"maskedChangesOnly": truesays an absentcompositionChangesdoes not mean the composition is unchanged — it differs only in fields excluded from the diff (your--ignore-paths, or the fields suppressed for readability). Applying it still creates a new CompositionRevision, whichrevisionImpactreports. -
Warnings: A top-level
warningsarray of non-fatal advisories, each with amessageand an optionalcontextmap of the key/value pairs from the emitting call site. Distinct fromerrorsand with no effect on the exit code; see Warnings above. Entries are in the order raised, except the unverified-addition summaries, which come last, sorted, each with a stringcountin itscontext. -
Errors: A top-level
errorsarray ofOutputErrorobjects (see Validation Errors below for the schema and an example), plus per-XRerrorfields inimpactAnalysisfor composition diffs
When validation fails on the input XR or any rendered composed resource, crossplane-diff reports the failure in both human-readable and machine-readable form. Two things land here: local schema validation against the CRD/XRD, and the cluster's own refusal of a resource during the dry run (a validating webhook, a ValidatingAdmissionPolicy, a ResourceQuota). Both answer the same question — "will the cluster accept this?" — so they share the exit-code tier and the structured output field, and are told apart by type (see the FieldValidationError table below).
Exit-code precedence (per DetermineExitCode): any error in the run beats diff detection, so a partially-failed run never returns exit code 3 even if some XRs produced diffs. Among errors, tool errors (exit code 1) beat validation errors (exit code 2). Exit code 2 therefore requires every error in the run to be a validation error. See the Exit Codes table below.
Human-readable output (crossplane-diff xr invalid-xr.yaml):
Per Unix convention, errors go to stderr and diff content goes to stdout. When validation is the only failure, stdout is empty and the structured failure detail appears on stderr, prefixed by an ERROR: <resourceID>: marker:
# stderr
ERROR: XNopResource/invalid-schema-xr: cannot validate resources: ns.diff.example.org/v1alpha1/XNopResource default/invalid-schema-xr:
spec.coolField: Invalid value: "number": ... [schema]
ns.nop.example.org/v1alpha1/XDownstreamResource default/invalid-schema-xr:
spec.forProvider.configData: Invalid value: "boolean": ... [schema]
# stdout
(empty)
The cannot validate resources: prefix is added by DefaultDiffProcessor's errors.Wrap around the inner SchemaValidationError — every local schema validation failure carries that anchor. A cluster rejection reaches the same exit-code tier by a different route (it is raised from diff calculation, not from the validator), so it carries a different anchor: the cluster rejected <Kind>/<name>:, followed by the apiserver's own message. Match on either, or on the structured errors[].validationFailures[], rather than on cannot validate resources: alone.
Each per-resource block starts with a header that includes the resource identity, followed by indented error lines:
- Cluster-scoped resource:
<apiVersion>/<Kind> <name>: - Namespaced resource:
<apiVersion>/<Kind> <namespace>/<name>: - Resource without
metadata.name(e.g. a resource discovered missing a schema before it was named): collapses to just<apiVersion>/<Kind>:
Each indented error line has the shape <message> [<type>], where <type> is one of [schema], [cel], [unknownField], or [defaulting]. A bad value is appended as (got <value>) when it isn't already substring-present in the message. When some inputs in a batched run succeed and others fail validation, the successful diffs appear on stdout and the failing inputs' ERROR: blocks appear on stderr.
This per-resource block form belongs to the local validator. A cluster rejection has nothing to break into field-level lines — the apiserver hands back one message — so it prints as a single ERROR: line and carries no [<type>] suffix. Its "admission" type is visible in structured output only.
Machine-readable output (crossplane-diff xr invalid-xr.yaml --output json):
{
"summary": { "added": 0, "modified": 0, "removed": 0 },
"changes": [],
"errors": [
{
"resourceID": "XNopResource/invalid-schema-xr",
"message": "cannot validate resources: ns.diff.example.org/v1alpha1/XNopResource default/invalid-schema-xr:\n spec.coolField: Invalid value: \"number\": ... [schema]\nns.nop.example.org/v1alpha1/XDownstreamResource default/invalid-schema-xr:\n spec.forProvider.configData: Invalid value: \"boolean\": ... [schema]",
"validationFailures": [
{
"apiVersion": "ns.diff.example.org/v1alpha1",
"kind": "XNopResource",
"name": "invalid-schema-xr",
"namespace": "default",
"status": "invalid",
"errors": [
{
"type": "schema",
"field": "spec.coolField",
"message": "spec.coolField: Invalid value: \"number\": ...",
"value": "number"
}
]
},
{
"apiVersion": "ns.nop.example.org/v1alpha1",
"kind": "XDownstreamResource",
"name": "invalid-schema-xr",
"namespace": "default",
"status": "invalid",
"errors": [
{
"type": "schema",
"field": "spec.forProvider.configData",
"message": "spec.forProvider.configData: Invalid value: \"boolean\": ...",
"value": "boolean"
}
]
}
]
}
]
}The OutputError schema:
| Field | Type | Description |
|---|---|---|
resourceID |
string | Identifies which user-supplied input the diff was processing (one entry per batched run). Format: <Kind>/<name>. |
message |
string | Human-readable error string — the same text written to stderr. |
validationFailures |
[]ResourceValidationFailure, optional |
Structured per-resource breakdown. Set for local schema-validation failures and for cluster rejections during the dry run; nil for tool, IO, render, and scope-check errors. |
ResourceValidationFailure carries apiVersion, kind, name, namespace, status (one of "invalid" or "missingSchema" — "valid" rows are filtered out), and errors, a list of FieldValidationError records:
| Field | Type | Description |
|---|---|---|
type |
string | "schema", "cel", "unknownField", "defaulting", or "admission". The first four come from local validation against the CRD/XRD. "admission" means the apiserver refused the resource during the dry run — a validating webhook, a ValidatingAdmissionPolicy, a ResourceQuota. A missing namespace is deliberately NOT here: it degrades instead, because it is a precondition you may be about to satisfy rather than a verdict on the object (see dryRun.skipReason: namespaceNotFound). It sits in the same field on purpose: to a consumer asking "why won't the cluster accept this?", it is the same kind of answer. |
field |
string, optional | JSONPath of the offending field, when locatable. Absent for "admission": the apiserver reports one rejection, not a field list. |
message |
string | Validator-emitted human-readable description; for k8s-derived schema errors this typically already embeds the field path and bad value. For "admission" it is the apiserver's rejection message verbatim. |
value |
any, optional | The offending value as the validator saw it. Type-preserved (string, number, bool, struct). |
An "admission" failure is reported the same way whether the resource already exists or is being added — the cluster's verdict does not depend on that, so neither does the exit code. See the note under Exit Codes for the behaviour change this represents.
resourceID and validationFailures are intentionally complementary: resourceID anchors the failure to one user-supplied input, while validationFailures enumerates every resource (the input itself plus any composed resource) that failed validation under that input. They overlap on kind+name when the input itself is among the failing resources — that's deliberate, so consumers iterating validationFailures never miss an XR-level rejection.
For comp output the same OutputError structure (including validationFailures) appears only in the top-level errors[] of the composition-diff JSON. Per-composition and per-XR failures use a simpler error string field (under compositions[] and impactAnalysis[] respectively) — they don't carry the structured validation breakdown.
The tool returns different exit codes to indicate the result of the diff operation, making it easy to use in CI/CD pipelines and scripts:
| Exit Code | Meaning |
|---|---|
| 0 | Success - no differences detected |
| 1 | Tool error - execution failed (e.g., cluster access issues, invalid input) |
| 2 | Validation error - the cluster will not accept a resource: it failed local validation against its CRD/XRD schema, or the apiserver rejected it during the dry run (validating webhook, ValidatingAdmissionPolicy, ResourceQuota) |
| 3 | Diff detected - differences were found between input and cluster state |
| 130 | Interrupted - the run was stopped by SIGINT (Ctrl+C) or SIGTERM; results are incomplete |
An interrupt stops the run but still releases the function containers it started (cleanup is bounded at 30 seconds),
and, once diffing has begun, -o json/-o yaml output is still written, with an errors[] entry saying the run was
interrupted. An interruption outranks every other exit code. A second Ctrl+C exits immediately with code 1 and may leave
function containers behind; reaping containers whose owning run died is tracked in #525.
Exit codes 0-3 are ordered by severity. When processing multiple resources, the highest severity exit code is returned:
# Example: Use exit codes in CI/CD
crossplane-diff xr my-xr.yaml
case $? in
0) echo "No changes needed" ;;
1) echo "Error running diff" ; exit 1 ;;
2) echo "Cluster would reject a resource" ; exit 1 ;;
3) echo "Changes detected - review required" ;;
esacBehaviour change: cluster rejections now exit 2, not 1. Exit code 2 used to mean only local schema validation. A resource refused by a validating webhook during the dry-run apply surfaced as a plain tool error — exit 1. It is now exit 2, with an
errors[].validationFailures[]entry oftype: "admission".This deliberately covers modifications of existing resources, not just the newly dry-run additions. Reclassifying only additions would have made the exit code for one cluster fact depend on whether the resource happened to exist already, rather than on what the cluster said about it. A pipeline that treats exit 1 as "the tool broke, retry or escalate" and exit 2 as "the input is bad, tell the author" now gets the right bucket for a webhook rejection; one that was matching on exit 1 to detect rejections needs updating.
Revision churn does not set exit code 3. For comp, exit code 3 means the composition's displayed diff is non-empty, or at least one composite's downstream resources change. Note the first of those: exit 3 does not imply that anything renders differently — a visible metadata-only edit (adding a label, say) shows a composition diff and so exits 3, even though every composite renders identically.
Exit 0 therefore requires both an empty displayed diff and no composite reporting downstream changes. A composition that creates a new CompositionRevision can satisfy both — when its difference is confined to fields excluded from the diff (reported as "maskedChangesOnly": true), and, for such a composition, when its composites were also left unevaluated (reported as "impactAnalysisSkipped": true; see --analyze-on). Note that neither flag causes exit 0 on its own: a visible composition diff exits 3 whether or not the composites were evaluated. This is what keeps a GitOps loop re-applying the same manifests from failing its diff gate on every run. A pipeline that does want to gate on revision churn reads revisionImpact.createsRevision from the structured output.
The tool prioritizes accuracy above all else:
- Never silently continues in the face of failures
- Avoids making best-guesses that could compromise accuracy
- Fails completely rather than emit potentially incorrect partial results
- Reaches extensively into the cluster for all information needed to produce accurate diffs
- Caches resources only to avoid API throttling
The tool does not take a snapshot of cluster state before processing, so changes made to the cluster during execution may affect results.
- Design Document: Comprehensive technical design and architecture
- CLAUDE.md: Instructions for Claude. Contains development principles and guidelines for LLMs.
Prerequisites:
- Go 1.24+
- Docker
- Earthly (install instructions)
- kubectl
Setup Steps:
-
Install required tools:
go install sigs.k8s.io/controller-runtime/tools/setup-envtest@release-0.20 setup-envtest use 1.30.3 # or whatever cluster version we're using now -
Generate test manifests:
earthly +go-generate --CROSSPLANE_IMAGE_TAG=main
-
Build the project:
earthly +build
Unit and Integration Tests (fast):
cd cmd/diff
go test ./...Development Checks (linting, tests, generation):
# Run before opening any PR
earthly +reviewableEnd-to-End Tests:
# Full test matrix against multiple Crossplane versions (slower)
earthly -P +e2e-matrix
# Single E2E test against specific version
earthly +e2e --CROSSPLANE_IMAGE_TAG=main# Build binary
earthly +build
# Run linting
earthly +lint
# Generate code/manifests
earthly +generate
# All available targets
earthly --helpThe tool follows a clean layered architecture:
- Command Layer: CLI argument parsing and coordination
- Application Layer: Process orchestration and resource loading
- Domain Layer: Core diff logic, rendering, and validation
- Client Layer: Kubernetes and Crossplane API interactions
The project includes comprehensive testing:
- Unit tests: Fast, isolated component testing
- Integration tests: Using
envtestfor realistic cluster interactions - E2E tests: Full end-to-end scenarios across v1, v2-cluster, and v2-namespaced configurations
Contributions are welcome! Please see our contributing guidelines for detailed setup instructions and development workflow.
Quick Start for Contributors:
- Fork and clone the repository
- Run setup:
earthly +go-generate --CROSSPLANE_IMAGE_TAG=main - Make your changes
- Test your changes:
earthly +reviewable && earthly -P +e2e-matrix - Open a pull request
See CONTRIBUTING.md for complete guidelines and CODE_OF_CONDUCT.md for community standards.
This project is licensed under the Apache License 2.0 - see the LICENSE file for details.