Skip to content

feat(project): add alpha project simulate on the ported crossplane-diff engine - #436

Open
jcogilvie wants to merge 126 commits into
crossplane:mainfrom
jcogilvie:jco-simulate-diff-port
Open

jcogilvie wants to merge 126 commits into
crossplane:mainfrom
jcogilvie:jco-simulate-diff-port

Conversation

@jcogilvie

@jcogilvie jcogilvie commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

Description of your changes

Adds an alpha crossplane project simulate (#367) built on the crossplane diff engine, ported from crossplane-diff into internal/diff. It previews how a project's changed Compositions and embedded functions affect the XRs on a live cluster. Provider diff servers then plan each composed managed resource.

Builds on #429 (resource simulate, merged).

  • Engine port (internal/diff/**): the renderer, types, ref, dockerrun, the core/kubernetes/crossplane clients and the diffprocessor, ported from crossplane-diff (import rewrites and gci grouping only), plus a client bundle client.New. It tracks upstream through crossplane-diff#591, #593, #594, #595, #597 and #598:

    • Object comparison lives in internal/diff/compare.
    • Processors return reports (XRReport, CompDiffOutput), and the command renders them.
    • The output model lives in internal/diff/result.
    • Filtered and pinned XRs are always recorded.
    • Errors print once, through kong, not inline.
    • Cluster lookups are built lazily, so a get-only identity works: no cluster-wide list of Functions, Compositions, XRDs or EnvironmentConfigs at startup.

    CLI-only engine additions, written to be upstreamable: an opt-in per-XR budget (WithXRTimeout), whose clock pauses while shared work runs (OutsideXRDeadline), and an after-XR hook (WithAfterXRDiff).

  • Integration tests (internal/diff/integration): every case from crossplane-diff's TestDiffIntegration and TestCompDiffIntegration, run against envtest with the real Docker render engine. One runSimulate function translates each case's crossplane-diff arguments and runs the real project simulate command through kong. Structured assertions check plan.Result.

    • Both tables share one expectation builder (simtest.Expect() / AssertStructured), since every mode emits the same plan.Result. Each mode keeps its own matching rules, so no check got weaker or stricter.
    • There are also plan-output cases, least-privilege cases (a get-only user must produce the same output as admin), and provider-plan cases against an in-process fake diff server.
    • TestProjectSimulateIntegration runs the command on real project files: malformed and empty projects, a project mixed with other inputs, a plain project that must match Composition mode's result, an embedded function that must win over a same-named cluster Function, and a provider dependency that must decide which diff server is used (through a test-only SimulateHooks.Servers).
    • All cases pass except upstream's own two skips (CrossNamespaceResourceCollision, AddedResourceRetainsCompositionAuthoredStatus). No gap: skips remain.
    • They run natively on macOS as well as Linux (nix run .#integration-test, or ./nix.sh run .#integration-test in a container).
  • Project glue:

    • project.LoadFunctions (lifted from render xr), project.LoadCompositions and project.LoadProviderDependencies.
    • An overlay FunctionProvider that serves project functions by name and falls back to the cluster's.
    • Detection of embedded functions whose digest differs from the cluster's, using the same index digest as project push.
    • kube.RESTConfig, now shared with resource simulate.
  • project simulate command (alpha). -f/--input is repeatable and picks the mode from what it's given:

    • Project file (the default): Compositions from the project, with the project's functions overlaid on the cluster's.
    • Composition files: crossplane-diff's comp. XRs are found on the cluster, and the cluster's functions run.
    • XR or claim files: crossplane-diff's xr. Compositions and functions come from the cluster.
    • Rejected: a project file mixed with other inputs, and Compositions mixed with other resources.
    • Per-mode flags: a flag that doesn't apply to the chosen mode is rejected, as crossplane-diff's comp and xr do.
    • One path per -f: commas are part of the path; repeat -f for more inputs.
    • Validation before work: bad flag values (--name, -n, --max-concurrency, --max-iterations, --timeout) fail during parsing, before any function build or server start.
    • Other flags: -c/--context, impersonation, -n, --name (repeatable, comma-separated, accepts ns/name), --include-pinned, --max-iterations, --timeout, --max-concurrency, --cache-dir, -o plan|diff|json|yaml, the render-backend version flags, and crossplane-diff's other common flags (--ignore-paths, --function-credentials, --dry-run-on, --analyze-on, ...).
  • Provider plans: each composed managed resource is planned by its provider's diff server, with resource simulate's Planner, now shared in internal/simulate. Unchanged provider-managed resources are planned too, so cloud drift shows.

    • Flags: --skip-plan, --providers, --image-configs and --session-id. Planning is on by default.
    • Which servers start: --providers overrides. Otherwise project mode uses the project's provider dependencies, and Composition and XR modes start the cluster's providers on demand for the kinds actually rendered. A failed lookup for a kind skips that kind with one warning.
    • Unsupported kinds: a kind no server serves keeps the cluster diff. A server that says it can't diff marks the plan approximate.
  • Output: -o plan is the default terraform-style view. -o json|yaml emit plan.Result (formatVersion 0.3) through one mapper (internal/simulate.FromXRReport and FromCompReport). -o diff is crossplane-diff's diff, plus inline # forces replacement, (known after apply) and a (requires replacement) header and summary count, but only when provider plans ran. Without them it is byte-identical to crossplane-diff.

  • plan.Result: each plan carries before (live) and after (rendered), the cleaned objects the diff compares, with Secret values redacted. It also carries composite grouping, typed errors with validation failures, warnings, revision impact for each Composition, dry-run verification, a deferred count, and notes. The notes say when the provider layer was skipped or no diff server served the resources. resource simulate shares the model, so its JSON gains before/after and the same stripping.

  • Render: a typed render.ErrStaleRenderBackend, so the engine can tell a stale render backend apart from other errors.

  • Pinned XRs: an XR on a Manual policy whose pinned revision calls a changed embedded function is rendered against that revision, with the project's functions, and reported with pinned: true and a detail naming the revision. Otherwise pinned XRs are deferred, still with pinned: true.

  • Nix and CI: pinned Crossplane v2.4.1 CRDs and envtest v1.30.3 assets (Linux and macOS, amd64 and arm64) for the tests. The integration tests need Docker for the render engine, and the Nix sandbox has none.

    • So they sit behind an integration build tag, the same way the existing compilegate tests do, and lint still covers them.
    • They run through their own nix run .#integration-test app and integration-tests CI job, outside the sandbox. Unit tests and flake check are unchanged. The app keeps the host PATH (nix inputs first) so Docker's credential helpers resolve.
    • The integration job uploads a -coverpkg=./... profile to codecov under the integration flag, and codecov merges it with the unittests upload for the same commit.
    • A later e2e suite fits in as a third tag, app and job.

Still to do for #367:

Decisions to review (hard to reverse, even under alpha):

  • project simulate writes into the project. In project mode, simulate builds the embedded functions the same way project build and render xr do. That generates language schemas into the project's schemas/ directory as a side effect. For a preview command, that may be unexpected: running it in CI or on a clean checkout leaves a modified working tree. Should simulate generate schemas into a temporary directory instead, leaving the project untouched? Or is writing them acceptable, since render xr already does it and the schemas are needed to build the functions anyway? If we change it, should render xr change with it?
  • Output: -o plan is the default, -o json|yaml emit plan.Result, and crossplane-diff's v1/v2 JSON schemas are gone.
  • plan.Result field shapes: every summary count is always present (deferred included); a composite's reason is a camelCase enum (manualPolicy, revisionSelectorMismatch, deleting, unknown), mapped explicitly from the engine's values so an upstream rename can't silently change the contract; an error's resource is errors[].ref; pinned is true for every XR on a Manual policy. Every input mode emits this one shape, but compositions and composites are omitempty, so an XR-mode result has no compositions key rather than []. Do reviewers agree, or should both always be present so consumers see the same keys whatever -f is given?
  • before/after in plan.Result: they follow terraform's show -json and are null for a create or delete respectively. A resource whose only change is provider-side drift has no objects.
  • Field path grammar in plan.Result: path is one string. Plain keys join with ., list indexes are [N], and any other key is quoted terraform-style as ["key"], e.g. metadata.annotations["crossplane.io/composite"]. This lets annotation and label keys round-trip, but json/yaml consumers must parse the grammar to split a path. An additive segments list is an option if that's a burden. Paths from upjet's diff servers don't quote dotted TF map keys (e.g. a tags key a.b), which needs an upjet change.
  • Exit codes:
    • The codes are 0 (no changes), 1 (error), 2 (schema validation only), 3 (changes found), 80 (invalid flags or arguments, kong's usage-error code) and 130 (interrupted), with crossplane-diff's precedence 1 > 2 > 3, so changes exit 3 by default as in crossplane-diff.
    • Exit 3 counts both cluster changes and provider changes, including drift. Deferred and skipped results don't count. An approximate plan adds no change of its own, but the cluster change it annotates still counts.
    • Changes found are a result, not an error: simulate sets a kong-bound common.ExitStatus that main exits with after a run that returns no error. Errors still exit through kong's FatalIfErrorf.
  • Failure unit: the XR, all-or-nothing, matching the engine.
    • --timeout (default 2m, must be > 0) is a per-XR budget covering the XR's render and its resources' provider plans.
    • Function and provider image pulls and server starts don't count against it. The crossplane render image pull still counts against the first XR's budget, and the help says so.
    • A plan error or timeout on any of an XR's resources fails that XR with none of its plans, and the run exits 1.
  • Flag names: -f/--input, --name, --include-pinned, -c/--context, --skip-plan, --providers, --image-configs, --session-id.
  • -f classification: a single file that isn't a project file and holds no Compositions is treated as XRs. Each one is checked against the cluster's XRDs, as a claim and then as an XR. Anything else (a Configuration, a Provider, a ConfigMap) is rejected with its kind, name and what -f accepts.
  • Errors print once: the ported renderers no longer write each error to stderr inline as ERROR: Kind/name: .... kong prints the returned error once, as crossplane: error: ..., and that error names each failing resource. Structured output's errors[] is unchanged.
  • Target cluster: the user's kubeconfig context, not the dev control plane.

Also worth a look:

  • fix(project): project build with spec.paths.apis: "/" no longer packages XRDs and Compositions from examples, functions and operations. It's a separate commit, easy to cherry-pick on its own.
  • resource simulate changes:
  • Dependency bumps forced by the port: go.yaml.in/yaml/v3 v3.0.5, golang.org/x/sys v0.48.0. The port's semver use moved to Masterminds v3.
  • Exit code 80: a render version below the v2.3.4 floor also exits 80, as a usage error.
  • Sequential engine: the engine renders XRs one at a time, and the provider-plan caches rely on that. Their docs say so.
  • No manual end-to-end run: the command hasn't been run by hand against a real cluster or real provider diff servers; the integration tests use envtest and a fake diff server. Stdin input (-f -) hasn't been tested end to end.

I have:

🤖 Generated with Claude Code

@jcogilvie
jcogilvie force-pushed the jco-simulate-diff-port branch from 9dff52a to 881e787 Compare October 20, 2026 15:53
jcogilvie added a commit to crossplane-contrib/crossplane-diff that referenced this pull request Oct 21, 2026
The renderers wrote every OutputError to stderr as "ERROR: Kind/name: ...",
and the processors also returned the failure, which main printed again as
"Error: ...". A failing run reported each error twice in two formats.

Stop the renderers printing errors and drop OutputError.FormatError. Structured
output keeps errors[] unchanged, and warnings still go to stderr when raised.
main prints every returned error, schema-validation ones included, since the
renderers no longer print them. DiffComposition now names each failing
resource in its returned error, as PerformDiff already did. The integration
harness prints a returned error as main does so stderr assertions see what a
user sees.

Backport of crossplane/cli#436 (19817b2).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
jcogilvie added a commit to crossplane-contrib/crossplane-diff that referenced this pull request Oct 21, 2026
The renderers wrote every OutputError to stderr as "ERROR: Kind/name: ...",
and the processors also returned the failure, which main printed again as
"Error: ...". A failing run reported each error twice in two formats.

Stop the renderers printing errors and drop OutputError.FormatError. Structured
output keeps errors[] unchanged, and warnings still go to stderr when raised.
main prints every returned error, schema-validation ones included, since the
renderers no longer print them. DiffComposition now names each failing
resource in its returned error, as PerformDiff already did. The integration
harness prints a returned error as main does so stderr assertions see what a
user sees.

Backport of crossplane/cli#436 (19817b2).

Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
jcogilvie and others added 26 commits October 22, 2026 01:10
Callers can now detect a backend that predates `crossplane internal render`
with errors.Is instead of matching stderr text. The user-visible message is
unchanged.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Verbatim copy of cmd/diff/{renderer,renderer/types,types,testutils} from
crossplane-contrib/crossplane-diff at 9fa7d31 with import paths rewritten,
gci import grouping, and named constants for repeated literals in
testutils/mock_builder.go (goconst). Adds gopsutil/v4 (used by the envtest
reaper) and bumps go.yaml.in/yaml/v3 to v3.0.5 and golang.org/x/sys to
v0.48.0, which it requires.

TestCRDsForXRD needs Crossplane's CRDs and fails until a CRD fetch exists.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Fetch Crossplane's cluster/crds at v2.4.1 (the apis/v2 version go.mod
pins) as a fixed-output derivation and expose it to the test check and
the test app as CROSSPLANE_CRDS_DIR. PinnedCrossplaneCRDsDir now reads
that variable instead of an Earthly-populated cluster/gomod/crds.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Verbatim copy of cmd/diff/{ref,dockerrun,client/core} from
crossplane-contrib/crossplane-diff at 11e6fc3 with import paths rewritten.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Upstream replaced Info-level advisory logging with a dt.Advisories
channel; resync renderer, renderer/types, testutils and dockerrun.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Verbatim copy of cmd/diff/client/kubernetes from
crossplane-contrib/crossplane-diff at 9fa7d31 with import paths
rewritten and gci import grouping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Verbatim copy of cmd/diff/client/crossplane from
crossplane-contrib/crossplane-diff at 9fa7d31 with import paths
rewritten and gci import grouping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Verbatim copy of cmd/diff/diffprocessor from
crossplane-contrib/crossplane-diff at 9fa7d31 with import paths
rewritten and gci import grouping.

Adds github.com/Masterminds/semver v1.5.0, which crossplane-diff's
render_version.go imports, and refreshes the nix vendor hash.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
The cli's docker and local render engines now mark a backend that lacks
`crossplane internal render` as render.ErrStaleRenderBackend, so the
diff render engine checks that sentinel instead of defining its own and
matching the error text.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Port crossplane-diff's AppContext as internal/diff/client.New and share
resource simulate's rest.Config construction via kube.RESTConfig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Lift render xr's loadFunctionsFromProject into project.LoadFunctions with
no behavior change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
When spec.paths.apis is "/", project build walks the whole project and
should skip the examples, functions and operations directories. Walk paths
are rooted at "/" but the default excludes are relative, so the prefix check
never matched and those directories were packaged. Anchor each exclude at
"/" and match the directory itself or anything beneath it, so "examples"
no longer matches "examples-foo" either.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Add LoadCompositions, which returns the Compositions under a project's
APIs path using the same walk and excludes as project build.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Add ProjectFunctionProvider, a FunctionProvider that resolves pipeline
steps naming a project function to that function and asks the wrapped
(cluster) provider only for the remaining steps, so an embedded function
that is not installed in the cluster no longer fails the lookup and an
installed one no longer runs the stale cluster version. Returned
functions are per-call copies, as CachedFunctionProvider's are.

Overlaid functions carry no pull-policy annotation, so the docker
runtime keeps its IfNotPresent default and daemon-loaded images run
without a registry pull.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Add `crossplane project simulate`, which previews how a project's
Compositions would change the composite resources in the user's current
Kubernetes context. It builds the project's functions, overlays them on the
cluster's functions by name, and runs the diff engine's composition diff,
printing the result with the engine's own renderers (-o diff|json|yaml).

Exit status is 0 on success, 1 on a tool error, 2 when only schema
validation failed, and 130 when interrupted. The timeout bounds the whole
simulation because the engine has no per-composite deadline yet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Add --exit-code to `crossplane project simulate`. With it, a simulation that
succeeds and finds changes exits with status 3, so CI can gate on it, as with
git diff --exit-code. Without it, changes still exit 0, because finding them
is what a simulation is for. Tool, schema-validation and interrupt statuses
take precedence either way.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
…harness

Port TestDiffIntegration and TestCompDiffIntegration from crossplane-diff
origin/main 6a140b6 (cmd/diff/diff_integration_test.go, diff_it_utils_test.go,
main_test.go and the ~350-file testdata tree) into internal/diff/integration,
verbatim case tables included. Every case is CLI args plus an expected exit
code and output, funneled through one runSimulate harness function that wires
this repo's ported engine the way crossplane-diff's comp and xr commands do;
when crossplane project simulate grows this surface, the harness swaps to the
real command in a single-function change.

Cases the simulate surface cannot express yet are ported in full and skipped
with greppable gap:/env: reasons rather than dropped.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Auto-fix gci/tagalign/prealloc; preallocate the harness's option slice;
mark the five builders carried verbatim from crossplane-diff whose cases
are gap-skipped as nolint:unused with the reason.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
The gap/env guards sat after startIntegrationCluster, so a skipped case
still booted envtest before skipping; move them ahead of cluster startup
and thread the case's timeout in, since the args they inspect are built
before the cluster too. Also skip when CROSSPLANE_CRDS_DIR is unset, so
plain go test skips rather than fatals, and document the package in a
README.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
TestDiffSingleResourceConcurrent starts its own envtest cluster, so it
needs the same Docker/envtest/CRDS guards the runner applies; without
them nix run .#test died in envtest.Start instead of skipping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
The flag-parity branch is merged, so --no-color, --ignore-paths,
--function-credentials, --eventual-state, --analyze-on, --analyze-unchanged
(deprecated; simulate's --analyze-on=always) and --dry-run-on all exist on
project simulate. Remove the gap guard and wire --function-credentials
through the harness, loading its Secrets at parse time the way both
crossplane-diff's kong MapperValue and simulate's own flag do.

41 of 142 subtests now run and pass (was 23); the remaining skips are
the xr-mode and repeated --resource gaps plus crossplane-diff's own
upstream skip reasons.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Package the envtest control-plane binaries (kube-apiserver, etcd,
kubectl) from the controller-tools envtest v1.30.3 release, the version
crossplane-diff's Earthfile targets. Wire KUBEBUILDER_ASSETS into the
sandboxed test check, where the render-engine cases still skip on the
missing Docker daemon but the envtest cluster cases can run, and add an
unsandboxed integration-test app plus a CI job that runs it on a
runner's host Docker daemon.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
jcogilvie and others added 4 commits October 22, 2026 15:35
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
… errors

Reject --max-concurrency=0 and bad --name/-n in Validate, skip malformed pipeline steps, join render errors with processing errors, note missing diff servers only when resources were considered, treat each -f value as one path, and update the help for pinned composites.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
jcogilvie and others added 16 commits October 22, 2026 17:08
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
…sources

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Docker credential helpers live on the host PATH, not in Nix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
@jcogilvie

Copy link
Copy Markdown
Collaborator Author

project simulate on a real cluster: sample output

I ran this PR's CLI (built at 48f2d6e) against Crossplane 2.4.3 on a local kind cluster. The cluster had provider-aws-s3 v2.9.0, the first release with the offline diff server, plus function-patch-and-transform and function-auto-ready. The provider had no cloud credentials. The diff server needs only a ProviderConfig object to exist, so a ClusterProviderConfig with credentials.source: None was enough.

The fixture is a namespaced XBucketSet XR. Its Composition composes one S3 Bucket and patches the XR's spec.region into the Bucket.

Composition mode: a changed Composition

comp-changed.yaml is the applied Composition with one more tag, env: changed.

crossplane project simulate -f comp-changed.yaml

Exit 3 (changes found), 20s.

[~] Composition.apiextensions.crossplane.io xbucketset (update)
    Applying this creates a new CompositionRevision, which 1 composite would adopt.
    spec:
      pipeline[0]:
        input:
          resources[0]:
            base:
              spec:
                forProvider:
                  tags:
                    + env: "changed"

XR: XBucketSet.e2e.example.org default/demo
  [~] Bucket.s3.aws.m.upbound.io default/demo-139106315f5d (update)
      spec:
        forProvider:
          tags:
            + env: "changed"
          tagsAll:
            + env: "changed"

Plan: 0 to add, 2 to change, 0 to destroy.

Only the provider's plan reports tagsAll. With --skip-plan (2s), the cluster-layer diff alone shows only tags, and the output ends with Note: Cloud-layer plan skipped (--skip-plan).

stderr: the diff server starting on demand from the cluster's Provider
Pulling provider package xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0...
✓ Pulling provider package xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0
Starting diff server for xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0...
✓ Starting diff server for xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0

XR mode: a change that forces replacement

xr-changed.yaml is the applied XR with spec.region changed from us-east-1 to us-west-2.

crossplane project simulate -f xr-changed.yaml

Exit 3, 33s. The provider's plan marks the Bucket's region as forcing replacement:

XR: XBucketSet.e2e.example.org default/demo
  [-/+] Bucket.s3.aws.m.upbound.io default/demo-139106315f5d (replace)
      spec:
        forProvider:
          ~ region: "us-east-1" -> "us-west-2"  # forces replacement

  [~] XBucketSet.e2e.example.org default/demo (update)
      spec:
        ~ region: "us-east-1" -> "us-west-2"

Plan: 0 to add, 1 to change, 0 to destroy, 1 requires replacement.

Warning: 1 resource has fields that force replacement: region.
Crossplane providers never destroy and recreate to apply a change.
Applying this leaves the resource unsynced until it is replaced manually.
The same run with -o diff
~~~ Bucket/demo-139106315f5d (requires replacement)
  apiVersion: s3.aws.m.upbound.io/v1beta1
  kind: Bucket
  metadata:
    annotations:
      crossplane.io/composition-resource-name: bucket
      crossplane.io/external-name: simulate-e2e-17d00936-1fd5-408e-a788-08adaf39e6db
    generateName: demo-
    labels:
      crossplane.io/composite: demo
    namespace: default
  spec:
    forProvider:
-     region: us-east-1
+     region: us-west-2  # forces replacement
      tags:
        crossplane-kind: bucket.s3.aws.m.upbound.io
        crossplane-name: demo-139106315f5d
        crossplane-providerconfig: default
        purpose: crossplane-simulate-e2e
    initProvider: {}
    managementPolicies:
    - '*'
    providerConfigRef:
      kind: ClusterProviderConfig
      name: default

---
~~~ XBucketSet/demo
  apiVersion: e2e.example.org/v1alpha1
  kind: XBucketSet
  metadata:
    finalizers:
    - composite.apiextensions.crossplane.io
    labels:
      crossplane.io/composite: demo
    name: demo
    namespace: default
  spec:
    crossplane:
      compositionRef:
        name: xbucketset
      compositionRevisionRef:
        name: xbucketset-e87129b
      compositionUpdatePolicy: Automatic
-   region: us-east-1
+   region: us-west-2
    versioning: false

---

Summary: 2 modified (1 requires replacement)
The same run with -o json (excerpt: before and after omitted)
{
  "formatVersion": "0.3",
  "summary": {
    "create": 0,
    "update": 1,
    "destroy": 0,
    "replace": 1,
    "noop": 0,
    "approximate": 0,
    "errored": 0,
    "deferred": 0
  },
  "composites": [
    {
      "ref": {
        "apiVersion": "e2e.example.org/v1alpha1",
        "kind": "XBucketSet",
        "name": "demo",
        "namespace": "default"
      },
      "status": "changed"
    }
  ],
  "errored": false,
  "plans": [
    {
      "apiVersion": "s3.aws.m.upbound.io/v1beta1",
      "kind": "Bucket",
      "name": "demo-139106315f5d",
      "namespace": "default",
      "action": "replace",
      "changes": [
        {
          "path": "spec.forProvider.region",
          "actual": { "value": "us-east-1" },
          "planned": { "value": "us-west-2" },
          "requiresReplace": true,
          "origin": "desired"
        }
      ],
      "replacePaths": ["spec.forProvider.region"],
      "composite": {
        "apiVersion": "e2e.example.org/v1alpha1",
        "kind": "XBucketSet",
        "name": "demo",
        "namespace": "default"
      }
    }
  ]
}

The second plan entry, the XR's own spec.region update, is left out.

Project mode

A minimal project holds the XRD and the changed Composition under apis/, and depends on provider-aws-s3 v2.9.0 and the two functions.

crossplane project simulate -f crossplane-project.yaml

Exit 3, 58s including the project build. The plan is identical to the Composition-mode run above. The diff server comes from the project's provider dependency:

stderr
Resolving function dependencies...
✓ Resolving function dependencies
Collecting resources...
✓ Collecting resources
Updating dependency xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0...
Updating dependency xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0...
Updating dependency xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.7...
✓ Updating dependency xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.7.0
✓ Updating dependency xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.10.7
✓ Updating dependency xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0
Generating schemas...
✓ Generating schemas
Building configuration package...
✓ Building configuration package
Pulling provider package xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0...
✓ Pulling provider package xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0
Starting diff server for xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0...
✓ Starting diff server for xpkg.crossplane.io/crossplane-contrib/provider-aws-s3:v2.9.0

No changes

Simulating the applied XR as-is exits 0:

XR: XBucketSet.e2e.example.org default/demo

Plan: 0 to add, 0 to change, 0 to destroy.

A provider plan that fails

Before the ClusterProviderConfig existed, the diff server couldn't plan the Bucket. The whole XR fails and the command exits 1. The Composition's own diff still prints:

XR: XBucketSet.e2e.example.org default/demo
  [!] XBucketSet.e2e.example.org default/demo (error)
      cannot plan: unable to process resource e2e.example.org/v1alpha1/XBucketSet/default/demo: cannot plan 1 resources with their diff servers: Bucket/demo-139106315f5d: diff server cannot plan: rpc error: code = Internal desc = cannot compute diff for a Terraform plugin SDKv2 resource: cannot connect for Terraform plugin SDKv2 resource: cannot get terraform setup: cannot resolve the provider config: cannot get referenced ProviderConfig: ClusterProviderConfig.aws.m.upbound.io "default" not found

Plan: 0 to add, 1 to change, 0 to destroy.

Warning: 1 resource could not be planned.

No diff-server or render containers were left running after any run.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
@jcogilvie
jcogilvie marked this pull request as ready for review October 23, 2026 16:01
@jcogilvie
jcogilvie requested review from a team and tampakrap as code owners October 23, 2026 16:01
@jcogilvie
jcogilvie requested review from negz and removed request for a team October 23, 2026 16:01
@haarchri
haarchri requested review from adamwg and haarchri October 23, 2026 20:18
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.

1 participant