Skip to content

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

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

jcogilvie wants to merge 155 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 25 commits October 26, 2026 19:29
… error

Use the kubeconfig context's namespace for namespaced resources without
one, as resource simulate does. Join a Composition render failure with
the simulation's error instead of replacing it, by sharing one
renderReport helper between the XR and Composition paths. Remove the
unused DiffOptions.Format switch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Parse --name once in Validate, drop the redundant WithoutCancel, inline
selectServers, and make ref.Parse errors flag-neutral (wrapped as
invalid --name by the caller). Delete the unused ref.ParseAll. Tighten
RenderErrorKeepsDiffError and fix stale TestSimulateDiffOptions cases.

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

Add project.NewXpkgClient and use it from render xr, render op, project
build, run and simulate instead of five inline copies.

Replace LoadFunctions/LoadFunctionsWithDigests with one LoadFunctions that
takes the parsed project, the dependency manager and an options struct.
Project simulate now parses the project once and builds one client and one
manager (with schema generators) for both function and provider
dependencies, so --image-configs, when set, replaces the project's
ImageConfigs for both. The help says so.

BuildWithMaxConcurrency clamps 0 to 1. Before, 0 made an unbuffered
semaphore and render xr/op hung with --max-concurrency=0. Simulate's
Validate check and the max() in build and run are no longer needed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
LoadFunctions always computed embedded function digests, which go through
a strict configuration tag. render xr, which discards them, then failed for
a project repository without an explicit registry and did index work for
nothing. Add LoadFunctionsOptions.Digests; only simulate sets it.

Log the project file path at the callers again, say what nil Generators
means, and note that --image-configs doesn't change schema generation
images.

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

Simulate takes the render engine flags as render does, including
--crossplane-binary and --crossplane-docker-network (falling back to
CROSSPLANE_DIFF_DOCKER_NETWORK). --function-registry-override is gone:
function images, except the project's embedded functions, are rewritten
by the effective ImageConfigs, which --image-configs replaces and which
may now be used with --skip-plan.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
EngineFlags' --session-id help now takes a kong variable, so simulate
describes the diff-server label it actually sets, while render keeps
its text. Pin that ImageConfig rewrites keep a digest, leave the
ImageConfig out of the rewrite error when none was selected, drop the
test-only NewProjectFunctionProviderFactory, and move --image-configs
out of the provider-plan section of the help, noting the binary and
docker-network xor and that an unparseable project file in the cwd is
an error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Shorten flag help to one sentence and move the details to help.md.
Take --function-credentials as a plain path and load it in Validate
with render.LoadCredentials, keeping only v1 Secrets.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Check each document's TypeMeta before decoding it as a Secret, so other
kinds such as a ConfigMap with plain-text data are ignored, not rejected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
plan.Result, plan.Summary and plan.Renderer go back to their shape on
main, so resource simulate's -o json|yaml no longer carries project
fields or summary.deferred, and its format version resets to 0.1.

Project simulate's result moves to simulate.ProjectResult, which embeds
plan.Result and holds the composites, compositions, errors, warnings and
notes. ProjectSummary.Deferred is now counted from the composites in
NewProjectResult instead of being set by whoever builds them. The
project renderer reuses plan.RenderPlan, RenderSummaryLine and
RenderSummaryWarnings.

Drop Verification.Performed: a Verification is present only for a dry
run that was not performed. Fix the Pinned doc to match deferred
composites, which are pinned too.

plan no longer imports internal/diff/compare: cleanView strips status,
server-managed metadata, last-applied-configuration and composed
resource references itself.

BREAKING CHANGE: project simulate's JSON drops dryRun.performed and
reports formatVersion 0.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
ProjectResult embedded plan.Result, so encoding/json wrote the embedded
plans ahead of the outer summary, composites and compositions. A
MarshalJSON now writes the keys in their previous order: formatVersion,
errored, summary, composites, compositions, plans, errors, warnings,
notes. YAML goes through it too.

The summary has one source: Result.Summary holds the plan counts, and
Deferred() counts deferred composites, joined only when serialized.
ProjectSummary is gone.

cleanView's doc now says it deliberately skips the engine's
synthetic-name rule, and a test pins that a generateName-shaped name and
an empty spec.crossplane are kept.

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

