Skip to content

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

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

jcogilvie wants to merge 65 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.

Builds on #429 (resource simulate, merged).

Work in progress toward #367. Done so far:

  • Engine port (internal/diff/**): renderer, types, ref, dockerrun, the core/kubernetes/crossplane clients and the diffprocessor, ported verbatim from crossplane-diff (import rewrites and gci grouping only), plus a client bundle client.New.

  • Integration tests (internal/diff/integration): every case from crossplane-diff's TestDiffIntegration and TestCompDiffIntegration at 6a140b6, run against envtest with the real Docker render engine. The case tables are verbatim. One runSimulate function translates each case's crossplane-diff arguments and runs the real project simulate command through kong.

    • 142 cases: 139 pass. One skips with gap: simulate has no repeated --resource scoping, and two carry upstream's own skips. grep -rn 'gap:' lists what is left to enable.
  • Project glue: project.LoadFunctions (lifted from render xr), project.LoadCompositions, an overlay FunctionProvider that serves project functions by name and falls back to the cluster's, and 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.
    • Other flags: -c/--context, impersonation, -n, --name, --include-pinned, --max-iterations, --timeout, --max-concurrency, -o diff|json|yaml, --output-version, the render-backend version flags, and crossplane-diff's other common flags (--ignore-paths, --function-credentials, --dry-run-on, --analyze-on, ...).
  • Engine boundary (tracking upstream): ported from crossplane-diff#591, #593, #594 and #595:

    • 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.

    The CLI keeps its own error printing: errors print once, via kong, not inline.

  • Mapper: internal/simulate.FromXRReport and FromCompReport turn engine reports into plan.Result, with Secrets redacted and claims kept. It is not wired into the command yet.

  • plan.Result: carries composite grouping, typed errors with validation failures, warnings, revision impact for each Composition, dry-run verification and a deferred count. The text renderer groups plans under their XR. resource simulate's output is unchanged, and its JSON formatVersion is now 0.2.

  • internal/simulate: resource simulate's per-resource planning moves into a reusable Planner (Plan, PlanWithLive) and RelevantProviderImages, so project simulate can plan composed MRs through the provider diff servers.

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

  • Nix and CI: pinned Crossplane v2.4.1 CRDs and envtest v1.30.3 assets 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 integration job writes a -coverpkg=./... profile and uploads it to codecov under the integration flag. 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:

  • -o plan as the default, with json|yaml emitting plan.Result through one mapper. Then drop --output-version and crossplane-diff's v1/v2 schemas, and rewrite the structured IT assertions
  • Plan each composed MR through the provider diff servers, with inline # forces replacement and (known after apply) in -o diff
  • Claims, and the exception for pinned XRs when an embedded function changes
  • CODEOWNERS for the ported packages

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

  • Output: -o plan is the default. -o json|yaml emit plan.Result (formatVersion 0.2), and -o diff is crossplane-diff's diff. --output-version is hidden and temporary: it selects crossplane-diff's schema while the integration tests move over, and is then removed.
  • 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: 0 (no changes), 1 (error), 2 (schema validation only), 3 (changes found) or 130 (interrupted), with crossplane-diff's precedence 1 > 2 > 3, so changes exit 3 by default as in crossplane-diff. 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.
  • Flag names: -f/--input (its long form was --project-file), --name, --include-pinned, -c/--context.
  • -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, and 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. This deviates from crossplane-diff, which printed both. Structured output's errors[] is unchanged.
  • Target cluster: the user's kubeconfig context, not the dev control plane.
  • Cluster-wide listing: diff comp lists Functions, Compositions and XRDs cluster-wide on startup. That conflicts with the issue's "no cluster-wide work beyond the scoped discovery"; it is flagged here, not fixed yet.

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.
  • 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 exits 80 (kong's parse-error code), not 1.
  • Timeout: --timeout covers the whole run, not each XR.
  • No manual end-to-end run: the command hasn't been run by hand against a real cluster; the integration tests run it against envtest. Stdin input (-f -) hasn't been tested end to end.

I have:

🤖 Generated with Claude Code

jcogilvie and others added 27 commits October 20, 2026 11:45
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>
The test app now runs the whole suite with envtest assets and Docker, as
crossplane-diff's go-test target does, and writes a -coverpkg=./...
coverage profile so integration coverage of internal/diff counts. CI's
tests job runs the app outside the sandbox; the separate integration-test
app and job are removed. The sandboxed check stays for flake check.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
@jcogilvie
jcogilvie force-pushed the jco-simulate-diff-port branch from 9dff52a to 881e787 Compare October 20, 2026 15:53
jcogilvie and others added 2 commits October 20, 2026 12:07
This reverts commit 881e787.

Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
The diff integration tests need Docker and envtest assets, so they run in
their own integration-test target rather than with the unit tests. The
integration tag keeps go test ./... from compiling them; golangci-lint
builds with the tag so they are still linted. An untagged doc.go keeps the
package buildable without the tag.

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 7 commits October 20, 2026 15:44
The ported renderers wrote every OutputError to stderr as "ERROR:
Kind/name: ...", and the processors also returned the failure, which
kong printed again as "crossplane: error: ...". A failing run reported
each error twice in two formats.

Stop the renderers printing errors and drop OutputError.FormatError,
which nothing else used. Structured output keeps errors[] unchanged,
and warnings still go to stderr when raised. The command's returned
error is now the only place a human sees an error, so it must name
each failure: PerformDiff already joined "unable to process resource
Kind/name: ..." for each input, and DiffComposition now does the same
under "impact analysis failed for N XR(s)" and "failed to process all
compositions", in place of bare counts. The integration harness prints
a returned error the way kong's FatalIfErrorf does, so the cases'
stderr assertions see what a user sees.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Ports crossplane-diff#591. GenerateDiff, Options{IgnorePaths, ForVerdict} and
PathLastAppliedConfiguration move to internal/diff/compare, DiffCalculator
takes compare.Options, and renderer.DiffOptions keeps only presentation
fields. No behaviour change.

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

Renderer.Render now takes a plan.Result. Result gains Composites,
Compositions with RevisionImpact, typed Errors, Warnings and
Summary.Deferred; ResourcePlan gains Composite and DryRun. FieldChanges
is exported for mappers. The plan renderer groups plans under an
XR header when a composite is set; ungrouped output is unchanged.

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>
Port of crossplane-diff ad02f2b (#593).

DiffProcessor.PerformDiff now returns a *dt.XRReport (groups, union errors,
sorted advisories) and CompDiffProcessor.DiffComposition a
*renderer.CompDiffOutput, instead of rendering and returning a has-diffs
bool. project simulate builds the renderer from its flags and the kong
context's writers, renders the returned report (even alongside an error, so
partial results and structured errors still reach stdout), and derives the
exit code from the error and the report's HasChanges.

ProcessorConfig drops Colorize, Compact, MinimizeComposition, OutputFormat,
OutputVersion, Stdout, Stderr, GetDiffOptions and the DiffRenderer and
CompDiffRenderer factories with their options. A processor's default
AdvisoryCollector is now collect-only; the command injects the
stderr-writing one. Cleanup still runs before the advisories are read into
the report, so a teardown advisory reaches warnings[].

The CLI keeps its divergences: errors are returned, naming each failing
resource, rather than printed inline, so the report-error tests also
assert on the returned error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Port of crossplane-diff 1271905 (#594).

Rename internal/diff/renderer/types to internal/diff/result and move into
it the comp report types (CompDiffOutput, CompositionDiff, RevisionImpact,
XRImpact and their siblings) with their HasChanges verdicts, and the
unverified-additions summary, exported as DryRunWarnings,
XRDryRunWarnings and CompDryRunWarnings. The renderers import them from
result, and diffprocessor no longer imports renderer. No behaviour change.

The renderers keep the CLI's divergence: they still do not print errors
inline to stderr.

Anchor the .gitignore entries for Nix's result symlinks to the repository
root, so they no longer ignore the new package.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Port of crossplane-diff ccd756f (#595).

DiffComposition now always records the composites it filters out (Manual
update policy, revision selector mismatch, being deleted) in
ImpactAnalysis as filtered entries, not only in --resource mode.
CompositionDiff.Scoped records that the composites were the ones
--resource named, and the renderers list filtered entries one by one only
when it is set; default discovery still reports them only through the
affectedResources counters. Human output and discovery-mode structured
output are unchanged.

A Manual-policy filtered composite that names a revision carries
FilterDetail "compositionRevisionRef: <name>" (the human line ignores
it), and XRImpact.Pinned marks a composite --include-manual evaluated
that would not adopt the resulting revision.

Co-Authored-By: Claude Opus 5.5 (1M context) <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).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Add FromXRReport and FromCompReport, which translate the diff engine's
reports into plan.Result: one plan per resource diff (the XR's own
included) grouped under its composite, typed errors with validation
failures, deferred and skipped composites from filter reasons, and a
Compositions entry with revision impact. Secret data is redacted and
identical changes reached by several inputs are listed once.

plan gains three omitempty fields the mapping needs:
CompositeResult.Pinned, CompositionResult.ImpactAnalysisSkipped and
Result.Notes.

Co-Authored-By: Claude Opus 5.5 (1M context) <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 19 commits October 20, 2026 22:14
The plan text renderer prints each Composition's revision impact as one
line under its plan, and the result's Notes after the summary. A result
with neither, like resource simulate's, renders as before.

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>
project simulate now prints a field-level plan by default. -o json and
-o yaml emit plan.Result through the internal/simulate mapper, and
-o diff keeps the engine's human renderer. The command picks the
renderer with a small switch; the engine gains no factories.

--output-version is hidden and temporary. Only when it is set do -o
json|yaml emit crossplane-diff's v1/v2 structured schema, and it is
rejected with -o plan or -o diff. The integration harness passes
-o diff for the text cases and --output-version for the structured
ones, so the existing cases still assert on the old output. New
integration cases pin -o plan text and the -o json plan.Result shape
for an xr run and a composition run.

BREAKING CHANGE: the default output is now plan, not diff, and -o
json|yaml emit plan.Result rather than crossplane-diff's schema. Pass
-o diff for the previous default output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Map keys that are not plain identifiers are written as ["key"] in
FieldChange paths, and the plan text prints them whole as one level, so
annotation and label keys like crossplane.io/composite no longer split
into nested levels.

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>
A comp run analyses composites per composition, so with several compositions
plan.Result could not say which composition a composite's impact belongs to.
CompositeResult now carries Composition, set by simulate.FromCompReport and
omitted for xr runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
The integration cases' structured assertions read crossplane-diff's v1/v2
schema, so the harness had to pass --output-version. They now read
plan.Result, which project simulate's -o json emits, behind the same builder
API, and the harness no longer passes --output-version.

WithOutputVersion is gone along with its two call sites. A field change whose
old and new values are equal has no leaf FieldChange in plan.Result, so it is
asserted as unchanged (no change at or under the path) rather than by value.
The diffprocessor test for a failed composition renders its JSON through
simulate.FromCompReport.

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>
WithKeepEqualDiffs names the composed resources whose diff the calculator
keeps even when equal, and makes an unchanged composite's impact carry its
diffs, so a caller can refine them further (for example, with provider
diff servers). The predicate travels on compare.Options, so the calculator
and its factory signatures are unchanged.

Nil, the default, keeps nothing and leaves every output as it was: every
HasChanges and renderer already skips equal diffs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
PlanDiffs plans the engine's resource diffs against the provider diff
servers a Planner routes to, once per diff key. Removals stay the engine's,
equal diffs are planned only when kept, and kinds no server serves keep
their engine plan, never approximate. Planner.CanPlan reports whether a
kind is routed.

FromXRReport and FromCompReport take those plans, nil meaning none ran, and
merge them by diff key: a provider error is an error plan, NotSupported marks
the engine's plan approximate, a provider create, update or replace takes
over the action and changes, and a kept-equal resource the provider finds
unchanged is dropped. A composite one of them changes becomes changed. The
not-run note stays only when plans is nil; the command picks
NoteProviderPlanSkipped or NoteNoDiffServers when it wires this in.

ProviderManaged is the predicate for the engine's WithKeepEqualDiffs: it
follows the CRD to the ProviderRevision controlling it, the lookup
RelevantProviderImages already did, now factored into one place. Changed is
the verdict over a merged plan.Result.

The command still passes nil.

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Add the generic, nil-default renderer hook DiffOptions.Replaced: a replaced
diff's header reads "(requires replacement)" and every summary appends
"(N requires replacement)". With a nil hook output is byte-identical.

Add simulate.AnnotateXRReport and AnnotateCompReport, which annotate a
copy of the engine report with provider plans as terraform does: a
forcing field's + line ends "  # forces replacement", computed fields
print "(known after apply)" (split from an equal line, rewritten on a +
line, or added under their deepest ancestor), and sensitive values print
"(sensitive value)". ReplacedBy builds the Replaced hook, nil for no
plans. An equal diff the provider would change renders as a modified
drift block (driftDiff, pending confirmation). Nothing calls these yet.

plan.FormatValue is exported for the drift block.

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>
The plan.Result assertion helpers move from internal/diff/testutils to
internal/simulate/simtest, and the integration tests (which run project
simulate) import them from there. The diffprocessor test for a failed
composition asserts on dt.CompDiffOutput instead of mapping it through
simulate.FromCompReport.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
plan.Result serializes no object for a plan, so an expectation whose old and
new values are equal can only check that nothing changed under the path.
Say so where the check is made.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
The debug counts string prints replaced= only when a diff is replaced, so
upstream's format is unchanged when there are none. The listed-change key now
includes a plan's error, approximate flag and replace paths. driftDiff's
comment is a plain doc comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Jonathan Ogilvie <jonathan.ogilvie@sumologic.com>
Every ResourcePlan now serializes the objects it compares, following
terraform's show -json change.before/after, so CI consumers can match a
plan to its input and render their own diff:

- before: the live object, absent for a create.
- after: the desired object, absent for a delete.

Project simulate uses the engine's Clean views, the same ones changes[]
is diffed from. Resource simulate and the provider/fallback paths use
the live and desired objects. A Secret's data and stringData values are
redacted in both. ResourcePlan.Desired, which nothing read, is replaced.
The result format version moves to 0.3.

The integration assertion for an unchanged field now checks its value
in before and after again, rather than only that nothing changed.

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