Change paths are now crossplane-runtime fieldpath strings, the syntax
--ignore-paths reads. Drop JoinKey, isIdent, PathSegments and simtest's
parsePath in favour of fieldpath.Segments/Parse.

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>
…dpath

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Replace common.ExitStatus and project simulate's exitError with one
common.ExitError (a kong.ExitCoder); a nil Err exits silently, which main
honours. resource simulate now exits 3 when a plan would change anything,
and its --cache-dir follows the CLI convention (CROSSPLANE_XPKG_CACHE,
dependency.DefaultCacheDir()). Flags that do nothing for the inputs warn;
misleading scope flags with XR files exit 80.

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>
…lags

resource simulate exits 1 after rendering when any plan errored, as
project simulate does. --providers and --session-id with --skip-plan now
warn instead of exiting 80. Add common.ExitCodeChanges and
ExitCodeSchemaValidation, pinned to the engine's codes, and exit silently
only for an unwrapped ExitError value via common.SilentExit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
kong's Context.Run returns errors.Join(runErr, hookErr), so an unwrapped
type assertion never matched and changes found printed an empty error
and exited 1. Match with errors.As, and require an empty message so a
wrapping or joined error is still printed.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Use "cluster diff" and "provider plan" throughout, print one
no-diff-server message (stderr only for -o diff, Notes otherwise), drop
crossplane-diff from the help, and trim help internals.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Load -f inputs with ld.WithoutPipelineInputs() so the loader never returns
Composition step inputs as manifests, and delete pipelineInputs, which
dropped any real resource identical to a step input.

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

Move internal/diff/integration into cmd/crossplane/project behind the
integration build tag, so internal/ no longer imports cmd/ from tests,
and unexport the simulate test hooks.

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>
// provider diff servers as the engine finishes rendering it, so an XR whose
// plans fail is reported as an error, whole, like a failed render.
//
// It keeps one plan per diff key: when two XRs produce the same key, the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

what happen if two XRs render the same composed resource differently, XR B report is merged with a provider plan computed from XR A desired object. Please key by (XR, diff key), or flag the conflict?!

}
}

plans := PlanDiffs(ctx, x.planner, x.keep, diffs)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Keys that are already collected are still planned over gRPC, and the result is thrown away; if that call fails, XR B fails for nothing. Please skip keys you already have.

keep func(*unstructured.Unstructured) bool
ensure EnsureServers

mu sync.Mutex

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The mutex only covers half of the contract, while the comment says the engine is sequential. Either drop it and document not safe for concurrent use, or lock all of AfterXRDiff

for _, r := range resources {
gvk := r.GroupVersionKind()
gk := gvk.GroupKind()
img, ok := cache.images[gk]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

There are three caches with three failure policies. crdDefaults refuses to cache transient errors, but RelevantProviderImages caches "" on any error and ProviderManaged caches false silently, which drops provider-found drift. Please use one rule: never cache non-definite answers.

// A lookup that fails answers false, so the resource keeps the engine's
// verdict alone. XRs are never provider-managed: their CRDs are controlled by
// their CompositeResourceDefinition.
func ProviderManaged(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper) func(*unstructured.Unstructured) bool {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

nit: the same CRD is fetched by multiple code paths:
ProviderManaged, RelevantProviderImages and default.go, on top of the engines own SchemaClient CRD cache. Please use one per-run CRD lookup and derive the answers from it

}
}

// RenderImage returns the fully-resolved render image reference the engine will

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

resolveRenderImage is a copy of render.crossplaneImageFromFlags. Please export the render helper and delete this one. RenderImage() is used only in tests...


switch {
case bySelector == 0 && byDeletion == 0:
return fmt.Sprintf("All %d XR(s) using composition %s have Manual update policy (use --include-manual to see them)",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The user-facing text says "use --include-manual", but the flag here is --include-pinned - think in the design doc we usinging include-manual right ?

)

// EnvironmentClient handles environment configurations.
type EnvironmentClient interface {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

is this every used ? feels death code here

limitations under the License.
*/

// Package dockerrun ties the Docker resources a crossplane-diff run creates — function containers, the

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is a third container cleanup scheme next to internal/containerruntime and internal/diffserver, which use other labels and are never reaped. One command now has two cleanup strategies. Please either move lease/reap into containerruntime for everything, or drop it from this PR.

@@ -0,0 +1,82 @@
/*

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Per run we now build two Kubernetes client stacks with two discovery caches (client/core + client/kubernetes vs simulate.NewKubeClient) and two CRD-default caches (diffprocessor/defaulter.go vs internal/simulate/defaults.go). Please share one.

jcogilvie and others added 2 commits October 27, 2026 11:06
…ssplane-simulate

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants