From 07ac89bf2c275292ba896afcb60f1fdb6081d1d8 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 17:59:42 -0400 Subject: [PATCH 001/126] feat(render): expose ErrStaleRenderBackend for outdated render backends 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 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/render/engine.go | 29 +++++++++++++++++++ cmd/crossplane/render/engine_docker.go | 2 +- cmd/crossplane/render/engine_docker_test.go | 31 +++++++++++++++++++++ cmd/crossplane/render/engine_local.go | 2 +- 4 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/render/engine.go b/cmd/crossplane/render/engine.go index be2f4405..654ab73a 100644 --- a/cmd/crossplane/render/engine.go +++ b/cmd/crossplane/render/engine.go @@ -17,9 +17,11 @@ limitations under the License. package render import ( + "bytes" "context" "fmt" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" @@ -30,6 +32,33 @@ import ( // DefaultCrossplaneImage is the default Crossplane image used for rendering. const DefaultCrossplaneImage = "xpkg.crossplane.io/crossplane/crossplane" +// ErrStaleRenderBackend reports that the render backend is a crossplane binary +// or image that predates `crossplane internal render`. Render errors carrying +// this condition match it with errors.Is. +var ErrStaleRenderBackend = errors.New("render backend predates `crossplane internal render`") + +// staleRenderBackendSignature is the kong usage error a crossplane CLI that +// lacks the `internal` command prints when asked to run `internal render`. It +// cannot come from a pipeline or connectivity failure on a current backend. +const staleRenderBackendSignature = "unexpected argument internal" + +// staleRenderBackendError marks err as ErrStaleRenderBackend without changing +// its message. +type staleRenderBackendError struct{ err error } + +func (e *staleRenderBackendError) Error() string { return e.err.Error() } +func (e *staleRenderBackendError) Unwrap() error { return e.err } +func (e *staleRenderBackendError) Is(target error) bool { return target == ErrStaleRenderBackend } + +// classifyRenderError marks err as ErrStaleRenderBackend when the backend's +// stderr carries the stale-backend signature, and otherwise returns it as is. +func classifyRenderError(err error, stderr []byte) error { + if err == nil || !bytes.Contains(stderr, []byte(staleRenderBackendSignature)) { + return err + } + return &staleRenderBackendError{err: err} +} + // An Engine executes a crossplane internal render request and returns the // response. type Engine interface { diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go index 1b813c20..02e8d8ba 100644 --- a/cmd/crossplane/render/engine_docker.go +++ b/cmd/crossplane/render/engine_docker.go @@ -200,7 +200,7 @@ func (e *dockerRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Ren } return rsp, errors.Errorf("crossplane internal render in Docker: pipeline returned fatal: %s", exitErr.Stderr) } - return nil, errors.Wrapf(err, "crossplane internal render in Docker returned error with output: %s", stderr) + return nil, classifyRenderError(errors.Wrapf(err, "crossplane internal render in Docker returned error with output: %s", stderr), stderr) } rsp := &renderv1alpha1.RenderResponse{} diff --git a/cmd/crossplane/render/engine_docker_test.go b/cmd/crossplane/render/engine_docker_test.go index c1f29992..7dee6438 100644 --- a/cmd/crossplane/render/engine_docker_test.go +++ b/cmd/crossplane/render/engine_docker_test.go @@ -18,6 +18,7 @@ package render import ( "context" + "fmt" "slices" "strings" "testing" @@ -81,6 +82,8 @@ func TestDockerRenderEngineRender(t *testing.T) { wantErr bool wantInErr []string wantSingleOccurrence []string + wantIs error + wantNotIs error } cases := map[string]struct { @@ -154,6 +157,25 @@ func TestDockerRenderEngineRender(t *testing.T) { "container exited with status 1", }, wantSingleOccurrence: []string{"the container is sad"}, + wantNotIs: ErrStaleRenderBackend, + }, + }, + "StaleRenderBackend": { + reason: "A backend that lacks `internal render` is reported as ErrStaleRenderBackend, matchable through further wrapping, with the stderr message unchanged.", + args: args{ + runFn: func(_ context.Context, _ string, _ ...docker.RunContainerOption) ([]byte, []byte, error) { + return nil, []byte("crossplane: error: unexpected argument internal"), &docker.ContainerExitError{ + ExitCode: 80, + Stderr: []byte("crossplane: error: unexpected argument internal"), + } + }, + }, + want: want{ + wantErr: true, + wantInErr: []string{ + "crossplane internal render in Docker returned error with output: crossplane: error: unexpected argument internal", + }, + wantIs: ErrStaleRenderBackend, }, }, "HardFailNonExitError": { @@ -211,6 +233,15 @@ func TestDockerRenderEngineRender(t *testing.T) { } } + // Wrap once more to check the sentinel survives callers' wrapping. + wrapped := fmt.Errorf("caller: %w", err) + if tc.want.wantIs != nil && !errors.Is(wrapped, tc.want.wantIs) { + t.Errorf("\n%s\nRender(...): errors.Is(%q, %v) = false, want true", tc.reason, err, tc.want.wantIs) + } + if tc.want.wantNotIs != nil && errors.Is(wrapped, tc.want.wantNotIs) { + t.Errorf("\n%s\nRender(...): errors.Is(%q, %v) = true, want false", tc.reason, err, tc.want.wantNotIs) + } + if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { t.Errorf("\n%s\nRender(...): -want, +got:\n%s", tc.reason, diff) } diff --git a/cmd/crossplane/render/engine_local.go b/cmd/crossplane/render/engine_local.go index 22277a61..20b9ce0f 100644 --- a/cmd/crossplane/render/engine_local.go +++ b/cmd/crossplane/render/engine_local.go @@ -85,7 +85,7 @@ func (e *localRenderEngine) Render(ctx context.Context, req *renderv1alpha1.Rend } return rsp, errors.Errorf("crossplane internal render: pipeline returned fatal: %s", stderr.String()) } - return nil, errors.Wrapf(err, "crossplane internal render returned error with output: %s", stderr.String()) + return nil, classifyRenderError(errors.Wrapf(err, "crossplane internal render returned error with output: %s", stderr.String()), stderr.Bytes()) } rsp := &renderv1alpha1.RenderResponse{} From b18b3ae4de905b12dbf1adf8ed3f8fe25c9bae1b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 17:59:42 -0400 Subject: [PATCH 002/126] feat(diff): port renderer, types and testutils from crossplane-diff 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 Signed-off-by: Jonathan Ogilvie --- go.mod | 18 +- go.sum | 30 +- internal/diff/renderer/comp_diff_renderer.go | 810 ++++++ .../diff/renderer/comp_diff_renderer_test.go | 1424 ++++++++++ internal/diff/renderer/diff_formatter.go | 753 ++++++ internal/diff/renderer/diff_formatter_test.go | 539 ++++ internal/diff/renderer/diff_renderer.go | 383 +++ internal/diff/renderer/diff_renderer_test.go | 497 ++++ internal/diff/renderer/dry_run_warnings.go | 211 ++ .../diff/renderer/dry_run_warnings_test.go | 310 +++ internal/diff/renderer/structured_renderer.go | 725 ++++++ .../diff/renderer/structured_renderer_test.go | 849 ++++++ internal/diff/renderer/types/types.go | 460 ++++ .../diff/testutils/envtestreaper/reaper.go | 120 + .../testutils/envtestreaper/reaper_test.go | 47 + internal/diff/testutils/mock_builder.go | 2230 ++++++++++++++++ internal/diff/testutils/mocks.go | 1169 +++++++++ .../diff/testutils/structured_assertions.go | 2307 +++++++++++++++++ .../testutils/structured_assertions_test.go | 764 ++++++ internal/diff/testutils/utils.go | 203 ++ internal/diff/testutils/xrd_crds.go | 91 + internal/diff/testutils/xrd_crds_test.go | 315 +++ internal/diff/types/types.go | 191 ++ 23 files changed, 14436 insertions(+), 10 deletions(-) create mode 100644 internal/diff/renderer/comp_diff_renderer.go create mode 100644 internal/diff/renderer/comp_diff_renderer_test.go create mode 100644 internal/diff/renderer/diff_formatter.go create mode 100644 internal/diff/renderer/diff_formatter_test.go create mode 100644 internal/diff/renderer/diff_renderer.go create mode 100644 internal/diff/renderer/diff_renderer_test.go create mode 100644 internal/diff/renderer/dry_run_warnings.go create mode 100644 internal/diff/renderer/dry_run_warnings_test.go create mode 100644 internal/diff/renderer/structured_renderer.go create mode 100644 internal/diff/renderer/structured_renderer_test.go create mode 100644 internal/diff/renderer/types/types.go create mode 100644 internal/diff/testutils/envtestreaper/reaper.go create mode 100644 internal/diff/testutils/envtestreaper/reaper_test.go create mode 100644 internal/diff/testutils/mock_builder.go create mode 100644 internal/diff/testutils/mocks.go create mode 100644 internal/diff/testutils/structured_assertions.go create mode 100644 internal/diff/testutils/structured_assertions_test.go create mode 100644 internal/diff/testutils/utils.go create mode 100644 internal/diff/testutils/xrd_crds.go create mode 100644 internal/diff/testutils/xrd_crds_test.go create mode 100644 internal/diff/types/types.go diff --git a/go.mod b/go.mod index 504796fb..b3c530a1 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,8 @@ require ( github.com/getkin/kin-openapi v0.149.0 github.com/go-git/go-billy/v5 v5.9.1 github.com/go-git/go-git/v5 v5.19.2 + github.com/go-logr/logr v1.4.4 + github.com/go-logr/stdr v1.2.2 github.com/gobuffalo/flect v1.0.3 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.22.1 @@ -34,6 +36,8 @@ require ( github.com/oapi-codegen/oapi-codegen/v2 v2.8.0 github.com/pkg/errors v0.9.1 github.com/posener/complete v1.2.3 + github.com/sergi/go-diff v1.4.0 + github.com/shirou/gopsutil/v4 v4.26.9 github.com/spf13/afero v1.15.0 github.com/willabides/kongplete v0.4.0 golang.org/x/mod v0.40.0 @@ -139,6 +143,7 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/dprotaso/go-yit v0.0.0-20250513223454-5ece0c5aa76c // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.11.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/evanphx/json-patch v5.9.11+incompatible // indirect @@ -154,9 +159,8 @@ require ( github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect - github.com/go-logr/logr v1.4.4 // indirect - github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/analysis v0.25.2 // indirect github.com/go-openapi/errors v0.22.7 // indirect github.com/go-openapi/jsonpointer v0.23.1 // indirect @@ -216,6 +220,7 @@ require ( github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/mattn/go-runewidth v0.0.27 // indirect @@ -243,6 +248,7 @@ require ( github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect @@ -254,7 +260,6 @@ require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.11.0 // indirect - github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/sigstore/cosign/v2 v2.6.3 // indirect @@ -276,6 +281,8 @@ require ( github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.2-0.20260407074541-7e8f69f906ef // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/tklauser/go-sysconf v0.4.0 // indirect + github.com/tklauser/numcpus v0.12.0 // indirect github.com/transparency-dev/formats v0.1.1 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect @@ -286,6 +293,7 @@ require ( github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect github.com/yuin/goldmark v1.8.4 // indirect github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect go.opentelemetry.io/otel v1.45.0 // indirect @@ -299,13 +307,13 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.56.0 // indirect golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.47.0 // indirect + golang.org/x/sys v0.48.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index f927e28b..236321ea 100644 --- a/go.sum +++ b/go.sum @@ -276,6 +276,8 @@ github.com/dprotaso/go-yit v0.0.0-20250513223454-5ece0c5aa76c h1:EMwsP/vaHQDLhAX github.com/dprotaso/go-yit v0.0.0-20250513223454-5ece0c5aa76c/go.mod h1:lHwJo6jMevQL9tNpW6vLyhkK13bYHBcoh9tUakMhbnE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.11.1 h1:2zpWRSQNVKN4eKsKO9eM1ILDgWfYMY9GwqRmK6XeQ/0= +github.com/ebitengine/purego v0.11.1/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/dot v1.11.0 h1:zsrhCuFHAJge/aZIC4N4LdHy5tqYu4tWEaUzIwdYj4Y= @@ -334,6 +336,8 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/analysis v0.25.2 h1:I0vy4n3alz+DHTiN1PRhCb7QZxkK6g5YmswZKv2TKuw= github.com/go-openapi/analysis v0.25.2/go.mod h1:Uhs1t/2XR10EnwONYILGEzw8gcfGIG5Xk5K2AxnhqDo= github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= @@ -439,6 +443,7 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84= @@ -568,6 +573,8 @@ github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhn github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss= github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= @@ -669,6 +676,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6 h1:jL3a8soXdzuTCcRnKhOmtcsVOObdDTFf4O2B403HPRU= +github.com/power-devops/perfstat v0.0.0-20260805114148-88456608a4f6/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= @@ -712,6 +721,8 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shirou/gopsutil/v4 v4.26.9 h1:CaBo/hBFqvlJoLvAQEKdHdf5GD97/MN2ACfyv63+2Ig= +github.com/shirou/gopsutil/v4 v4.26.9/go.mod h1:nKH+8wX2zxr/mDbsR+AAy6Qd2z4ZOQ7wfQaEp+yrYmg= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sigstore/cosign/v2 v2.6.3 h1:1W+rZWz0zkTfqmTmYBOQS/Jt97NKz1OCzxTV2YAp1+s= @@ -772,8 +783,8 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= @@ -790,6 +801,10 @@ github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC6 github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= +github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= +github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= +github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/transparency-dev/formats v0.1.1 h1:4bVHJc+KdBgpA1OJD1yjI+g0i5Z1graCppTMH8lWKJI= github.com/transparency-dev/formats v0.1.1/go.mod h1:qtZ8goRuJ8FTBG9c9+Bj0rn2rUG7eG/AUTkr+Aw3jFw= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= @@ -824,6 +839,8 @@ github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= @@ -892,8 +909,9 @@ go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -940,6 +958,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -947,6 +966,7 @@ golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -961,8 +981,8 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/internal/diff/renderer/comp_diff_renderer.go b/internal/diff/renderer/comp_diff_renderer.go new file mode 100644 index 00000000..32774ff1 --- /dev/null +++ b/internal/diff/renderer/comp_diff_renderer.go @@ -0,0 +1,810 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package renderer + +import ( + "encoding/json" + "fmt" + "io" + "slices" + "strings" + + sigsyaml "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +const ( + headerCompositionChanges = "=== Composition Changes ===" + headerAffectedResources = "=== Affected Composite Resources ===" + headerImpactAnalysis = "=== Impact Analysis ===" +) + +// CompDiffRenderer renders composition diff results. +// Both human-readable and structured (JSON/YAML) renderers implement this interface. +type CompDiffRenderer interface { + // RenderCompDiff renders the complete composition diff output. + // Top-level and tool errors go to DiffOptions.Stderr and are included in + // structured output payloads. Per-composition messages (diffs, "no changes", + // per-composition errors) go to DiffOptions.Stdout as part of the diff narrative. + RenderCompDiff(output *CompDiffOutput) error +} + +// DefaultCompDiffRenderer renders composition diffs in human-readable format. +type DefaultCompDiffRenderer struct { + logger logging.Logger + diffRenderer DiffRenderer + opts DiffOptions +} + +// NewDefaultCompDiffRenderer creates a new human-readable composition diff renderer. +func NewDefaultCompDiffRenderer(logger logging.Logger, diffRenderer DiffRenderer, opts DiffOptions) CompDiffRenderer { + return &DefaultCompDiffRenderer{ + logger: logger, + diffRenderer: diffRenderer, + opts: opts, + } +} + +// RenderCompDiff renders the composition diff in human-readable format. +// Top-level errors go to r.opts.Stderr. Per-composition output (diffs, status +// messages, per-composition errors) goes to r.opts.Stdout. +func (r *DefaultCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { + stdout := r.opts.Stdout + + for i, comp := range output.Compositions { + if i > 0 { + if _, err := fmt.Fprint(stdout, "\n"+strings.Repeat("=", 80)+"\n\n"); err != nil { + return errors.Wrap(err, "cannot write composition separator") + } + } + + if err := r.renderComposition(&comp); err != nil { + return err + } + } + + // One run-wide summary of unverified additions; see DiffRenderer.RenderDiffs for why it is derived + // at render time, and why the per-composition reuse of the diff renderer above does not emit it. + if err := writeWarnings(r.opts.Stderr, compDryRunWarnings(output)); err != nil { + return err + } + + // Write top-level errors to stderr + for _, e := range output.Errors { + if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } + + return nil +} + +// renderComposition renders every section belonging to a single composition: its own changes, +// followed — only if it processed successfully — by the sections describing its effect on composites. +// A composition that failed to process has no such sections to show; renderCompositionChanges +// reported the error in their place. +func (r *DefaultCompDiffRenderer) renderComposition(comp *CompositionDiff) error { + switch { + case r.opts.MinimizeComposition: + if err := r.renderMinimizedCompositionChanges(comp); err != nil { + return err + } + default: + if err := r.renderCompositionChanges(comp); err != nil { + return err + } + } + + if comp.Error == nil { + return r.renderCompositionImpact(comp) + } + + return nil +} + +// renderCompositionImpact renders the sections describing how a composition affects its composites: +// either the note standing in for them when their evaluation was skipped, or the affected-XR list and +// the downstream impact analysis. +func (r *DefaultCompDiffRenderer) renderCompositionImpact(comp *CompositionDiff) error { + if comp.ImpactAnalysisSkipped { + return r.renderSkippedComposition(comp) + } + + // Name the revision before the per-composite sections. A composed resource whose template reads + // the revision name shows a diff below with no other visible cause; this line is that cause. + if _, err := fmt.Fprint(r.opts.Stdout, revisionImpactMessage(comp.RevisionImpact)); err != nil { + return errors.Wrap(err, "cannot write revision impact message") + } + + // Render affected XRs list with status indicators + if err := r.renderAffectedResourcesList(comp); err != nil { + return err + } + + // Render impact analysis (downstream diffs) + return r.renderImpactAnalysis(comp) +} + +// renderSkippedComposition stands in for the affected-XR and impact-analysis sections when a +// composition's XRs were deliberately not evaluated. Both sections would be empty and misleading in +// that case, so say so once instead — then report the composites the tool did rule out locally, which +// no render was needed to establish. +func (r *DefaultCompDiffRenderer) renderSkippedComposition(comp *CompositionDiff) error { + if _, err := fmt.Fprint(r.opts.Stdout, skippedMessage(comp.RevisionImpact)); err != nil { + return errors.Wrap(err, "cannot write impact analysis skipped message") + } + + return r.renderFilteredUnderSkip(comp) +} + +// renderCompositionChanges renders the composition changes section. +func (r *DefaultCompDiffRenderer) renderCompositionChanges(comp *CompositionDiff) error { + stdout := r.opts.Stdout + + if _, err := fmt.Fprintf(stdout, headerCompositionChanges+"\n\n"); err != nil { + return errors.Wrap(err, "cannot write composition changes header") + } + + // Check for composition processing error first + if comp.Error != nil { + if _, err := fmt.Fprintf(stdout, "Error processing composition %s: %s\n\n", comp.Name, comp.Error.Message); err != nil { + return errors.Wrap(err, "cannot write composition error") + } + + return nil + } + + if comp.CompositionDiff == nil || comp.CompositionDiff.DiffType == dt.DiffTypeEqual { + if err := writeNoDisplayableChanges(stdout, comp); err != nil { + return err + } + + return nil + } + + diffs := map[string]*dt.ResourceDiff{ + fmt.Sprintf("Composition/%s", comp.Name): comp.CompositionDiff, + } + + // Identity-less group: the human renderer renders it as a flat, header-less + // block, preserving comp's output. Comp provides its own XR grouping via + // impact analysis one level up. + if err := r.diffRenderer.RenderDiffs(identitylessGroups(diffs), nil, nil); err != nil { + return errors.Wrap(err, "cannot render composition diff") + } + + if _, err := fmt.Fprintf(stdout, "\n"); err != nil { + return errors.Wrap(err, "cannot write separator") + } + + return nil +} + +// skippedMessage explains why the composites were not evaluated, which depends on why the analysis +// was skipped. The two reasons are not interchangeable: an identical composition creates no +// CompositionRevision and so genuinely cannot affect anything, whereas a metadata-only change does +// create one — the user simply asked not to pay for evaluating it. Reporting the first message for +// the second case would claim a guarantee the tool did not establish. +func skippedMessage(impact RevisionImpact) string { + if impact.CreatesRevision { + return fmt.Sprintf("Impact analysis skipped: --analyze-on=spec-change and this composition's spec is unchanged. Applying it still creates a new CompositionRevision that %d composite%s would adopt; whether that changes any rendered output was not evaluated. Pass --analyze-on=any-change to check.\n\n", + impact.RepointedComposites, pluralize(impact.RepointedComposites)) + } + + return "Impact analysis skipped: this composition is identical to the cluster's, so applying it creates no new CompositionRevision and no composite resource could change as a result. Pass --analyze-on=always to evaluate them anyway.\n\n" +} + +// renderFilteredUnderSkip reports the composites ruled out as not adopting the resulting revision, for +// a composition whose impact analysis was skipped. Deciding that needs no render, so the skip must not +// suppress it: --analyze-on governs how much work is done, never whether a real consequence is +// reported (issue #478). What is withheld is the changed/unchanged/errored summary +// renderAffectedResourcesList prints, which would be unearned here — those composites went +// unevaluated. +// +// As elsewhere, --resource mode names each composite (the user asked about them by name, and their +// FilterReason reached ImpactAnalysis) while default discovery reports the per-reason breakdown. +// +// ImpactAnalysis is listed as-is, not re-filtered by status: under a skip it holds only the filtered +// entries, because the kept composites were never rendered. That is the processor's guarantee, pinned +// by its SkipReportsFilterConsequences test. Re-filtering here would not add safety — it would hide an +// unearned changed/unchanged verdict from this output while it still reached the structured output. +func (r *DefaultCompDiffRenderer) renderFilteredUnderSkip(comp *CompositionDiff) error { + filtered := totalFiltered(comp.AffectedResources) + if filtered == 0 { + return nil + } + + stdout := r.opts.Stdout + + if _, err := fmt.Fprintf(stdout, "%d of %d XR(s) using composition %s would not adopt this revision: %s\n", + filtered, comp.AffectedResources.Total, comp.Name, filteredClauses(comp.AffectedResources)); err != nil { + return errors.Wrap(err, "cannot write filtered XRs summary") + } + + if len(comp.ImpactAnalysis) > 0 { + if _, err := fmt.Fprint(stdout, r.buildXRStatusList(comp.ImpactAnalysis)); err != nil { + return errors.Wrap(err, "cannot write filtered XRs list") + } + } + + if _, err := fmt.Fprintln(stdout); err != nil { + return errors.Wrap(err, "cannot write filtered XRs separator") + } + + return nil +} + +// revisionImpactMessage reports the revision churn applying this composition causes, for the run where +// the composites *were* evaluated. It is what keeps a downstream diff interpretable: the composites' own +// compositionRevisionRef is never shown changing (the new ref reaches only their render input), so a +// composed resource whose template reads the revision name would otherwise change with no visible +// cause. See issue #474. +// +// Deliberately does NOT name the revision, even though PredictedRevisionName is available here. The +// name's suffix is a hash of the composition's content, so putting it in human output would make +// otherwise-stable output churn on any composition edit — and it buys the reader nothing, because the +// diff body already shows the value wherever it actually propagates. Consumers that want the name read +// revisionImpact.predictedRevisionName from structured output, where a hash is not a readability +// problem. For the same reason the unpredictable case is not distinguished here; the warning on stderr +// says so, which is the right channel for "this could not be checked". +// +// Empty unless a revision is created AND some composite would adopt it: with nothing adopting it there +// is no downstream diff to explain, and the sections around this one already report the composition as +// new or changed. +// +// The skipped path does not use this: skippedMessage carries the same fact in its own sentence, so +// emitting both would state it twice. +func revisionImpactMessage(impact RevisionImpact) string { + if !impact.CreatesRevision || impact.RepointedComposites == 0 { + return "" + } + + return fmt.Sprintf("Applying this composition creates a new CompositionRevision, which %d composite%s would adopt.\n\n", + impact.RepointedComposites, pluralize(impact.RepointedComposites)) +} + +// writeNoDisplayableChanges reports a composition with no diff body to show. That covers two +// different situations, and conflating them would misreport the second: the composition really is +// identical, or it differs only in fields excluded from the diff. The latter is still a change — +// applying it produces a new CompositionRevision that affected composites adopt — so it must not be +// announced as "no changes". +func writeNoDisplayableChanges(stdout io.Writer, comp *CompositionDiff) error { + if comp.MaskedChangesOnly { + if _, err := fmt.Fprintf(stdout, "Composition %s differs only in fields excluded from this diff (--ignore-paths, or fields suppressed for readability). That is still a change: applying it creates a new CompositionRevision.\n\n", comp.Name); err != nil { + return errors.Wrap(err, "cannot write masked-changes message") + } + + return nil + } + + if _, err := fmt.Fprintf(stdout, "No changes detected in composition %s\n\n", comp.Name); err != nil { + return errors.Wrap(err, "cannot write no changes message") + } + + return nil +} + +// renderMinimizedCompositionChanges renders a single marker line per composition +// instead of the full YAML diff body. Errors and no-change compositions are +// shown as usual (i.e., not minimized); only changed compositions are collapsed. +func (r *DefaultCompDiffRenderer) renderMinimizedCompositionChanges(comp *CompositionDiff) error { + stdout := r.opts.Stdout + + if _, err := fmt.Fprintf(stdout, headerCompositionChanges+"\n\n"); err != nil { + return errors.Wrap(err, "cannot write composition changes header") + } + + if comp.Error != nil { + if _, err := fmt.Fprintf(stdout, "Error processing composition %s: %s\n\n", comp.Name, comp.Error.Message); err != nil { + return errors.Wrap(err, "cannot write composition error") + } + + return nil + } + + if comp.CompositionDiff == nil || comp.CompositionDiff.DiffType == dt.DiffTypeEqual { + if err := writeNoDisplayableChanges(stdout, comp); err != nil { + return err + } + + return nil + } + + marker := strings.Repeat(string(comp.CompositionDiff.DiffType), 3) + + color, resetColor := "", "" + + if r.opts.UseColors { + switch comp.CompositionDiff.DiffType { + case dt.DiffTypeModified: + color = dt.ColorYellow + case dt.DiffTypeAdded: + color = dt.ColorGreen + case dt.DiffTypeRemoved: + color = dt.ColorRed + case dt.DiffTypeEqual: + // no color for equal + } + + resetColor = dt.ColorReset + } + + if _, err := fmt.Fprintf(stdout, "%s%s Composition/%s (minimized)%s\n\n", color, marker, comp.Name, resetColor); err != nil { + return errors.Wrap(err, "cannot write minimized composition line") + } + + return nil +} + +// renderAffectedResourcesList renders the affected XRs list with status indicators. +func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *CompositionDiff) error { + stdout := r.opts.Stdout + + if len(comp.ImpactAnalysis) == 0 { + // No XRs surfaced. Either none were found, or all matched-by-name XRs were filtered out + // (by Manual policy, revision-selector mismatch, and/or being deleted); report the + // breakdown if so. + switch { + case totalFiltered(comp.AffectedResources) > 0: + if _, err := fmt.Fprintf(stdout, "%s\n", allFilteredMessage(comp.Name, comp.AffectedResources)); err != nil { + return errors.Wrap(err, "cannot write filtered XRs message") + } + default: + if _, err := fmt.Fprintf(stdout, "No XRs found using composition %s\n", comp.Name); err != nil { + return errors.Wrap(err, "cannot write no XRs message") + } + } + + return nil + } + + // Build the XR list with status indicators + xrList := r.buildXRStatusList(comp.ImpactAnalysis) + + // Generate summary line + summary := formatXRStatusSummary( + comp.AffectedResources.WithChanges, + comp.AffectedResources.Unchanged, + comp.AffectedResources.WithErrors, + ) + + // Write the XR list with summary + if _, err := fmt.Fprintf(stdout, headerAffectedResources+"\n\n%s%s\n", xrList, summary); err != nil { + return errors.Wrap(err, "cannot write XR list") + } + + return nil +} + +// renderImpactAnalysis renders the impact analysis section with downstream diffs. +func (r *DefaultCompDiffRenderer) renderImpactAnalysis(comp *CompositionDiff) error { + stdout := r.opts.Stdout + + if _, err := fmt.Fprintf(stdout, headerImpactAnalysis+"\n\n"); err != nil { + return errors.Wrap(err, "cannot write impact analysis header") + } + + // Collect all diffs from the impact analysis using stored ResourceDiffs. + allDiffs := make(map[string]*dt.ResourceDiff) + + for _, impact := range comp.ImpactAnalysis { + if impact.Status == XRStatusChanged && impact.Diffs != nil { + for key, diff := range impact.Diffs { + // Skip equal diffs (may be stored for removal detection purposes) + if diff.DiffType != dt.DiffTypeEqual { + allDiffs[key] = diff + } + } + } + } + + // Render all diffs if we found some, or show a message if empty. + // Identity-less group: rendered flat (no per-XR header); comp groups via + // impact analysis one level up. + if len(allDiffs) > 0 { + if err := r.diffRenderer.RenderDiffs(identitylessGroups(allDiffs), nil, nil); err != nil { + r.logger.Debug("Failed to render diffs", "error", err) + return errors.Wrap(err, "failed to render diffs") + } + } else { + if _, err := fmt.Fprint(stdout, "All composite resources are up-to-date. No downstream resource changes detected.\n\n"); err != nil { + return errors.Wrap(err, "cannot write empty impact message") + } + } + + return nil +} + +// totalFiltered sums the per-reason filter counters. Kept in one place so adding a reason cannot +// leave a caller silently ignoring it. +func totalFiltered(summary AffectedResourcesSummary) int { + return summary.FilteredByPolicy + summary.FilteredBySelector + summary.FilteredByDeletion +} + +// allFilteredMessage builds the default-discovery summary line for the case where every +// matched-by-name XR was filtered out, breaking the total down by reason so users understand why +// nothing is shown and how to see more. Single-reason cases get bespoke prose that names the +// remedy; mixed reasons fall through to an enumerated breakdown. Only called when at least one +// XR was filtered. +func allFilteredMessage(compName string, summary AffectedResourcesSummary) string { + byPolicy, bySelector, byDeletion := summary.FilteredByPolicy, summary.FilteredBySelector, summary.FilteredByDeletion + total := totalFiltered(summary) + + 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)", + total, compName) + case byPolicy == 0 && byDeletion == 0: + return fmt.Sprintf("All %d XR(s) using composition %s have a compositionRevisionSelector that does not match the composition's labels, so they would not adopt this revision", + total, compName) + case byPolicy == 0 && bySelector == 0: + return fmt.Sprintf("All %d XR(s) using composition %s are being deleted, so they would not adopt this revision", + total, compName) + } + + return fmt.Sprintf("All %d XR(s) using composition %s were filtered: %s", + total, compName, filteredClauses(summary)) +} + +// filteredClauses enumerates the per-reason filter counters as a comma-separated breakdown (e.g. +// "1 with Manual update policy (use --include-manual to see them), 2 being deleted"), omitting reasons +// with no XRs. Shared by allFilteredMessage's mixed-reason case and renderFilteredUnderSkip so a newly +// added reason is described identically in both. +func filteredClauses(summary AffectedResourcesSummary) string { + clauses := make([]string, 0, 3) + + for _, c := range []struct { + count int + text string + }{ + {summary.FilteredByPolicy, "with Manual update policy (use --include-manual to see them)"}, + {summary.FilteredBySelector, "with a compositionRevisionSelector that does not match the composition's labels"}, + {summary.FilteredByDeletion, "being deleted"}, + } { + if c.count > 0 { + clauses = append(clauses, fmt.Sprintf("%d %s", c.count, c.text)) + } + } + + return strings.Join(clauses, ", ") +} + +// filteredSuffix returns the human-readable explanation appended to a filtered XR line, chosen by +// the XR's FilterReason. Selector-mismatch entries additionally surface the concrete FilterDetail +// hint (which selector failed to match which labels) so users can self-diagnose the exclusion. +func filteredSuffix(impact XRImpact) string { + switch impact.FilterReason { + case FilterReasonManualPolicy: + return " — filtered: Manual update policy (use --include-manual to evaluate)" + case FilterReasonRevisionSelectorMismatch: + if impact.FilterDetail != "" { + return fmt.Sprintf(" — filtered: revision selector mismatch (%s)", impact.FilterDetail) + } + + return " — filtered: revision selector mismatch" + case FilterReasonDeleting: + if impact.FilterDetail != "" { + return fmt.Sprintf(" — filtered: being deleted (%s)", impact.FilterDetail) + } + + return " — filtered: being deleted" + default: + return " — filtered" + } +} + +// buildXRStatusList builds the XR list with status indicators. +func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []XRImpact) string { + var sb strings.Builder + + // Color codes and indicators + checkMark := "\u2713" + warningMark := "\u26a0" + errorMark := "\u2717" + colorGreen := "" + colorYellow := "" + colorRed := "" + colorReset := "" + + if r.opts.UseColors { + colorGreen = dt.ColorGreen + colorYellow = dt.ColorYellow + colorRed = dt.ColorRed + colorReset = dt.ColorReset + } + + for _, impact := range impacts { + // Format namespace/scope information + scope := fmt.Sprintf("namespace: %s", impact.Namespace) + if impact.Namespace == "" { + scope = "cluster-scoped" + } + + // Determine status indicator and color based on status + var ( + indicator, color string + suffix string + ) + + switch impact.Status { + case XRStatusError: + indicator = errorMark + color = colorRed + case XRStatusChanged: + indicator = warningMark + color = colorYellow + case XRStatusUnchanged: + indicator = checkMark + color = colorGreen + case XRStatusFiltered: + indicator = "⊘" // ⊘ + color = colorYellow + suffix = filteredSuffix(impact) + } + + fmt.Fprintf(&sb, "%s %s %s/%s (%s)%s%s\n", + color, + indicator, + impact.Kind, impact.Name, scope, + suffix, + colorReset) + + // Include error details for XRStatusError impacts so users can diagnose issues. + if impact.Status == XRStatusError && impact.Error != nil { + fmt.Fprintf(&sb, "%s Error: %s%s\n", color, impact.Error.Message, colorReset) + } + } + + return sb.String() +} + +// StructuredCompDiffRenderer renders composition diffs in JSON/YAML format. +type StructuredCompDiffRenderer struct { + logger logging.Logger + opts DiffOptions +} + +// NewStructuredCompDiffRenderer creates a new structured composition diff renderer. +func NewStructuredCompDiffRenderer(logger logging.Logger, opts DiffOptions) CompDiffRenderer { + return &StructuredCompDiffRenderer{ + logger: logger, + opts: opts, + } +} + +// RenderCompDiff renders the composition diff in structured format (JSON/YAML). +// Top-level errors go to both r.opts.Stderr (for human visibility) and the +// structured output payload. Per-composition data goes to r.opts.Stdout. +func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { + // One run-wide summary of unverified additions, derived here from the diffs and appended after the + // warnings raised during the run. See DiffRenderer.RenderDiffs. + dryRun := compDryRunWarnings(output) + + // Convert internal representation to JSON output structure + warnings := append(slices.Clone(output.Warnings), dryRun...) + + v2 := buildStructuredCompOutput(output) + v2.Warnings = warnings + + // Anything but v2, including the empty value, is v1. + var ( + jsonOutput any = v2 + errs = v2.Errors + ) + + if r.opts.OutputVersion != OutputVersionV2 { + v1 := v2.v1() + jsonOutput, errs = v1, v1.Errors + } + + var ( + data []byte + err error + ) + + switch r.opts.Format { + case OutputFormatJSON: + data, err = json.MarshalIndent(jsonOutput, "", " ") + case OutputFormatYAML: + data, err = sigsyaml.Marshal(jsonOutput) + case OutputFormatDiff: + fallthrough + default: + return errors.Errorf("unsupported format for structured comp diff renderer: %s", r.opts.Format) + } + + if err != nil { + return errors.Wrap(err, "failed to marshal comp diff output") + } + + _, err = r.opts.Stdout.Write(append(data, '\n')) + if err != nil { + return errors.Wrap(err, "failed to write output") + } + + // Of the warnings, only the ones derived here go to stderr: the rest went there when raised. + if err := writeWarnings(r.opts.Stderr, dryRun); err != nil { + return err + } + + // Write errors to stderr for human visibility (they're also included in the structured output) + for _, e := range errs { + if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } + + return nil +} + +// buildStructuredCompOutput converts internal CompDiffOutput to the v2 document, from which v1 is +// derived (see compDiffWireV2.v1). Each failed composition's error is appended to the top-level +// errors, after the processor's, so that list is the union of every error in the document. +func buildStructuredCompOutput(output *CompDiffOutput) *compDiffWireV2 { + result := &compDiffWireV2{ + OutputVersion: OutputVersionV2, + Compositions: make([]compositionDiffWireV2, 0, len(output.Compositions)), + Errors: slices.Clone(output.Errors), + } + + for _, comp := range output.Compositions { + jsonComp := compositionDiffWireV2{ + Name: comp.Name, + AffectedResources: comp.AffectedResources, + ImpactAnalysis: make([]xrEntryWire, 0, len(comp.ImpactAnalysis)), + ImpactAnalysisSkipped: comp.ImpactAnalysisSkipped, + MaskedChangesOnly: comp.MaskedChangesOnly, + } + + // Omit revisionImpact entirely when the comparison never completed, rather than serializing a + // zero value that would claim changeScope "" and createsRevision false. See + // RevisionImpact.determined and issue #479. + if comp.RevisionImpact.determined() { + revisionImpact := comp.RevisionImpact + jsonComp.RevisionImpact = &revisionImpact + } + + if comp.Error != nil { + jsonComp.Errors = []dt.OutputError{*comp.Error} + result.Errors = append(result.Errors, *comp.Error) + } + + if comp.CompositionDiff != nil && comp.CompositionDiff.DiffType != dt.DiffTypeEqual { + jsonComp.CompositionChanges = resourceDiffToChangeDetail(comp.CompositionDiff) + } + + for _, impact := range comp.ImpactAnalysis { + entry := toXREntry(impact.ObjectReference, impact.Status, impact.Diffs, impact.Error) + entry.FilterReason = impact.FilterReason + entry.FilterDetail = impact.FilterDetail + jsonComp.ImpactAnalysis = append(jsonComp.ImpactAnalysis, entry) + } + + result.Compositions = append(result.Compositions, jsonComp) + } + + return result +} + +// v1 projects the document to v1. Its compositions and their impactAnalysis[] entries take the v1 +// shape, and its top-level errors drop the failed compositions' errors, which v1 reports only on the +// composition. buildStructuredCompOutput appends those last, one per failed composition, so they are +// the list's tail. +func (d *compDiffWireV2) v1() *compDiffWire { + failed := 0 + result := &compDiffWire{ + OutputVersion: OutputVersionV1, + Compositions: make([]compositionDiffWire, 0, len(d.Compositions)), + Warnings: d.Warnings, + } + + for _, comp := range d.Compositions { + if len(comp.Errors) > 0 { + failed++ + } + + result.Compositions = append(result.Compositions, comp.v1()) + } + + result.Errors = d.Errors[:len(d.Errors)-failed] + + return result +} + +// v1 projects a composition to v1, where its error is a string rather than errors[]. +func (c compositionDiffWireV2) v1() compositionDiffWire { + result := compositionDiffWire{ + Name: c.Name, + Error: firstErrorMessage(c.Errors), + CompositionChanges: c.CompositionChanges, + AffectedResources: c.AffectedResources, + ImpactAnalysis: make([]xrImpactWire, 0, len(c.ImpactAnalysis)), + ImpactAnalysisSkipped: c.ImpactAnalysisSkipped, + MaskedChangesOnly: c.MaskedChangesOnly, + RevisionImpact: c.RevisionImpact, + } + + for _, entry := range c.ImpactAnalysis { + result.ImpactAnalysis = append(result.ImpactAnalysis, entry.v1()) + } + + return result +} + +// v1 projects the entry to comp's v1 impactAnalysis[] entry: identity inlined, the error as a string, +// and summary and changes wrapped in downstreamChanges, present only when there are changes. +func (e xrEntryWire) v1() xrImpactWire { + result := xrImpactWire{ + ObjectReference: e.XR, + Status: e.Status, + FilterReason: e.FilterReason, + FilterDetail: e.FilterDetail, + Error: firstErrorMessage(e.Errors), + } + + if e.Status == XRStatusChanged && len(e.Changes) > 0 { + result.DownstreamChanges = &DownstreamChanges{Summary: e.Summary, Changes: e.Changes} + } + + return result +} + +// firstErrorMessage returns the message of the one error v2 carries where v1 had an error string, or +// "" when there is none. +func firstErrorMessage(errs []dt.OutputError) string { + if len(errs) == 0 { + return "" + } + + return errs[0].Message +} + +// formatXRStatusSummary generates the summary line with correct pluralization. +func formatXRStatusSummary(changedCount, unchangedCount, errorCount int) string { + parts := []string{} + + if changedCount > 0 { + parts = append(parts, fmt.Sprintf("%d resource%s with changes", changedCount, pluralize(changedCount))) + } + + if unchangedCount > 0 { + parts = append(parts, fmt.Sprintf("%d resource%s unchanged", unchangedCount, pluralize(unchangedCount))) + } + + if errorCount > 0 { + parts = append(parts, fmt.Sprintf("%d resource%s with errors", errorCount, pluralize(errorCount))) + } + + if len(parts) == 0 { + return "" + } + + return fmt.Sprintf("\nSummary: %s\n", strings.Join(parts, ", ")) +} + +// pluralize returns "s" if count is not 1, otherwise returns empty string. +func pluralize(count int) string { + if count == 1 { + return "" + } + + return "s" +} diff --git a/internal/diff/renderer/comp_diff_renderer_test.go b/internal/diff/renderer/comp_diff_renderer_test.go new file mode 100644 index 00000000..4e72b7c7 --- /dev/null +++ b/internal/diff/renderer/comp_diff_renderer_test.go @@ -0,0 +1,1424 @@ +package renderer + +import ( + "bytes" + "encoding/json" + "reflect" + "strings" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +// testCompDiffFixture defines a reusable test case with input and expected output. +type testCompDiffFixture struct { + name string + output *CompDiffOutput + minimize bool + validate func(t *testing.T, format OutputFormat, result string) +} + +// bothViews returns a ResourceViews whose Raw and Clean both point at obj. Test +// fixtures use it when the object has no ignorable / server-side fields, so the +// cleaned view equals the raw one. +func bothViews(obj *un.Unstructured) dt.ResourceViews { + return dt.ResourceViews{Raw: obj, Clean: obj} +} + +// sharedCompDiffFixtures returns test fixtures that should be run through both JSON and YAML renderers. +func sharedCompDiffFixtures() []testCompDiffFixture { + return []testCompDiffFixture{ + { + name: "EmptyCompositions", + output: &CompDiffOutput{Compositions: []CompositionDiff{}}, + validate: func(t *testing.T, format OutputFormat, result string) { + t.Helper() + + if format == OutputFormatJSON { + var parsed compDiffWire + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if len(parsed.Compositions) != 0 { + t.Errorf("Expected 0 compositions, got %d", len(parsed.Compositions)) + } + } else if !strings.Contains(result, "compositions:") { + t.Error("Expected YAML to contain 'compositions:'") + } + }, + }, + { + name: "WithChanges", + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + CompositionDiff: &dt.ResourceDiff{ + DiffType: dt.DiffTypeAdded, + ResourceName: "test-comp", + Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, + Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), + }, + AffectedResources: AffectedResourcesSummary{Total: 2, WithChanges: 1, Unchanged: 1}, + ImpactAnalysis: []XRImpact{ + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusChanged}, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-2"}, Status: XRStatusUnchanged}, + }, + }}, + }, + validate: func(t *testing.T, format OutputFormat, result string) { + t.Helper() + + if format == OutputFormatJSON { + var parsed compDiffWire + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if len(parsed.Compositions) != 1 { + t.Fatalf("Expected 1 composition, got %d", len(parsed.Compositions)) + } + + if parsed.Compositions[0].Name != "test-comp" { + t.Errorf("Expected name 'test-comp', got '%s'", parsed.Compositions[0].Name) + } + + if parsed.Compositions[0].AffectedResources.Total != 2 { + t.Errorf("Expected total 2, got %d", parsed.Compositions[0].AffectedResources.Total) + } + + // Verify compositionChanges is present in JSON + if parsed.Compositions[0].CompositionChanges == nil { + t.Error("Expected compositionChanges to be present") + } + + // Verify embedded ObjectReference fields are at top level (not nested) + // This tests that json:",inline" works correctly + var rawParsed map[string]any + if err := json.Unmarshal([]byte(result), &rawParsed); err != nil { + t.Fatalf("Failed to parse raw JSON: %v", err) + } + + comps, ok := rawParsed["compositions"].([]any) + if !ok { + t.Fatalf("Expected 'compositions' to be array, got %T", rawParsed["compositions"]) + } + + comp, ok := comps[0].(map[string]any) + if !ok { + t.Fatalf("Expected compositions[0] to be object, got %T", comps[0]) + } + + impacts, ok := comp["impactAnalysis"].([]any) + if !ok { + t.Fatalf("Expected 'impactAnalysis' to be array, got %T", comp["impactAnalysis"]) + } + + impact, ok := impacts[0].(map[string]any) + if !ok { + t.Fatalf("Expected impacts[0] to be object, got %T", impacts[0]) + } + + // Verify apiVersion, kind, name are top-level keys (not nested under "objectReference") + if _, ok := impact["apiVersion"]; !ok { + t.Error("Expected 'apiVersion' to be a top-level field in xrImpactWire (embedded from ObjectReference)") + } + + if _, ok := impact["kind"]; !ok { + t.Error("Expected 'kind' to be a top-level field in xrImpactWire (embedded from ObjectReference)") + } + + if _, ok := impact["name"]; !ok { + t.Error("Expected 'name' to be a top-level field in xrImpactWire (embedded from ObjectReference)") + } + + // ObjectReference should NOT be nested + if _, ok := impact["objectReference"]; ok { + t.Error("ObjectReference should be inlined, not a nested field") + } + } else { + if !strings.Contains(result, "compositions:") { + t.Error("Expected YAML to contain 'compositions:'") + } + + if !strings.Contains(result, "name: test-comp") { + t.Error("Expected YAML to contain 'name: test-comp'") + } + } + }, + }, + { + // MinimizeComposition is a human-output affordance only: structured + // (JSON/YAML) output must always retain full compositionChanges. + name: "MinimizeKeepsStructuredFidelity", + minimize: true, + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + CompositionDiff: &dt.ResourceDiff{ + DiffType: dt.DiffTypeModified, + ResourceName: "test-comp", + Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, + Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), + Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), + }, + AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusUnchanged}}, + }}, + }, + validate: func(t *testing.T, format OutputFormat, result string) { + t.Helper() + + if format == OutputFormatJSON { + var parsed compDiffWire + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if parsed.Compositions[0].CompositionChanges == nil { + t.Error("expected compositionChanges preserved in structured output when minimized") + } + + if len(parsed.Compositions[0].ImpactAnalysis) != 1 { + t.Errorf("expected impact analysis preserved, got %d", len(parsed.Compositions[0].ImpactAnalysis)) + } + } else if !strings.Contains(result, "compositionChanges:") { + t.Error("expected YAML to retain 'compositionChanges:' when minimized") + } + }, + }, + { + // Issue #479: a composition that failed to process never completed the comparison, so nothing + // about its revision impact was determined. Serializing the zero value would put changeScope + // outside its "none"/"metadata"/"spec" enum and assert createsRevision: false about an apply + // the tool never evaluated. The object is omitted instead; a composition that *was* compared + // keeps it, including when the composites went unevaluated. + name: "FailedCompositionOmitsRevisionImpact", + output: &CompDiffOutput{ + Compositions: []CompositionDiff{ + { + Name: "compared.example.org", + ImpactAnalysisSkipped: true, + RevisionImpact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + ImpactAnalysis: []XRImpact{}, + }, + { + Name: "broken.example.org", + Error: &dt.OutputError{Message: "cannot calculate composition diff"}, + ImpactAnalysis: []XRImpact{}, + }, + }, + }, + validate: func(t *testing.T, format OutputFormat, result string) { + t.Helper() + + if format != OutputFormatJSON { + // YAML shares the JSON tags, so absence of the key is the whole assertion here. + if strings.Count(result, "revisionImpact:") != 1 { + t.Errorf("expected exactly one revisionImpact block in YAML, got:\n%s", result) + } + + return + } + + var parsed compDiffWire + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if len(parsed.Compositions) != 2 { + t.Fatalf("expected 2 compositions, got %d", len(parsed.Compositions)) + } + + compared, broken := parsed.Compositions[0], parsed.Compositions[1] + + want := &RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1} + if diff := gcmp.Diff(want, compared.RevisionImpact); diff != "" { + t.Errorf("compared composition revisionImpact mismatch (-want +got):\n%s", diff) + } + + if broken.RevisionImpact != nil { + t.Errorf("failed composition should omit revisionImpact, got %+v", *broken.RevisionImpact) + } + }, + }, + } +} + +func TestStructuredCompDiffRenderer_RenderCompDiff(t *testing.T) { + formats := []OutputFormat{OutputFormatJSON, OutputFormatYAML} + fixtures := sharedCompDiffFixtures() + + for _, format := range formats { + for _, fixture := range fixtures { + testName := string(format) + "/" + fixture.name + t.Run(testName, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var buf bytes.Buffer + + opts := DefaultDiffOptions() + opts.Format = format + opts.MinimizeComposition = fixture.minimize + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} // discard stderr + + renderer := NewStructuredCompDiffRenderer(logger, opts) + + err := renderer.RenderCompDiff(fixture.output) + if err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + fixture.validate(t, format, buf.String()) + }) + } + } +} + +func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { + tests := map[string]struct { + output *CompDiffOutput + colorize bool + minimize bool + validate func(t *testing.T, result string) + }{ + "EmptyCompositions": { + output: &CompDiffOutput{Compositions: []CompositionDiff{}}, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if result != "" { + t.Errorf("Expected empty output, got: %q", result) + } + }, + }, + "NoChangesComposition": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusUnchanged}}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "=== Composition Changes ===") { + t.Error("Expected composition changes header") + } + + if !strings.Contains(result, "No changes detected in composition test-comp") { + t.Error("Expected no changes message") + } + + if !strings.Contains(result, "=== Affected Composite Resources ===") { + t.Error("Expected affected resources header") + } + }, + }, + "FilteredByPolicy": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + AffectedResources: AffectedResourcesSummary{FilteredByPolicy: 3}, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "Manual update policy") { + t.Error("Expected Manual update policy message") + } + }, + }, + "FilteredBySelectorOnly": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + AffectedResources: AffectedResourcesSummary{FilteredBySelector: 2}, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "compositionRevisionSelector") { + t.Errorf("Expected compositionRevisionSelector message, got %q", result) + } + }, + }, + "FilteredByPolicyAndSelector": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + AffectedResources: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2}, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "Manual update policy") || !strings.Contains(result, "compositionRevisionSelector") { + t.Errorf("Expected both Manual-policy and selector messages, got %q", result) + } + }, + }, + "CompositionWithError": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "error-comp", + Error: &dt.OutputError{Message: "failed to fetch composition from cluster"}, + AffectedResources: AffectedResourcesSummary{ + Total: 0, + WithChanges: 0, + Unchanged: 0, + WithErrors: 0, + }, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + // Should show the error message + if !strings.Contains(result, "Error processing composition error-comp") { + t.Error("Expected error processing message") + } + + if !strings.Contains(result, "failed to fetch composition from cluster") { + t.Error("Expected error details") + } + + // Should NOT show affected resources or impact analysis sections + if strings.Contains(result, "=== Affected Composite Resources ===") { + t.Error("Should not show affected resources header when composition has error") + } + + if strings.Contains(result, "=== Impact Analysis ===") { + t.Error("Should not show impact analysis header when composition has error") + } + }, + }, + "MinimizeChangedComposition": { + // A changed composition collapses to a single marker line (no YAML body, + // no per-composition Summary footer), while the impact sections remain. + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + CompositionDiff: &dt.ResourceDiff{DiffType: dt.DiffTypeModified, ResourceName: "test-comp", Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}}, + AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusUnchanged}}, + }}, + }, + colorize: false, + minimize: true, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "=== Composition Changes ===") { + t.Errorf("expected composition changes header, got: %q", result) + } + + if !strings.Contains(result, "~~~ Composition/test-comp (minimized)") { + t.Errorf("expected minimized marker line, got: %q", result) + } + + if !strings.Contains(result, "=== Affected Composite Resources ===") { + t.Errorf("expected affected resources section, got: %q", result) + } + + if !strings.Contains(result, "=== Impact Analysis ===") { + t.Errorf("expected impact analysis section, got: %q", result) + } + + if strings.Contains(result, "Summary: 1 modified") { + t.Errorf("expected no composition Summary footer when minimized, got: %q", result) + } + }, + }, + // Issue #478: which composites would not adopt the resulting revision is settled locally, with no + // render, so a skipped impact analysis must still report it. What the skip withholds is the + // changed/unchanged/errored summary — that verdict needed the renders it did not run. + "SkippedAnalysisStillReportsFilteredComposites": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + ImpactAnalysisSkipped: true, + RevisionImpact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + AffectedResources: AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + for _, want := range []string{ + "Impact analysis skipped", + "2 of 3 XR(s) using composition test-comp would not adopt this revision", + "1 with Manual update policy (use --include-manual to see them)", + "1 being deleted", + } { + if !strings.Contains(result, want) { + t.Errorf("expected %q in output, got:\n%s", want, result) + } + } + + // The kept composites went unevaluated, so no per-status verdict may be printed for them. + for _, unwanted := range []string{"=== Affected Composite Resources ===", "=== Impact Analysis ==="} { + if strings.Contains(result, unwanted) { + t.Errorf("did not expect %q for a skipped composition, got:\n%s", unwanted, result) + } + } + }, + }, + // In --resource mode the filtered composites reach ImpactAnalysis, so the skip path names them + // individually with their reason and detail — the affordance issue #457 shipped, which the skip + // used to discard. + "SkippedAnalysisNamesFilteredCompositesInResourceMode": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + ImpactAnalysisSkipped: true, + RevisionImpact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true}, + AffectedResources: AffectedResourcesSummary{Total: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []XRImpact{{ + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "my-deleting-xr", Namespace: "default"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonDeleting, + FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", + }}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + for _, want := range []string{ + "Impact analysis skipped", + "1 of 1 XR(s) using composition test-comp would not adopt this revision", + "XR/my-deleting-xr", + "filtered: being deleted (deletionTimestamp: 2026-09-07T11:25:03Z)", + } { + if !strings.Contains(result, want) { + t.Errorf("expected %q in output, got:\n%s", want, result) + } + } + }, + }, + // Nothing was filtered, so the skip note stands alone — no "0 of 0" line. + "SkippedAnalysisWithNothingFilteredSaysNoMore": { + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + ImpactAnalysisSkipped: true, + RevisionImpact: RevisionImpact{ChangeScope: "none"}, + AffectedResources: AffectedResourcesSummary{Total: 2}, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "Impact analysis skipped") { + t.Errorf("expected the skip note, got:\n%s", result) + } + + if strings.Contains(result, "would not adopt this revision") { + t.Errorf("did not expect a filtered-composites line when nothing was filtered, got:\n%s", result) + } + }, + }, + "MinimizeErrorStillSurfaces": { + // A processing error must render in full even when minimized: only the + // diff body is collapsed, never the error. + output: &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "error-comp", + Error: &dt.OutputError{Message: "boom"}, + ImpactAnalysis: []XRImpact{}, + }}, + }, + colorize: false, + minimize: true, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "Error processing composition error-comp") { + t.Errorf("expected error to surface when minimized, got: %q", result) + } + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var buf bytes.Buffer + + opts := DefaultDiffOptions() + opts.UseColors = tt.colorize + opts.MinimizeComposition = tt.minimize + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} // discard stderr + + diffRenderer := NewDiffRenderer(logger, opts) + renderer := NewDefaultCompDiffRenderer(logger, diffRenderer, opts) + + err := renderer.RenderCompDiff(tt.output) + if err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + tt.validate(t, buf.String()) + }) + } +} + +// TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr verifies that +// top-level errors in CompDiffOutput.Errors are written to stderr (not stdout) +// to follow Unix conventions. +func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testing.T) { + output := &CompDiffOutput{ + Compositions: []CompositionDiff{}, + Errors: []dt.OutputError{ + {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, + {Message: "cluster connection lost"}, + }, + } + + logger := tu.TestLogger(t, false) + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + opts := DefaultDiffOptions() + opts.UseColors = false + opts.Stdout = &stdout + opts.Stderr = &stderr + + diffRenderer := NewDiffRenderer(logger, opts) + renderer := NewDefaultCompDiffRenderer(logger, diffRenderer, opts) + + if err := renderer.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + stderrOut := stderr.String() + for _, e := range output.Errors { + if !strings.Contains(stderrOut, e.FormatError()) { + t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + } + + // Verify errors were NOT written to stdout + if strings.Contains(stdout.String(), e.FormatError()) { + t.Errorf("Expected stdout to NOT contain error %q, got: %q", e.FormatError(), stdout.String()) + } + } +} + +// TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr verifies that +// top-level errors in CompDiffOutput.Errors are written to stderr in addition to being +// included in the structured output. +func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testing.T) { + output := &CompDiffOutput{ + Compositions: []CompositionDiff{}, + Errors: []dt.OutputError{ + {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, + {Message: "cluster connection lost"}, + }, + } + + for _, format := range []OutputFormat{OutputFormatJSON, OutputFormatYAML} { + t.Run(string(format), func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &stdout + opts.Stderr = &stderr + + renderer := NewStructuredCompDiffRenderer(logger, opts) + + if err := renderer.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + // Verify errors in stderr + stderrOut := stderr.String() + for _, e := range output.Errors { + if !strings.Contains(stderrOut, e.FormatError()) { + t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + } + } + + // Verify errors are ALSO in structured output (stdout) + stdoutStr := stdout.String() + for _, e := range output.Errors { + if !strings.Contains(stdoutStr, e.Message) { + t.Errorf("Expected stdout to contain error message %q, got: %q", e.Message, stdoutStr) + } + } + }) + } +} + +func Test_formatXRStatusSummary(t *testing.T) { + tests := map[string]struct { + changed, unchanged, errors int + want string + }{ + "NoResources": {0, 0, 0, ""}, + "OneChanged": {1, 0, 0, "\nSummary: 1 resource with changes\n"}, + "MultipleChanged": {5, 0, 0, "\nSummary: 5 resources with changes\n"}, + "AllTypes": {2, 3, 1, "\nSummary: 2 resources with changes, 3 resources unchanged, 1 resource with errors\n"}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := formatXRStatusSummary(tt.changed, tt.unchanged, tt.errors) + if got != tt.want { + t.Errorf("formatXRStatusSummary() = %q, want %q", got, tt.want) + } + }) + } +} + +func Test_pluralize(t *testing.T) { + tests := map[string]struct { + count int + want string + }{ + "Singular_NoSuffix": {count: 1, want: ""}, + "Zero_HasSuffix": {count: 0, want: "s"}, + "Plural_HasSuffix": {count: 2, want: "s"}, + "Negative_HasSuffix": {count: -1, want: "s"}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := pluralize(tt.count); got != tt.want { + t.Errorf("pluralize(%d) = %q, want %q", tt.count, got, tt.want) + } + }) + } +} + +// TestCompDiffOutput_JSONSchema validates that the internal CompDiffOutput type +// serializes to JSON with the correct schema structure. This includes verifying: +// - compositionChanges field appears with correct type symbol +// - downstreamChanges is present for changed XRs with proper summary counts +// - error field serializes correctly on error impacts +// - nested structures (ImpactAnalysis, AffectedResources) serialize properly +// +// YAML serialization uses the same JSON tags (via sigs.k8s.io/yaml), so if JSON +// serializes correctly, YAML will too. YAML format coverage is provided by +// TestStructuredCompDiffRenderer_RenderCompDiff. +func TestCompDiffOutput_JSONSchema(t *testing.T) { + output := &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "xbuckets.example.org", + CompositionDiff: &dt.ResourceDiff{ + DiffType: dt.DiffTypeModified, + ResourceName: "xbuckets.example.org", + Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, + Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), + Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), + }, + AffectedResources: AffectedResourcesSummary{Total: 5, WithChanges: 2, Unchanged: 2, WithErrors: 1}, + ImpactAnalysis: []XRImpact{ + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-1"}, + Status: XRStatusChanged, + Diffs: map[string]*dt.ResourceDiff{ + "s3.aws.upbound.io/v1beta1/Bucket//new-bucket": { + DiffType: dt.DiffTypeAdded, + ResourceName: "new-bucket", + Gvk: schema.GroupVersionKind{Group: "s3.aws.upbound.io", Version: "v1beta1", Kind: "Bucket"}, + Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), + }, + "s3.aws.upbound.io/v1beta1/Bucket//existing-bucket": { + DiffType: dt.DiffTypeModified, + ResourceName: "existing-bucket", + Gvk: schema.GroupVersionKind{Group: "s3.aws.upbound.io", Version: "v1beta1", Kind: "Bucket"}, + Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), + Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), + }, + }, + }, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-2"}, Status: XRStatusUnchanged}, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3"}, Status: XRStatusError, Error: &dt.OutputError{ResourceID: "XBucket/bucket-3", Message: "render failed"}}, + }, + }}, + } + + // Test via the structured renderer (JSON) + logger := tu.TestLogger(t, false) + + var jsonBuf bytes.Buffer + + opts := DefaultDiffOptions() + opts.Format = OutputFormatJSON + opts.Stdout = &jsonBuf + opts.Stderr = &bytes.Buffer{} // discard stderr + + jsonRenderer := NewStructuredCompDiffRenderer(logger, opts) + + if err := jsonRenderer.RenderCompDiff(output); err != nil { + t.Fatalf("Failed to render JSON: %v", err) + } + + var parsed compDiffWire + if err := json.Unmarshal(jsonBuf.Bytes(), &parsed); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + if len(parsed.Compositions) != 1 { + t.Fatalf("Expected 1 composition, got %d", len(parsed.Compositions)) + } + + comp := parsed.Compositions[0] + if comp.AffectedResources.Total != 5 { + t.Errorf("Expected total 5, got %d", comp.AffectedResources.Total) + } + + if len(comp.ImpactAnalysis) != 3 { + t.Errorf("Expected 3 impacts, got %d", len(comp.ImpactAnalysis)) + } + + // Verify compositionChanges in JSON + if comp.CompositionChanges == nil { + t.Error("Expected compositionChanges to be present") + } + + if comp.CompositionChanges.Type != "modified" { + t.Errorf("Expected compositionChanges.type 'modified', got '%s'", comp.CompositionChanges.Type) + } + + // Verify error impact in JSON + if comp.ImpactAnalysis[2].Error != "render failed" { + t.Errorf("Expected error 'render failed', got '%s'", comp.ImpactAnalysis[2].Error) + } + + // Verify changed impact has downstreamChanges in JSON + if comp.ImpactAnalysis[0].DownstreamChanges == nil { + t.Error("Expected downstreamChanges for changed XR") + } + + if comp.ImpactAnalysis[0].DownstreamChanges.Summary.Added != 1 { + t.Errorf("Expected 1 added, got %d", comp.ImpactAnalysis[0].DownstreamChanges.Summary.Added) + } + + if comp.ImpactAnalysis[0].DownstreamChanges.Summary.Modified != 1 { + t.Errorf("Expected 1 modified, got %d", comp.ImpactAnalysis[0].DownstreamChanges.Summary.Modified) + } +} + +func TestXRStatusFiltered_JSON(t *testing.T) { + output := &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "test-comp", + AffectedResources: AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []XRImpact{ + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonManualPolicy, + }, + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonRevisionSelectorMismatch, + FilterDetail: "compositionRevisionSelector {version: 0.0.1} does not match composition labels {version: 0.0.2}", + }, + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "deleting-xr", Namespace: "ns"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonDeleting, + FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", + }, + }, + }}, + } + + logger := tu.TestLogger(t, false) + + var jsonBuf bytes.Buffer + + opts := DefaultDiffOptions() + opts.Format = OutputFormatJSON + opts.Stdout = &jsonBuf + opts.Stderr = &bytes.Buffer{} + + r := NewStructuredCompDiffRenderer(logger, opts) + if err := r.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff: %v", err) + } + + var parsed compDiffWire + if err := json.Unmarshal(jsonBuf.Bytes(), &parsed); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if len(parsed.Compositions) != 1 || len(parsed.Compositions[0].ImpactAnalysis) != 3 { + t.Fatalf("expected 1 composition with 3 impacts, got %+v", parsed) + } + + // No impact should carry the removed "filtered_by_policy" status value. + for _, imp := range parsed.Compositions[0].ImpactAnalysis { + if string(imp.Status) != "filtered" { + t.Errorf("status: got %q, want %q", imp.Status, "filtered") + } + + if imp.DownstreamChanges != nil { + t.Errorf("downstreamChanges should be omitted for filtered impacts, got %+v", imp.DownstreamChanges) + } + } + + byName := map[string]xrImpactWire{} + for _, imp := range parsed.Compositions[0].ImpactAnalysis { + byName[imp.Name] = imp + } + + if got, want := string(byName["manual-xr"].FilterReason), "manual_policy"; got != want { + t.Errorf("manual-xr filterReason: got %q, want %q", got, want) + } + + if got, want := string(byName["selector-xr"].FilterReason), "revision_selector_mismatch"; got != want { + t.Errorf("selector-xr filterReason: got %q, want %q", got, want) + } + + if byName["selector-xr"].FilterDetail == "" { + t.Errorf("selector-xr expected a filterDetail hint, got empty") + } + + if got, want := string(byName["deleting-xr"].FilterReason), "deleting"; got != want { + t.Errorf("deleting-xr filterReason: got %q, want %q", got, want) + } + + if got, want := byName["deleting-xr"].FilterDetail, "deletionTimestamp: 2026-09-07T11:25:03Z"; got != want { + t.Errorf("deleting-xr filterDetail: got %q, want %q", got, want) + } + + if got := parsed.Compositions[0].AffectedResources.FilteredBySelector; got != 1 { + t.Errorf("filteredBySelector: got %d, want 1", got) + } + + if got := parsed.Compositions[0].AffectedResources.FilteredByDeletion; got != 1 { + t.Errorf("filteredByDeletion: got %d, want 1", got) + } +} + +func TestXRStatusFiltered_TextRenderer(t *testing.T) { + comp := CompositionDiff{ + Name: "test-comp", + AffectedResources: AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []XRImpact{ + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonManualPolicy, + }, + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonRevisionSelectorMismatch, + FilterDetail: "compositionRevisionSelector {version: 0.0.1} does not match composition labels {version: 0.0.2}", + }, + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "deleting-xr", Namespace: "ns"}, + Status: XRStatusFiltered, + FilterReason: FilterReasonDeleting, + FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", + }, + }, + } + + logger := tu.TestLogger(t, false) + r := &DefaultCompDiffRenderer{logger: logger, opts: DefaultDiffOptions()} + got := r.buildXRStatusList(comp.ImpactAnalysis) + + if !strings.Contains(got, "manual-xr") || !strings.Contains(got, "selector-xr") { + t.Errorf("expected both XR names in output, got %q", got) + } + + // Manual-policy exclusion must mention the policy and the escape hatch. + if !strings.Contains(strings.ToLower(got), "manual update policy") { + t.Errorf("expected 'Manual update policy' verbiage, got %q", got) + } + + // Selector-mismatch exclusion must distinguish itself from policy and surface the hint. + if !strings.Contains(strings.ToLower(got), "revision selector") { + t.Errorf("expected 'revision selector' verbiage for selector mismatch, got %q", got) + } + + if !strings.Contains(got, "does not match composition labels") { + t.Errorf("expected the selector-mismatch fix hint in output, got %q", got) + } + + // Deletion exclusion must say so and surface the timestamp, which is the diagnostic signal for + // an XR that has been stuck terminating. + if !strings.Contains(got, "being deleted") { + t.Errorf("expected 'being deleted' verbiage for a deleting XR, got %q", got) + } + + if !strings.Contains(got, "deletionTimestamp: 2026-09-07T11:25:03Z") { + t.Errorf("expected the deletionTimestamp detail in output, got %q", got) + } +} + +// Test_allFilteredMessage covers the default-discovery "everything was filtered" line. Each +// single-reason case gets bespoke prose that names the remedy; mixed reasons enumerate the +// breakdown. The total must always be the sum of the per-reason counters, so a newly added reason +// cannot silently go unreported. +func Test_allFilteredMessage(t *testing.T) { + tests := map[string]struct { + summary AffectedResourcesSummary + want string + }{ + "PolicyOnly": { + summary: AffectedResourcesSummary{FilteredByPolicy: 2}, + want: "All 2 XR(s) using composition test-comp have Manual update policy (use --include-manual to see them)", + }, + "SelectorOnly": { + summary: AffectedResourcesSummary{FilteredBySelector: 3}, + want: "All 3 XR(s) using composition test-comp have a compositionRevisionSelector that does not match the composition's labels, so they would not adopt this revision", + }, + "DeletionOnly": { + summary: AffectedResourcesSummary{FilteredByDeletion: 1}, + want: "All 1 XR(s) using composition test-comp are being deleted, so they would not adopt this revision", + }, + "PolicyAndSelector": { + summary: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2}, + want: "All 3 XR(s) using composition test-comp were filtered: 1 with Manual update policy (use --include-manual to see them), 2 with a compositionRevisionSelector that does not match the composition's labels", + }, + "PolicyAndDeletion": { + summary: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredByDeletion: 1}, + want: "All 2 XR(s) using composition test-comp were filtered: 1 with Manual update policy (use --include-manual to see them), 1 being deleted", + }, + "SelectorAndDeletion": { + summary: AffectedResourcesSummary{FilteredBySelector: 1, FilteredByDeletion: 2}, + want: "All 3 XR(s) using composition test-comp were filtered: 1 with a compositionRevisionSelector that does not match the composition's labels, 2 being deleted", + }, + "AllThreeReasons": { + summary: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2, FilteredByDeletion: 3}, + want: "All 6 XR(s) using composition test-comp were filtered: 1 with Manual update policy (use --include-manual to see them), 2 with a compositionRevisionSelector that does not match the composition's labels, 3 being deleted", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if diff := gcmp.Diff(tt.want, allFilteredMessage("test-comp", tt.summary)); diff != "" { + t.Errorf("allFilteredMessage() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestSkippedMessage pins the distinction the two skip reasons must keep. An identical composition +// creates no CompositionRevision and so genuinely cannot affect anything — a guarantee. A +// metadata-only change under --analyze-on=spec-change does create one and simply was not evaluated — +// an absence of information. Reporting the first for the second case would assert something the tool +// never established, which is the whole reason skippedMessage branches at all. +func TestSkippedMessage(t *testing.T) { + tests := map[string]struct { + impact RevisionImpact + // wantContains are substrings that must appear; wantOmits must not. + wantContains []string + wantOmits []string + }{ + "IdenticalCompositionMakesTheStrongClaim": { + impact: RevisionImpact{ChangeScope: "none", CreatesRevision: false}, + wantContains: []string{ + "identical to the cluster's", + "creates no new CompositionRevision", + "no composite resource could change", + "--analyze-on=always", + }, + // Must not hedge: for this case the tool really does know nothing can change. + wantOmits: []string{"was not evaluated", "not predictable"}, + }, + "MetadataOnlyChangeMakesOnlyTheWeakClaim": { + impact: RevisionImpact{ + ChangeScope: "metadata", + CreatesRevision: true, + RepointedComposites: 2, + PredictedRevisionName: "xbuckets.example.org-abc1234", + }, + wantContains: []string{ + "still creates a new CompositionRevision", + "2 composites would adopt", + "whether that changes any rendered output was not evaluated", + "--analyze-on=any-change", + }, + wantOmits: []string{ + // Must never claim nothing could change, nor that the composition is identical. + "no composite resource could change", + "identical to the cluster's", + // The hash suffix stays out of human output; see revisionImpactMessage. + "abc1234", + }, + }, + "SingleCompositeIsNotPluralized": { + impact: RevisionImpact{ + ChangeScope: "metadata", + CreatesRevision: true, + RepointedComposites: 1, + }, + wantContains: []string{"1 composite would adopt"}, + wantOmits: []string{"1 composites"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := skippedMessage(tt.impact) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("skippedMessage() missing %q, got:\n%s", want, got) + } + } + + for _, omit := range tt.wantOmits { + if strings.Contains(got, omit) { + t.Errorf("skippedMessage() must not contain %q, got:\n%s", omit, got) + } + } + }) + } +} + +// TestRevisionImpactMessage covers the line emitted when the composites *were* evaluated. It exists to +// make a downstream change interpretable — a composed resource templating on the revision name +// otherwise shows a diff with no visible cause — so the name has to be in it, and it must stay silent +// when there is no revision to name. +func TestRevisionImpactMessage(t *testing.T) { + tests := map[string]struct { + impact RevisionImpact + want string + wantContains []string + wantOmits []string + }{ + "NoRevisionSaysNothing": { + impact: RevisionImpact{ChangeScope: "none", CreatesRevision: false}, + want: "", + }, + "ReportsTheChurnWithoutTheHash": { + impact: RevisionImpact{ + ChangeScope: "spec", + CreatesRevision: true, + RepointedComposites: 3, + PredictedRevisionName: "xbuckets.example.org-abc1234", + }, + wantContains: []string{ + "creates a new CompositionRevision", + "3 composites would adopt", + }, + // The name is available on the impact but must stay out of human stdout, which would + // otherwise churn on every composition edit. Structured output carries it instead. + wantOmits: []string{"abc1234"}, + }, + "ZeroAdoptingCompositesSaysNothing": { + // The line's only job is explaining a downstream diff that a template reading the revision + // name would cause. Nothing adopts this revision, so there is no such diff, and the sections + // around it already report the composition as changed. + impact: RevisionImpact{ + ChangeScope: "spec", + CreatesRevision: true, + PredictedRevisionName: "xbuckets.example.org-abc1234", + }, + want: "", + }, + "UnpredictableNameReadsIdentically": { + // Nothing was seeded here, so a revision-observing change would go undetected — but that is + // said on stderr as a warning, which is the channel for "this could not be checked". The + // stdout line reports the same churn either way, so it needs no variant. + impact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + wantContains: []string{"creates a new CompositionRevision", "1 composite would adopt"}, + wantOmits: []string{"not predictable"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := revisionImpactMessage(tt.impact) + + if len(tt.wantContains) == 0 { + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("revisionImpactMessage() mismatch (-want +got):\n%s", diff) + } + + return + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("revisionImpactMessage() missing %q, got:\n%s", want, got) + } + } + + for _, omit := range tt.wantOmits { + if strings.Contains(got, omit) { + t.Errorf("revisionImpactMessage() must not contain %q, got:\n%s", omit, got) + } + } + }) + } +} + +// TestRenderCompDiff_RevisionImpactLine checks the wiring, which the message unit tests above cannot: +// that the line reaches stdout for an evaluated composition, and that a skipped one reports the churn +// exactly once rather than twice (skippedMessage carries its own sentence, and both would be emitted if +// the early-continue were removed). It also pins that the hash never reaches stdout by either route. +func TestRenderCompDiff_RevisionImpactLine(t *testing.T) { + impact := RevisionImpact{ + ChangeScope: "metadata", + CreatesRevision: true, + RepointedComposites: 1, + PredictedRevisionName: "xbuckets.example.org-abc1234", + } + + tests := map[string]struct { + comp CompositionDiff + // wantCount is how many times the churn is reported: "composite would adopt" appears in both + // revisionImpactMessage and skippedMessage, so it counts either route. + wantCount int + }{ + "EvaluatedCompositionReportsChurnOnce": { + comp: CompositionDiff{ + Name: "xbuckets.example.org", + RevisionImpact: impact, + AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []XRImpact{{ + ObjectReference: corev1.ObjectReference{Kind: "XBucket", Name: "test-xr"}, + Status: XRStatusUnchanged, + }}, + }, + wantCount: 1, + }, + "SkippedCompositionReportsChurnOnce": { + comp: CompositionDiff{ + Name: "xbuckets.example.org", + RevisionImpact: impact, + ImpactAnalysisSkipped: true, + }, + wantCount: 1, + }, + "UnchangedCompositionReportsChurnNever": { + comp: CompositionDiff{ + Name: "xbuckets.example.org", + RevisionImpact: RevisionImpact{ChangeScope: "none", CreatesRevision: false}, + }, + wantCount: 0, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + opts := DefaultDiffOptions() + opts.Stdout = &stdout + opts.Stderr = &stderr + opts.UseColors = false + + r := NewDefaultCompDiffRenderer(tu.TestLogger(t, false), NewDiffRenderer(tu.TestLogger(t, false), opts), opts) + + if err := r.RenderCompDiff(&CompDiffOutput{Compositions: []CompositionDiff{tt.comp}}); err != nil { + t.Fatalf("RenderCompDiff() unexpected error: %v", err) + } + + if got := strings.Count(stdout.String(), "composite would adopt"); got != tt.wantCount { + t.Errorf("RenderCompDiff() reported the revision churn %d times, want %d; output:\n%s", + got, tt.wantCount, stdout.String()) + } + + // The predicted name's hash suffix must never reach human stdout by any route; it lives in + // structured output, where churning on a composition edit costs nobody anything. + if strings.Contains(stdout.String(), "abc1234") { + t.Errorf("RenderCompDiff() leaked the revision hash into human output:\n%s", stdout.String()) + } + }) + } +} + +func TestCompositionDiff_HasChanges_FilteredOnly(t *testing.T) { + c := &CompositionDiff{ + ImpactAnalysis: []XRImpact{ + {Status: XRStatusFiltered, FilterReason: FilterReasonManualPolicy}, + {Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch}, + {Status: XRStatusFiltered, FilterReason: FilterReasonDeleting}, + }, + } + if c.HasChanges() { + t.Errorf("CompositionDiff with only filtered impacts should not be HasChanges()") + } +} + +// TestStructuredCompDiffRenderer_OutputVersions renders each per-XR status, and a failed composition, +// under each output version, and asserts the whole document plus what reaches stderr. +func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { + ref := func(name string) corev1.ObjectReference { + return corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: name} + } + addedBucket := &dt.ResourceDiff{ + DiffType: dt.DiffTypeAdded, + ResourceName: "new-bucket", + Gvk: schema.GroupVersionKind{Group: "s3.aws.upbound.io", Version: "v1beta1", Kind: "Bucket"}, + Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), + } + addedChange := ChangeDetail{ + Type: dt.DiffTypeWordAdded, + APIVersion: "s3.aws.upbound.io/v1beta1", + Kind: "Bucket", + Name: "new-bucket", + Diff: map[string]any{dt.DiffKeySpec: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}, + } + xrErr := &dt.OutputError{ + ResourceID: "XBucket/bucket-3", + Message: "render failed", + ValidationFailures: []dt.ResourceValidationFailure{{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3", Status: "invalid", Errors: []dt.FieldValidationError{{Type: "schema", Field: "spec.size", Message: "must be positive"}}}}, + } + compErr := &dt.OutputError{ResourceID: "Composition/xbroken.example.org", Message: "cannot fetch composition"} + revisionImpact := RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 3} + affected := AffectedResourcesSummary{Total: 4, WithChanges: 1, Unchanged: 1, WithErrors: 1, FilteredBySelector: 1} + + output := &CompDiffOutput{ + Compositions: []CompositionDiff{ + { + Name: "xbuckets.example.org", + AffectedResources: affected, + RevisionImpact: revisionImpact, + ImpactAnalysis: []XRImpact{ + {ObjectReference: ref("bucket-1"), Status: XRStatusChanged, Diffs: map[string]*dt.ResourceDiff{"s3.aws.upbound.io/v1beta1/Bucket//new-bucket": addedBucket}}, + {ObjectReference: ref("bucket-2"), Status: XRStatusUnchanged}, + {ObjectReference: ref("bucket-3"), Status: XRStatusError, Error: xrErr}, + {ObjectReference: ref("bucket-4"), Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, + }, + }, + { + Name: "xbroken.example.org", + Error: compErr, + ImpactAnalysis: []XRImpact{}, + }, + }, + // The processor lists each errored XR's OutputError at top level. + Errors: []dt.OutputError{*xrErr}, + } + + cases := map[string]struct { + version OutputVersion + // want is the whole document, as the type of the selected version. + want any + wantStderr []string + }{ + "DefaultIsV1": { + want: compDiffWire{ + OutputVersion: OutputVersionV1, + Compositions: []compositionDiffWire{ + { + Name: "xbuckets.example.org", + AffectedResources: affected, + RevisionImpact: &revisionImpact, + ImpactAnalysis: []xrImpactWire{ + {ObjectReference: ref("bucket-1"), Status: XRStatusChanged, DownstreamChanges: &DownstreamChanges{Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}}, + {ObjectReference: ref("bucket-2"), Status: XRStatusUnchanged}, + {ObjectReference: ref("bucket-3"), Status: XRStatusError, Error: "render failed"}, + {ObjectReference: ref("bucket-4"), Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, + }, + }, + { + Name: "xbroken.example.org", + Error: "cannot fetch composition", + ImpactAnalysis: []xrImpactWire{}, + }, + }, + // v1 does not list the failed composition here. + Errors: []dt.OutputError{*xrErr}, + }, + wantStderr: []string{xrErr.FormatError()}, + }, + "V2": { + version: OutputVersionV2, + want: compDiffWireV2{ + OutputVersion: OutputVersionV2, + Compositions: []compositionDiffWireV2{ + { + Name: "xbuckets.example.org", + AffectedResources: affected, + RevisionImpact: &revisionImpact, + ImpactAnalysis: []xrEntryWire{ + {XR: ref("bucket-1"), Status: XRStatusChanged, Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}, + {XR: ref("bucket-2"), Status: XRStatusUnchanged, Changes: []ChangeDetail{}}, + {XR: ref("bucket-3"), Status: XRStatusError, Changes: []ChangeDetail{}, Errors: []dt.OutputError{*xrErr}}, + {XR: ref("bucket-4"), Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch", Changes: []ChangeDetail{}}, + }, + }, + { + Name: "xbroken.example.org", + Errors: []dt.OutputError{*compErr}, + ImpactAnalysis: []xrEntryWire{}, + }, + }, + // v2 appends the failed composition, so the list is the union of the document's errors. + Errors: []dt.OutputError{*xrErr, *compErr}, + }, + wantStderr: []string{xrErr.FormatError(), compErr.FormatError()}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + opts := DefaultDiffOptions() + opts.Format = OutputFormatJSON + opts.Stdout = &stdout + opts.Stderr = &stderr + + if tc.version != "" { + opts.OutputVersion = tc.version + } + + if err := NewStructuredCompDiffRenderer(tu.TestLogger(t, false), opts).RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff() failed: %v", err) + } + + got := reflect.New(reflect.TypeOf(tc.want)) + if err := json.Unmarshal(stdout.Bytes(), got.Interface()); err != nil { + t.Fatalf("Failed to unmarshal: %v\nOutput: %s", err, stdout.String()) + } + + if diff := gcmp.Diff(tc.want, got.Elem().Interface()); diff != "" { + t.Errorf("structured output mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tc.wantStderr, strings.Split(strings.TrimSpace(stderr.String()), "\n")); diff != "" { + t.Errorf("stderr mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/diff/renderer/diff_formatter.go b/internal/diff/renderer/diff_formatter.go new file mode 100644 index 00000000..f204badb --- /dev/null +++ b/internal/diff/renderer/diff_formatter.go @@ -0,0 +1,753 @@ +// Package renderer contains the logic for formatting and displaying diffs. +package renderer + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + "k8s.io/apimachinery/pkg/api/equality" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + t "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// PathLastAppliedConfiguration is the ignore-path form of the annotation a client-side `kubectl +// apply` stamps on the object it applies. Exported because two callers must name the same path and +// must not drift apart: cleanupForDiff suppresses it from every rendered diff (see +// displayOnlyIgnoredPaths), and comp's revision-name prediction has to ask whether it is the *only* +// thing a composition differs by — in which case the resulting revision's identity is not +// predictable, because the value kubectl writes depends on the user's apply mode rather than on the +// file. +const PathLastAppliedConfiguration = "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]" + +// DiffOptions holds configuration options for the diff output. +type DiffOptions struct { + // Stdout is the writer for diff output (defaults to os.Stdout) + Stdout io.Writer + + // Stderr is the writer for error output (defaults to os.Stderr) + // Errors are written here following Unix conventions (errors to stderr, output to stdout) + Stderr io.Writer + + // Format specifies the output format (diff, json, yaml) + Format OutputFormat + + // UseColors determines whether to colorize the output + UseColors bool + + // AddPrefix is the prefix for added lines (default "+") + AddPrefix string + + // DeletePrefix is the prefix for deleted lines (default "-") + DeletePrefix string + + // ContextPrefix is the prefix for unchanged lines (default " ") + ContextPrefix string + + // ContextLines is the number of unchanged lines to show before/after changes in compact mode + ContextLines int + + // ChunkSeparator is the string used to separate chunks in compact mode + ChunkSeparator string + + // Compact determines whether to show a compact diff + Compact bool + + // IgnorePaths is a list of paths to ignore when calculating diffs + // Supports both simple paths (e.g., "metadata.annotations") and + // map key paths (e.g., "metadata.annotations[key.name/value]") + IgnorePaths []string + + // ForVerdict marks a comparison whose result answers "did this object change?" rather than + // producing output a human reads. Such a comparison keeps the display-only fields *in* the + // objects being compared (see displayOnlyIgnoredPaths in cleanupForDiff), so a difference in one + // of them registers: a field hidden purely to keep the rendered diff readable must never decide + // whether a change exists. + // + // Callers should also clear IgnorePaths for such a comparison, for the same reason — the user's + // masks are a display preference too. + ForVerdict bool + + // MinimizeComposition collapses composition changes to a single marker line + // per composition, omitting the full YAML diff body. Only consumed by the + // human-readable composition diff renderer; structured output is unaffected. + MinimizeComposition bool + + // OutputVersion selects the schema of structured (JSON/YAML) output. Empty means + // OutputVersionV1. The human-readable diff format ignores it. + OutputVersion OutputVersion +} + +// DefaultDiffOptions returns the default options with colors enabled. +func DefaultDiffOptions() DiffOptions { + return DiffOptions{ + Stdout: os.Stdout, + Stderr: os.Stderr, + Format: OutputFormatDiff, + OutputVersion: OutputVersionV1, + UseColors: true, + AddPrefix: "+ ", + DeletePrefix: "- ", + ContextPrefix: " ", + ContextLines: 3, + ChunkSeparator: "...", + Compact: false, + } +} + +// DiffFormatter is the interface that defines the contract for diff formatters. +type DiffFormatter interface { + Format(diffs []diffmatchpatch.Diff, options DiffOptions) string +} + +// FullDiffFormatter formats diffs with all context lines. +type FullDiffFormatter struct{} + +// CompactDiffFormatter formats diffs with limited context lines. +type CompactDiffFormatter struct{} + +// NewFormatter returns a DiffFormatter based on whether compact mode is desired. +func NewFormatter(compact bool) DiffFormatter { + if compact { + return &CompactDiffFormatter{} + } + + return &FullDiffFormatter{} +} + +// FormatDiff formats a slice of diffs according to the provided options. +func FormatDiff(diffs []diffmatchpatch.Diff, options DiffOptions) string { + // Use the appropriate formatter + formatter := NewFormatter(options.Compact) + return formatter.Format(diffs, options) +} + +// Format implements the DiffFormatter interface for FullDiffFormatter. +func (f *FullDiffFormatter) Format(diffs []diffmatchpatch.Diff, options DiffOptions) string { + var builder strings.Builder + + for _, diff := range diffs { + formattedLines, _ := processLines(diff, options) + for _, line := range formattedLines { + builder.WriteString(line) + builder.WriteString("\n") + } + } + + return builder.String() +} + +// Find change blocks (sequences of inserts/deletes). +type changeBlock struct { + StartIdx int + EndIdx int +} + +type lineItem struct { + Type diffmatchpatch.Operation + Content string + Formatted string +} + +// Format implements the DiffFormatter interface for CompactDiffFormatter. +func (f *CompactDiffFormatter) Format(diffs []diffmatchpatch.Diff, options DiffOptions) string { + // Create a flat array of all formatted lines with their diff types + // Preallocate with reasonable initial capacity based on number of diffs + allLines := make([]lineItem, 0, len(diffs)*4) + + for _, diff := range diffs { + formattedLines, hasTrailingNewline := processLines(diff, options) + + for i, formatted := range formattedLines { + // For non-trailing empty lines or regular lines + content := "" + if isEmptyTrailer := hasTrailingNewline && len(formattedLines) == 1 && i == 0; !isEmptyTrailer { + content = strings.Split(diff.Text, "\n")[i] + } + + allLines = append(allLines, lineItem{ + Type: diff.Type, + Content: content, + Formatted: formatted, + }) + } + } + + var ( + changeBlocks []changeBlock + currentBlock *changeBlock + ) + + // Identify all the change blocks + + for i, line := range allLines { + if line.Type != diffmatchpatch.DiffEqual { + // Start a new block if we don't have one + if currentBlock == nil { + currentBlock = &changeBlock{StartIdx: i, EndIdx: i} + } else { + // Extend current block + currentBlock.EndIdx = i + } + } else if currentBlock != nil { + // If we were in a block and hit an equal line, finish the block + changeBlocks = append(changeBlocks, *currentBlock) + currentBlock = nil + } + } + + // Add the last block if it's still active + if currentBlock != nil { + changeBlocks = append(changeBlocks, *currentBlock) + } + + // If we have no change blocks, return an empty string + if len(changeBlocks) == 0 { + return "" + } + + return f.stringBlocksWithContext(changeBlocks, allLines, options) +} + +func (f *CompactDiffFormatter) stringBlocksWithContext(changes []changeBlock, lines []lineItem, opts DiffOptions) string { + // Now build compact output with context + var builder strings.Builder + + contextLines := opts.ContextLines + + // Keep track of the last line we printed + lastPrintedIdx := -1 + + // Now process each block with its context + for blockIdx, block := range changes { + // Calculate visible range for context before the block + contextStart := max(0, block.StartIdx-contextLines) + + // If this isn't the first block, check if we need a separator + if blockIdx > 0 { + prevBlock := changes[blockIdx-1] + prevContextEnd := min(len(lines), prevBlock.EndIdx+contextLines+1) + + // If there's a gap between the end of the previous context and the start of this context, + // add a separator + if contextStart > prevContextEnd { + // Add separator + fmt.Fprintf(&builder, "%s\n", opts.ChunkSeparator) + + lastPrintedIdx = -1 // Reset to force printing of context lines + } else { + // Contexts overlap or are adjacent - adjust the start to avoid duplicate lines + contextStart = max(lastPrintedIdx+1, contextStart) + } + } + + // Print context before the change if we haven't already printed it + for i := contextStart; i < block.StartIdx; i++ { + if i > lastPrintedIdx { + builder.WriteString(lines[i].Formatted) + builder.WriteString("\n") + + lastPrintedIdx = i + } + } + + // Print the changes + for i := block.StartIdx; i <= block.EndIdx; i++ { + builder.WriteString(lines[i].Formatted) + builder.WriteString("\n") + + lastPrintedIdx = i + } + + // Print context after the change + contextEnd := min(len(lines), block.EndIdx+contextLines+1) + for i := block.EndIdx + 1; i < contextEnd; i++ { + builder.WriteString(lines[i].Formatted) + builder.WriteString("\n") + + lastPrintedIdx = i + } + } + + return builder.String() +} + +// GetLineDiff performs a proper line-by-line diff and returns the raw diffs. +func GetLineDiff(oldText, newText string) []diffmatchpatch.Diff { + patch := diffmatchpatch.New() + + // Use the line-to-char conversion to treat each line as an atomic unit + ch1, ch2, lines := patch.DiffLinesToChars(oldText, newText) + + diff := patch.DiffMain(ch1, ch2, false) + patch.DiffCleanupSemantic(diff) + + return patch.DiffCharsToLines(diff, lines) +} + +// logIdentity returns the kind/name and namespace to log for a diff, preferring +// the desired object over the current one. +func logIdentity(current, desired *un.Unstructured) (string, string) { + switch { + case desired != nil: + return fmt.Sprintf("%s/%s", desired.GetKind(), desired.GetName()), desired.GetNamespace() + case current != nil: + return fmt.Sprintf("%s/%s", current.GetKind(), current.GetName()), current.GetNamespace() + default: + return "unknown/unknown", "" + } +} + +// GenerateDiffWithOptions produces a structured diff between two unstructured objects. +func GenerateDiffWithOptions(_ context.Context, current, desired *un.Unstructured, logger logging.Logger, options DiffOptions) (*t.ResourceDiff, error) { + var diffType t.DiffType + + resourceKey, resourceNamespace := logIdentity(current, desired) + + logger.Debug("Generating diff", "resource", resourceKey, "namespace", resourceNamespace) + + // Determine diff type + switch { + case current == nil && desired != nil: + diffType = t.DiffTypeAdded + + logger.Debug("Diff type: Resource is being added", "resource", resourceKey, "namespace", resourceNamespace) + case current != nil && desired == nil: + diffType = t.DiffTypeRemoved + + logger.Debug("Diff type: Resource is being removed", "resource", resourceKey, "namespace", resourceNamespace) + case current != nil: // && desired != nil: + diffType = t.DiffTypeModified + + logger.Debug("Diff type: Resource is being modified", "resource", resourceKey, "namespace", resourceNamespace) + default: + logger.Debug("Error: both current and desired are nil") + return nil, errors.New("both current and desired cannot be nil") + } + + // Fast path for modifications: if the raw objects are already deeply equal + // there is nothing to clean or diff. + if diffType == t.DiffTypeModified && equality.Semantic.DeepEqual(current, desired) { + logger.Debug("Resources are semantically equal", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + // Clean each present object exactly once. The cleaned copies are reused for + // the cleaned-equality check, the YAML text diff, and are stored on the + // returned ResourceDiff for structured renderers to emit — so cleanup runs + // once per object rather than once per consumer. + var currentClean, desiredClean *un.Unstructured + + if current != nil { + currentClean = cleanupForDiff(current.DeepCopy(), logger.WithValues("resourceStage", "current", "before", current), options) + } + + if desired != nil { + desiredClean = cleanupForDiff(desired.DeepCopy(), logger.WithValues("resourceStage", "desired", "before", desired), options) + } + + // For modifications, if the cleaned objects are equal the only differences + // were in ignored / server-side fields. + if diffType == t.DiffTypeModified && equality.Semantic.DeepEqual(currentClean.Object, desiredClean.Object) { + logger.Debug("Resources are equal after cleanup (only metadata differences)", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + // Convert the already-cleaned objects to YAML for the text diff. + asString := func(clean *un.Unstructured) (string, error) { + if clean == nil { + return "", nil + } + + yaml, err := sigsyaml.Marshal(clean.Object) + if err != nil { + return "", err + } + + return string(yaml), nil + } + + currentStr, err := asString(currentClean) + if err != nil { + logger.Debug("Error marshaling current object to YAML", "error", err) + return nil, errors.Wrap(err, "cannot marshal current object to YAML") + } + + desiredStr, err := asString(desiredClean) + if err != nil { + logger.Debug("Error marshaling desired object to YAML", "error", err) + return nil, errors.Wrap(err, "cannot marshal desired object to YAML") + } + + // Return nil if content is identical + if desiredStr == currentStr { + logger.Debug("Resources have identical YAML representation", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + // Get the line by line diff + logger.Debug("Computing line-by-line diff", "resource", resourceKey, "namespace", resourceNamespace) + + lineDiffs := GetLineDiff(currentStr, desiredStr) + + if len(lineDiffs) == 0 { + logger.Debug("No differences found in line-by-line comparison", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + logger.Debug("Diff calculation complete", "resource", resourceKey, "namespace", resourceNamespace, "diff_chunks", len(lineDiffs)) + + // Extract resource kind, namespace, and name + var ( + name string + namespace string + gvk schema.GroupVersionKind + ) + // For removed resources, use current's kind, namespace, and name + + if diffType == t.DiffTypeRemoved { // current != nil + name = current.GetName() + namespace = current.GetNamespace() + gvk = current.GroupVersionKind() + } else { // desired != nil + // For added or modified resources, use desired's kind + gvk = desired.GroupVersionKind() + + // For namespace, prefer current (existing resource) over desired + if current != nil && current.GetNamespace() != "" { + namespace = current.GetNamespace() + } else { + namespace = desired.GetNamespace() + } + + // For name, prefer the current resource name if it exists (for generateName cases) + if current != nil && current.GetName() != "" { + name = current.GetName() + } else { + // If desired's metadata.name was produced by either our XR + // synthesis path or the binary's nameGenerator, substitute a + // "(generated)" display so the diff doesn't show a value the + // user can't predict. Bare generateName (no name yet) gets the + // same treatment. + switch { + case t.LooksLikeGeneratedName(desired.GetName(), desired.GetGenerateName()): + name = t.GeneratedDisplayName(desired.GetName(), desired.GetGenerateName()) + case desired.GetName() != "": + name = desired.GetName() + case desired.GetGenerateName() != "": + name = desired.GetGenerateName() + "(generated)" + } + } + } + + return &t.ResourceDiff{ + Gvk: gvk, + Namespace: namespace, + ResourceName: name, + DiffType: diffType, + LineDiffs: lineDiffs, + Current: t.ResourceViews{Raw: current, Clean: currentClean}, + Desired: t.ResourceViews{Raw: desired, Clean: desiredClean}, + }, nil +} + +// stripSyntheticName drops metadata.name from the diff body when the +// rendered name was produced by either our XR synthesis path or the binary's +// nameGenerator (see types.LooksLikeGeneratedName). Returns a list of +// modification messages for diagnostic logging. The composite label is left +// intact — downstream resources should still refer to their parent by the +// synthesized display name that appears at the diff header. +// +// Note we don't require generateName to be present: the embedded-suffix +// case (XR-synthesis suffix interpolated into a downstream resource's +// metadata.name via a composition template) typically has no generateName +// field on the rendered resource, and LooksLikeGeneratedName handles that +// path via Contains-on-suffix. +func stripSyntheticName(metadata map[string]any, name string, nameFound bool, generateName string) []string { + if !nameFound { + return nil + } + + if !t.LooksLikeGeneratedName(name, generateName) { + return nil + } + + delete(metadata, "name") + + return []string{fmt.Sprintf("removed display name %q", name)} +} + +func equalDiff(current *un.Unstructured, desired *un.Unstructured) *t.ResourceDiff { + return &t.ResourceDiff{ + Gvk: current.GroupVersionKind(), + Namespace: current.GetNamespace(), + ResourceName: current.GetName(), + DiffType: t.DiffTypeEqual, + LineDiffs: []diffmatchpatch.Diff{}, + // Equal diffs render nothing, so only the Raw views are populated + // (kept for identity/reference); Clean is intentionally left nil. + Current: t.ResourceViews{Raw: current}, + Desired: t.ResourceViews{Raw: desired}, + } +} + +// processLines extracts lines from a diff and processes them into a standardized format +// Returns the processed lines and whether there was a trailing newline. +func processLines(diff diffmatchpatch.Diff, options DiffOptions) ([]string, bool) { + lines := strings.Split(diff.Text, "\n") + hasTrailingNewline := strings.HasSuffix(diff.Text, "\n") + + // If there's a trailing newline, the split produces an empty string at the end + if hasTrailingNewline && len(lines) > 0 { + lines = lines[:len(lines)-1] + } + + result := make([]string, 0, len(lines)) + + // Format each line with appropriate prefix and color + for _, line := range lines { + result = append(result, formatLine(line, diff.Type, options)) + } + + // Add formatted empty line if there was just a newline + if hasTrailingNewline && len(lines) == 0 { + result = append(result, formatLine("", diff.Type, options)) + } + + return result, hasTrailingNewline +} + +// formatLine applies the appropriate prefix and color to a single line. +func formatLine(line string, diffType diffmatchpatch.Operation, options DiffOptions) string { + var ( + prefix string + colorStart, colorEnd string + ) + + switch diffType { + case diffmatchpatch.DiffInsert: + prefix = options.AddPrefix + if options.UseColors { + colorStart = t.ColorGreen + colorEnd = t.ColorReset + } + case diffmatchpatch.DiffDelete: + prefix = options.DeletePrefix + if options.UseColors { + colorStart = t.ColorRed + colorEnd = t.ColorReset + } + case diffmatchpatch.DiffEqual: + prefix = options.ContextPrefix + } + + if options.UseColors && colorStart != "" { + return fmt.Sprintf("%s%s%s%s", colorStart, prefix, line, colorEnd) + } + + return fmt.Sprintf("%s%s", prefix, line) +} + +// removeNestedPath removes a field from an object based on a path string. +// Supports both simple paths (e.g., "metadata.annotations") and +// map key paths (e.g., "metadata.annotations[key.name/value]"). +// Returns true if the field was found and removed, false otherwise. +func removeNestedPath(obj map[string]any, path string) bool { + if path == "" { + return false + } + + // Check if this is a map key path (contains brackets) + if strings.Contains(path, "[") && strings.HasSuffix(path, "]") { + // Parse path like "metadata.annotations[key]" + openBracket := strings.Index(path, "[") + closeBracket := strings.LastIndex(path, "]") + + if openBracket == -1 || closeBracket == -1 || closeBracket <= openBracket+1 { + return false // Invalid format + } + + // Extract the base path and the key + basePath := path[:openBracket] + key := path[openBracket+1 : closeBracket] + + // Split the base path into parts and use k8s helper to get the map reference + parts := strings.Split(basePath, ".") + + fieldValue, found, err := un.NestedFieldNoCopy(obj, parts...) + if !found || err != nil { + return false + } + + // Ensure it's a map + parentMap, ok := fieldValue.(map[string]any) + if !ok { + return false + } + + // Remove the specific key from the map + if _, keyExists := parentMap[key]; keyExists { + delete(parentMap, key) + + // If the parent map is now empty, remove the parent field entirely + if len(parentMap) == 0 { + un.RemoveNestedField(obj, parts...) + } + + return true + } + + return false + } + + // Simple path without brackets - use k8s unstructured helper + parts := strings.Split(path, ".") + + _, found, _ := un.NestedFieldNoCopy(obj, parts...) + if found { + un.RemoveNestedField(obj, parts...) + return true + } + + return false +} + +// cleanupForDiff removes fields that shouldn't be included in the diff. When options.ForVerdict is +// set the display-only paths are left in the object, because the caller is asking whether the objects +// differ rather than rendering them; see DiffOptions.ForVerdict. +func cleanupForDiff(obj *un.Unstructured, logger logging.Logger, options DiffOptions) *un.Unstructured { + resKind := obj.GetKind() + resName := obj.GetName() + resKey := fmt.Sprintf("%s/%s", resKind, resName) + + // Track all modifications for a single consolidated log message + var modifications []string + + // displayOnlyIgnoredPaths are suppressed from every rendered diff regardless of the caller's + // ignorePaths, because showing them is useless: last-applied-configuration is a multi-KB + // serialization of the object itself. + // + // They are suppressed from *display only*. Crossplane's Composition.Hash() + // (apis/apiextensions/v1/composition_hash.go) covers annotations as well as labels and spec, so a + // difference here does produce a new CompositionRevision — which composites re-point to, and + // which a composition template can observe via the XR's compositionRevisionRef. Letting a + // readability decision suppress that from a change verdict would elide a real, potentially + // render-affecting consequence, so verdict comparisons pass forVerdict and keep them. + displayOnlyIgnoredPaths := []string{ + PathLastAppliedConfiguration, + } + + // Remove display-only paths (unless this is a verdict), then the caller's. Duplicates between + // the two are harmless: removeNestedPath is a no-op once the path is gone. + stripPaths := func(paths []string) { + for _, path := range paths { + if removeNestedPath(obj.Object, path) { + modifications = append(modifications, fmt.Sprintf("ignored path: %s", path)) + } + } + } + + if !options.ForVerdict { + stripPaths(displayOnlyIgnoredPaths) + } + + stripPaths(options.IgnorePaths) + + // Remove server-side fields and metadata that we don't want to diff + metadata, found, _ := un.NestedMap(obj.Object, "metadata") + if found { + // Special handling for objects with both name and generateName + // If the name looks like a generated display name (ends with "(generated)") + // and generateName is also present, remove the name to avoid confusion + name, nameFound, _ := un.NestedString(metadata, "name") + generateName, _, _ := un.NestedString(metadata, "generateName") + + modifications = append(modifications, stripSyntheticName(metadata, name, nameFound, generateName)...) + + // Remove fields that change automatically or are server-side + fieldsToRemove := []string{ + "resourceVersion", + "uid", + "generation", + "creationTimestamp", + "managedFields", + "selfLink", + "ownerReferences", + } + + // Track which fields were actually removed for debugging + var removedFields []string + + for _, field := range fieldsToRemove { + if _, exists := metadata[field]; exists { + delete(metadata, field) + removedFields = append(removedFields, field) + } + } + + // Only record if some fields were actually removed + if len(removedFields) > 0 { + modifications = append(modifications, fmt.Sprintf("metadata fields: %s", strings.Join(removedFields, ", "))) + } + + _ = un.SetNestedMap(obj.Object, metadata, "metadata") + } + + // Remove resourceRefs field from spec if it exists + // TODO: remove spec.crossplane entirely, or only spec.crossplane.resourceRefs? + spec, found, _ := un.NestedMap(obj.Object, "spec") + if found && spec != nil { + if _, exists := spec["resourceRefs"]; exists { + delete(spec, "resourceRefs") + + modifications = append(modifications, "resourceRefs from spec") + } + + _ = un.SetNestedMap(obj.Object, spec, "spec") + } + + crossplane, found, _ := un.NestedMap(obj.Object, "spec", "crossplane") + if found && crossplane != nil { + if _, exists := crossplane["resourceRefs"]; exists { + delete(crossplane, "resourceRefs") + + modifications = append(modifications, "resourceRefs from spec.crossplane") + } + + _ = un.SetNestedMap(obj.Object, crossplane, "spec", "crossplane") + + if len(crossplane) == 0 { + // If spec.crossplane is empty after removing resourceRefs, remove the entire field + delete(spec, "crossplane") + _ = un.SetNestedMap(obj.Object, spec, "spec") + + modifications = append(modifications, "empty spec.crossplane field") + } + } + + // Remove status field as we're focused on spec changes + if _, exists := obj.Object["status"]; exists { + delete(obj.Object, "status") + + modifications = append(modifications, "status field") + } + + // Log a single consolidated message if any modifications were made + if len(modifications) > 0 { + logger.Debug("Cleaned object for diff", + "resource", resKey, + "removed", strings.Join(modifications, ", "), + "after", obj) + } + + return obj +} diff --git a/internal/diff/renderer/diff_formatter_test.go b/internal/diff/renderer/diff_formatter_test.go new file mode 100644 index 00000000..682ec7bd --- /dev/null +++ b/internal/diff/renderer/diff_formatter_test.go @@ -0,0 +1,539 @@ +package renderer + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/sergi/go-diff/diffmatchpatch" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func TestGenerateDiffWithOptions(t *testing.T) { + // Create test resources for diffing + current := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "old-value"). + WithSpecField("field2", int64(123)). + Build() + + desired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + WithSpecField("field2", int64(456)). + WithSpecField("field4", "added-field"). + Build() + + // Identical to current, for no-change test + noChanges := current.DeepCopy() + + // Resources carrying content that cleanupForDiff strips: an ignored + // annotation (via options.IgnorePaths below) and a server-side field + // (resourceVersion). Used to assert the Raw/Clean split. + const ignoredAnnotation = "argocd.argoproj.io/tracking-id" + + noisyCurrent := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "old-value"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). + Build() + noisyCurrent.SetResourceVersion("100") + + noisyDesired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). + Build() + noisyDesired.SetResourceVersion("200") + + // The expected Clean views for the noisy pair: the ignored annotation (and + // the now-empty annotations map) and the server-side resourceVersion are + // stripped, while the non-ignored spec change survives. + noisyCurrentClean := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "old-value"). + Build() + + noisyDesiredClean := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + Build() + + ignoreOpts := DefaultDiffOptions() + ignoreOpts.IgnorePaths = []string{"metadata.annotations[" + ignoredAnnotation + "]"} + + // A modified pair where the current (cluster) object is namespaced but the + // desired (rendered) object omits metadata.namespace — as manifests often + // do. Generation must prefer the current namespace so downstream (structured + // output, human diff) doesn't emit an empty namespace. Neither object has + // ignorable content, so Clean == Raw content. + nsCurrent := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + InNamespace("production"). + WithSpecField("field1", "old-value"). + Build() + + nsDesired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + Build() + + tests := map[string]struct { + current *un.Unstructured + desired *un.Unstructured + kind string + resName string + options DiffOptions + wantDiff *types.ResourceDiff + wantErr bool + }{ + "ModifiedResource": { + current: current, + desired: desired, + kind: "TestResource", + resName: "test-resource", + options: DefaultDiffOptions(), + wantDiff: &types.ResourceDiff{ + Gvk: current.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeModified, + // Nothing to strip, so Clean is content-equal to Raw. + Current: types.ResourceViews{Raw: current, Clean: current}, + Desired: types.ResourceViews{Raw: desired, Clean: desired}, + // LineDiffs will be checked separately + }, + }, + "NoChanges": { + current: current, + desired: noChanges, + kind: "TestResource", + resName: "test-resource", + options: DefaultDiffOptions(), + wantDiff: &types.ResourceDiff{ + Gvk: current.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeEqual, + // Equal diffs render nothing, so Clean is left nil by generation. + Current: types.ResourceViews{Raw: current}, + Desired: types.ResourceViews{Raw: noChanges}, + }, + }, + "NewResource": { + current: nil, + desired: desired, + kind: "TestResource", + resName: "test-resource", + options: DefaultDiffOptions(), + wantDiff: &types.ResourceDiff{ + Gvk: desired.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeAdded, + Current: types.ResourceViews{}, + Desired: types.ResourceViews{Raw: desired, Clean: desired}, + // LineDiffs will be checked separately + }, + }, + "RemovedResource": { + current: current, + desired: nil, + kind: "TestResource", + resName: "test-resource", + options: DefaultDiffOptions(), + wantDiff: &types.ResourceDiff{ + Gvk: current.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeRemoved, + Current: types.ResourceViews{Raw: current, Clean: current}, + Desired: types.ResourceViews{}, + // LineDiffs will be checked separately + }, + }, + "ModifiedResource_RawAndCleanViews": { + // Locks the Raw/Clean split: Raw retains the ignored annotation and + // server-side resourceVersion, while Clean has both stripped and + // keeps only the non-ignored spec change. + current: noisyCurrent, + desired: noisyDesired, + kind: "TestResource", + resName: "test-resource", + options: ignoreOpts, + wantDiff: &types.ResourceDiff{ + Gvk: noisyCurrent.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeModified, + Current: types.ResourceViews{Raw: noisyCurrent, Clean: noisyCurrentClean}, + Desired: types.ResourceViews{Raw: noisyDesired, Clean: noisyDesiredClean}, + }, + }, + "ModifiedResource_PrefersCurrentNamespace": { + // Regression: desired omits metadata.namespace but current has one. + // diff.Namespace must be the current namespace, not empty. + current: nsCurrent, + desired: nsDesired, + kind: "TestResource", + resName: "test-resource", + options: DefaultDiffOptions(), + wantDiff: &types.ResourceDiff{ + Gvk: nsCurrent.GroupVersionKind(), + ResourceName: "test-resource", + Namespace: "production", + DiffType: types.DiffTypeModified, + Current: types.ResourceViews{Raw: nsCurrent, Clean: nsCurrent}, + Desired: types.ResourceViews{Raw: nsDesired, Clean: nsDesired}, + }, + }, + "BothNil": { + current: nil, + desired: nil, + kind: "TestResource", + resName: "test-resource", + options: DefaultDiffOptions(), + wantErr: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + diff, err := GenerateDiffWithOptions(t.Context(), tt.current, tt.desired, tu.TestLogger(t, false), tt.options) + + if tt.wantErr { + if err == nil { + t.Errorf("GenerateDiffWithOptions() expected error but got none") + } + + return + } + + if err != nil { + t.Fatalf("GenerateDiffWithOptions() returned error: %v", err) + } + + if diff == nil { + t.Fatalf("GenerateDiffWithOptions() returned nil, want non-nil") + } + + // Check the basic properties + if diffStr := cmp.Diff(tt.wantDiff.Gvk, diff.Gvk); diffStr != "" { + t.Errorf("Gvk mismatch (-want +got):\n%s", diffStr) + } + + if diffStr := cmp.Diff(tt.wantDiff.ResourceName, diff.ResourceName); diffStr != "" { + t.Errorf("ResourceName mismatch (-want +got):\n%s", diffStr) + } + + if diffStr := cmp.Diff(tt.wantDiff.DiffType, diff.DiffType); diffStr != "" { + t.Errorf("DiffType mismatch (-want +got):\n%s", diffStr) + } + + // Check for line diffs - should be non-empty for changed resources + if diff.DiffType != types.DiffTypeEqual && len(diff.LineDiffs) == 0 { + t.Errorf("LineDiffs is empty for %s", name) + } + + if diffStr := cmp.Diff(tt.wantDiff.Namespace, diff.Namespace); diffStr != "" { + t.Errorf("Namespace mismatch (-want +got):\n%s", diffStr) + } + + // Check the Current and Desired views in full: Raw is the original + // object, Clean is the post-cleanup object (nil for equal diffs, and + // content-equal to Raw when there is nothing to strip). + if diffStr := cmp.Diff(tt.wantDiff.Current, diff.Current); diffStr != "" { + t.Errorf("Current views mismatch (-want +got):\n%s", diffStr) + } + + if diffStr := cmp.Diff(tt.wantDiff.Desired, diff.Desired); diffStr != "" { + t.Errorf("Desired views mismatch (-want +got):\n%s", diffStr) + } + }) + } +} + +func TestFormatDiff(t *testing.T) { + // Create test diffs + simpleDiffs := []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffEqual, Text: "unchanged line\n"}, + {Type: diffmatchpatch.DiffDelete, Text: "deleted line\n"}, + {Type: diffmatchpatch.DiffInsert, Text: "inserted line\n"}, + {Type: diffmatchpatch.DiffEqual, Text: "another unchanged line\n"}, + } + + tests := map[string]struct { + diffs []diffmatchpatch.Diff + options DiffOptions + contains []string + excludes []string + }{ + "EmptyDiffs": { + diffs: []diffmatchpatch.Diff{}, + options: DefaultDiffOptions(), + contains: []string{}, + excludes: []string{"unchanged", "deleted", "inserted"}, + }, + "StandardFormatting": { + diffs: simpleDiffs, + options: DefaultDiffOptions(), + contains: []string{ + "unchanged line", + "deleted line", + "inserted line", + "another unchanged line", + }, + }, + "WithoutColors": { + diffs: simpleDiffs, + options: func() DiffOptions { + opts := DefaultDiffOptions() + opts.UseColors = false + + return opts + }(), + contains: []string{ + " unchanged line", + "- deleted line", + "+ inserted line", + " another unchanged line", + }, + excludes: []string{ + "\x1b[31m", // Red color code + "\x1b[32m", // Green color code + }, + }, + "WithColors": { + diffs: simpleDiffs, + options: func() DiffOptions { + opts := DefaultDiffOptions() + opts.UseColors = true + + return opts + }(), + contains: []string{ + "unchanged line", + "deleted line", + "inserted line", + }, + }, + "CompactFormat": { + diffs: []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffEqual, Text: "context line 1\ncontext line 2\ncontext line 3\n"}, + {Type: diffmatchpatch.DiffDelete, Text: "deleted line 1\ndeleted line 2\n"}, + {Type: diffmatchpatch.DiffInsert, Text: "inserted line 1\ninserted line 2\n"}, + {Type: diffmatchpatch.DiffEqual, Text: "context line 4\ncontext line 5\ncontext line 6\n"}, + }, + options: func() DiffOptions { + opts := DefaultDiffOptions() + opts.Compact = true + opts.ContextLines = 1 + + return opts + }(), + contains: []string{ + "context line 3", + "deleted line 1", + "deleted line 2", + "inserted line 1", + "inserted line 2", + "context line 4", + }, + excludes: []string{ + "context line 1", + "context line 2", + "context line 5", + "context line 6", + }, + }, + "CustomPrefixes": { + diffs: simpleDiffs, + options: func() DiffOptions { + opts := DefaultDiffOptions() + opts.UseColors = false + opts.AddPrefix = "ADD " + opts.DeletePrefix = "DEL " + opts.ContextPrefix = "CTX " + + return opts + }(), + contains: []string{ + "CTX unchanged line", + "DEL deleted line", + "ADD inserted line", + "CTX another unchanged line", + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Format the diff + result := FormatDiff(tt.diffs, tt.options) + + // Check that the result contains expected substrings + for _, expected := range tt.contains { + if expected == "" { + continue + } + + if !strings.Contains(result, expected) { + t.Errorf("FormatDiff() result missing expected content: %q", expected) + } + } + + // Check that the result excludes certain substrings + for _, excluded := range tt.excludes { + if excluded == "" { + continue + } + + if strings.Contains(result, excluded) { + t.Errorf("FormatDiff() result contains unexpected content: %q", excluded) + } + } + }) + } +} + +func TestRemoveNestedPath(t *testing.T) { + tests := map[string]struct { + obj map[string]any + path string + want bool + wantObj map[string]any + descr string + }{ + "SimplePath": { + obj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + "namespace": "default", + }, + }, + path: "metadata.namespace", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + descr: "removes a simple nested field", + }, + "MapKeyPath": { + obj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "kubectl.kubernetes.io/last-applied-configuration": "large-json", + "argocd.argoproj.io/tracking-id": "some-id", + "keep-this": "value", + }, + }, + }, + path: "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "argocd.argoproj.io/tracking-id": "some-id", + "keep-this": "value", + }, + }, + }, + descr: "removes a specific key from a map", + }, + "MapKeyPathWithSlash": { + obj: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "argocd.argoproj.io/instance": "some-instance", + "provider": "aws", + }, + }, + }, + path: "metadata.labels[argocd.argoproj.io/instance]", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "provider": "aws", + }, + }, + }, + descr: "removes a label with slash in key", + }, + "NonExistentPath": { + obj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + path: "metadata.nonexistent", + want: false, + wantObj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + descr: "returns false for non-existent path", + }, + "NonExistentMapKey": { + obj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "keep-this": "value", + }, + }, + }, + path: "metadata.annotations[nonexistent-key]", + want: false, + wantObj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "keep-this": "value", + }, + }, + }, + descr: "returns false for non-existent map key", + }, + "EmptyPath": { + obj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + path: "", + want: false, + wantObj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + descr: "returns false for empty path", + }, + "RemoveEntireSection": { + obj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "key": "value", + }, + }, + "spec": map[string]any{ + "field": "value", + }, + }, + path: "metadata.annotations", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{}, + "spec": map[string]any{ + "field": "value", + }, + }, + descr: "removes entire nested map", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := removeNestedPath(tc.obj, tc.path) + + if got != tc.want { + t.Errorf("removeNestedPath() = %v, want %v for %s", got, tc.want, tc.descr) + } + + if diff := cmp.Diff(tc.wantObj, tc.obj); diff != "" { + t.Errorf("removeNestedPath() object mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go new file mode 100644 index 00000000..999d1368 --- /dev/null +++ b/internal/diff/renderer/diff_renderer.go @@ -0,0 +1,383 @@ +package renderer + +import ( + "cmp" + "fmt" + "maps" + "slices" + "strings" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// flattenGroups merges every group's diffs into a single map, for the human +// renderer's flat block. That block is only rendered for at most one input +// with an identity (see RenderDiffs), and for the composition renderer's +// identity-less reuse, so no two inputs' diffs are merged here; the +// structured flat view, which does span inputs, uses flatChangeSet instead. +// A nil group.Diffs is a safe no-op under maps.Copy. +func flattenGroups(groups []dt.XRDiffGroup) map[string]*dt.ResourceDiff { + out := make(map[string]*dt.ResourceDiff) + for _, g := range groups { + maps.Copy(out, g.Diffs) + } + + return out +} + +// DiffRenderer handles rendering diffs to output. +type DiffRenderer interface { + // RenderDiffs formats and outputs diffs, grouped by input XR. + // Diff output goes to DiffOptions.Stdout, errors go to DiffOptions.Stderr. + // The errs parameter contains the union of resource processing errors to + // include in output (the top-level/global error list). + // + // The warnings parameter carries non-fatal advisories for structured output only. Unlike errors, + // warnings have ALREADY been written to stderr, at the moment they were raised (see + // diffprocessor.WarningLogger) — emitting them at render time instead would lose any warning + // raised during a run that fails before rendering, and would report them out of chronological + // order with the work that produced them. The human renderer therefore ignores this parameter. + // + // One kind of warning is instead derived here, from the groups: the summary of added resources + // that could not be verified against the apiserver (see dryRunWarnings). Deciding what to summarise + // is a presentation decision, and the DryRunInfo it is built from only exists once the diffs do. + // Every renderer writes that summary to stderr; a structured one also appends it to warnings[], + // after the warnings passed in. + RenderDiffs(groups []dt.XRDiffGroup, errs []dt.OutputError, warnings []dt.OutputWarning) error +} + +// DefaultDiffRenderer implements the DiffRenderer interface. +type DefaultDiffRenderer struct { + logger logging.Logger + diffOpts DiffOptions +} + +// NewDiffRenderer creates a new DefaultDiffRenderer with the given options. +func NewDiffRenderer(logger logging.Logger, diffOpts DiffOptions) DiffRenderer { + return &DefaultDiffRenderer{ + logger: logger, + diffOpts: diffOpts, + } +} + +// SetDiffOptions updates the diff options used by the renderer. +func (r *DefaultDiffRenderer) SetDiffOptions(options DiffOptions) { + r.diffOpts = options +} + +func getKindName(d *dt.ResourceDiff) string { + return fmt.Sprintf("%s/%s", d.Gvk.Kind, d.ResourceName) +} + +// diffCounts holds the per-diff-type tally produced while rendering a list of +// diffs. outputCount is the number of diffs that actually emitted content +// (non-equal with a non-empty rendered body). +type diffCounts struct { + added int + modified int + removed int + equal int + output int +} + +// renderDiffList renders a flat set of diffs (sorted by kind/name) to stdout in +// the human-readable +++/~~~/--- form and returns the per-type counts. It does +// not print a summary; callers decide whether to emit a per-section or +// aggregate summary from the returned counts. Equal diffs are skipped. +func (r *DefaultDiffRenderer) renderDiffList(diffs map[string]*dt.ResourceDiff) (diffCounts, error) { + stdout := r.diffOpts.Stdout + + // Sort by GetKindName, which is how it's displayed to the user. + d := slices.AppendSeq(make([]*dt.ResourceDiff, 0, len(diffs)), maps.Values(diffs)) + slices.SortFunc(d, func(a, b *dt.ResourceDiff) int { + return cmp.Compare(getKindName(a), getKindName(b)) + }) + + var counts diffCounts + + for _, diff := range d { + resourceID := getKindName(diff) + + var header string + + // The added/modified/removed counters increment here, before the + // content-empty check below. This relies on the invariant that a + // non-equal ResourceDiff always renders non-empty content (its + // LineDiffs are non-trivial by construction). If that ever ceased to + // hold, counts.output could lag the type counters, desyncing the + // summary line from the number of rendered blocks. + switch diff.DiffType { + case dt.DiffTypeAdded: + counts.added++ + header = fmt.Sprintf("+++ %s", resourceID) + case dt.DiffTypeRemoved: + counts.removed++ + header = fmt.Sprintf("--- %s", resourceID) + case dt.DiffTypeModified: + counts.modified++ + header = fmt.Sprintf("~~~ %s", resourceID) + case dt.DiffTypeEqual: + counts.equal++ + // Skip rendering equal resources + continue + } + + content := FormatDiff(diff.LineDiffs, r.diffOpts) + + if content != "" { + if _, err := fmt.Fprintf(stdout, "%s\n%s\n---\n", header, content); err != nil { + r.logger.Debug("Error writing diff to output", "resource", resourceID, "error", err) + return counts, errors.Wrap(err, "failed to write diff to output") + } + + counts.output++ + } else { + r.logger.Debug("Empty diff content, skipping output", "resource", resourceID) + } + } + + return counts, nil +} + +// plus returns the field-wise sum of two tallies. Used to roll up per-XR counts +// into the cross-XR total for the aggregate footer. Returning a new value +// (rather than mutating) keeps every diffCounts method a value receiver, which +// also lets String satisfy Stringer on a value passed to the logger. +func (c diffCounts) plus(other diffCounts) diffCounts { + return diffCounts{ + added: c.added + other.added, + modified: c.modified + other.modified, + removed: c.removed + other.removed, + equal: c.equal + other.equal, + output: c.output + other.output, + } +} + +// String renders the tally as a single log-friendly field. Implementing +// Stringer lets callers log a diffCounts as one value ("counts", counts) +// instead of spelling out every field; fmt-based logr sinks honor it. (A +// structured-logging pass could later swap this for a logr.Marshaler to regain +// per-field queryability.) +func (c diffCounts) String() string { + return fmt.Sprintf("added=%d modified=%d removed=%d equal=%d output=%d", + c.added, c.modified, c.removed, c.equal, c.output) +} + +// summaryLine formats a "N added, N modified, N removed" fragment, omitting +// zero categories. Returns "" when there is nothing to report. +func (c diffCounts) summaryLine() string { + parts := make([]string, 0, 3) + + if c.added > 0 { + parts = append(parts, fmt.Sprintf("%d added", c.added)) + } + + if c.modified > 0 { + parts = append(parts, fmt.Sprintf("%d modified", c.modified)) + } + + if c.removed > 0 { + parts = append(parts, fmt.Sprintf("%d removed", c.removed)) + } + + return strings.Join(parts, ", ") +} + +// hasIdentity reports whether a group carries input-XR identity. The +// composition renderer reuses this renderer with identity-less groups (no +// single owning XR); those render as a flat, header-less block, preserving +// comp's output. The xr command always sets identity, producing per-XR +// sections. +func hasIdentity(g dt.XRDiffGroup) bool { + return g.XR.Kind != "" || g.XR.Name != "" +} + +// identitylessGroups wraps a flat diff map as a single group with no XR +// identity, the shape callers use when there is no single owning XR (the +// composition renderer's downstream diffs). RenderDiffs renders an +// identity-less group as a flat, header-less block. See hasIdentity. +func identitylessGroups(diffs map[string]*dt.ResourceDiff) []dt.XRDiffGroup { + return []dt.XRDiffGroup{{Diffs: diffs}} +} + +// RenderDiffs formats and prints the diffs. +// Diff output goes to r.diffOpts.Stdout, errors go to r.diffOpts.Stderr. +// +// Identity-bearing groups (the xr command) render as per-input-XR sections, +// each with a header and per-section summary, followed by an aggregate footer +// when there is more than one such group. Identity-less groups (the +// composition renderer's reuse) render as a single flat block, preserving the +// pre-grouping behavior. +// The warnings parameter is intentionally unused: warnings reach humans via stderr when they are +// raised, not at render time. See the DiffRenderer interface comment. +// +// The summary of unverified additions is derived from identity-bearing groups only. An identity-less +// group is the composition renderer's internal reuse of this one (see dt.XRDiffGroup), which happens +// once per composition; that renderer writes one run-wide summary itself, so deriving one here as well +// would repeat it. +func (r *DefaultDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.OutputError, _ []dt.OutputWarning) error { + r.logger.Debug("Rendering diffs to output", + "groupCount", len(groups), + "errorCount", len(errs), + "useColors", r.diffOpts.UseColors, + "compact", r.diffOpts.Compact) + + // Per-XR sections (and the aggregate footer) are only meaningful when more + // than one input XR is present — that's what there is to disambiguate. A + // single XR (or identity-less comp reuse) renders as a flat block, exactly + // as before grouping was introduced. + var xrGroups []dt.XRDiffGroup + + for _, g := range groups { + if hasIdentity(g) { + xrGroups = append(xrGroups, g) + } + } + + var err error + if len(xrGroups) > 1 { + err = r.renderGrouped(groups) + } else { + err = r.renderFlat(flattenGroups(groups)) + } + + if err != nil { + return err + } + + if err := writeWarnings(r.diffOpts.Stderr, xrDryRunWarnings(xrGroups)); err != nil { + return err + } + + // Write errors to stderr following Unix conventions + for _, e := range errs { + if _, err := fmt.Fprintln(r.diffOpts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } + + return nil +} + +// renderFlat renders a single flat block of diffs plus a "Summary:" line. This +// is the pre-grouping behavior, used for identity-less groups (comp reuse). +func (r *DefaultDiffRenderer) renderFlat(diffs map[string]*dt.ResourceDiff) error { + counts, err := r.renderDiffList(diffs) + if err != nil { + return err + } + + r.logger.Debug("Diff rendering complete", "counts", counts) + + if counts.output > 0 { + if line := counts.summaryLine(); line != "" { + if _, err := fmt.Fprintf(r.diffOpts.Stdout, "\nSummary: %s\n", line); err != nil { + return errors.Wrap(err, "failed to write summary to output") + } + } + } + + return nil +} + +// renderGrouped renders each group as a per-input-XR section (header + diffs + +// per-section summary, or an inline error / "No changes."), then an aggregate +// footer. Only called when there is more than one input XR (a single XR renders +// flat), so the footer always applies. Sections are emitted in input order. +func (r *DefaultDiffRenderer) renderGrouped(groups []dt.XRDiffGroup) error { + stdout := r.diffOpts.Stdout + + var ( + total diffCounts + unchangedXR int + errorXR int + ) + + for _, g := range groups { + // An identity-less group (should not normally be mixed in here) has no + // owning XR to head a section, so fold its diffs into the aggregate + // without a header or per-section summary. + if !hasIdentity(g) { + counts, err := r.renderDiffList(g.Diffs) + if err != nil { + return err + } + + total = total.plus(counts) + + continue + } + + if _, err := fmt.Fprintf(stdout, "=== %s/%s ===\n\n", g.XR.Kind, g.XR.Name); err != nil { + return errors.Wrap(err, "failed to write XR section header") + } + + if g.Err != nil { + errorXR++ + + if _, err := fmt.Fprintf(stdout, "Error: %s\n\n", g.Err.Message); err != nil { + return errors.Wrap(err, "failed to write XR error") + } + + continue + } + + counts, err := r.renderDiffList(g.Diffs) + if err != nil { + return err + } + + total = total.plus(counts) + + if counts.output == 0 { + unchangedXR++ + + if _, err := fmt.Fprintf(stdout, "No changes.\n\n"); err != nil { + return errors.Wrap(err, "failed to write no-changes message") + } + + continue + } + + if line := counts.summaryLine(); line != "" { + if _, err := fmt.Fprintf(stdout, "\nSummary: %s\n\n", line); err != nil { + return errors.Wrap(err, "failed to write section summary") + } + } + } + + return r.renderAggregateFooter(len(groups), total, unchangedXR, errorXR) +} + +// renderAggregateFooter writes the cross-XR totals line shown when more than one +// input XR was diffed. +func (r *DefaultDiffRenderer) renderAggregateFooter(xrCount int, total diffCounts, unchangedXR, errorXR int) error { + line := total.summaryLine() + if line == "" { + line = "no changes" + } + + var qualifiers []string + if unchangedXR > 0 { + qualifiers = append(qualifiers, fmt.Sprintf("%d unchanged", unchangedXR)) + } + + if errorXR > 0 { + qualifiers = append(qualifiers, fmt.Sprintf("%d error", errorXR)) + } + + suffix := "" + if len(qualifiers) > 0 { + suffix = fmt.Sprintf(" (%s)", strings.Join(qualifiers, ", ")) + } + + if _, err := fmt.Fprintf(r.diffOpts.Stdout, "%s\nTotal: %s across %d XRs%s\n", + strings.Repeat("=", 80), line, xrCount, suffix); err != nil { + return errors.Wrap(err, "failed to write aggregate footer") + } + + return nil +} diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go new file mode 100644 index 00000000..4905e250 --- /dev/null +++ b/internal/diff/renderer/diff_renderer_test.go @@ -0,0 +1,497 @@ +package renderer + +import ( + "bytes" + "strings" + "testing" + + "github.com/sergi/go-diff/diffmatchpatch" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { + // Create test diffs + addedDiff := &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, + ResourceName: "added-resource", + DiffType: dt.DiffTypeAdded, + LineDiffs: []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffInsert, Text: "apiVersion: example.org/v1\nkind: TestResource\nmetadata:\n name: added-resource\nspec:\n field: value"}, + }, + } + + modifiedDiff := &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, + ResourceName: "modified-resource", + DiffType: dt.DiffTypeModified, + LineDiffs: []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffEqual, Text: "apiVersion: example.org/v1\nkind: TestResource\nmetadata:\n name: modified-resource\n"}, + {Type: diffmatchpatch.DiffDelete, Text: "spec:\n field: old-value"}, + {Type: diffmatchpatch.DiffInsert, Text: "spec:\n field: new-value"}, + }, + } + + removedDiff := &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, + ResourceName: "removed-resource", + DiffType: dt.DiffTypeRemoved, + LineDiffs: []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffDelete, Text: "apiVersion: example.org/v1\nkind: TestResource\nmetadata:\n name: removed-resource\nspec:\n field: value"}, + }, + } + + equalDiff := &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, + ResourceName: "equal-resource", + DiffType: dt.DiffTypeEqual, + LineDiffs: []diffmatchpatch.Diff{}, + } + + changedBucket := modifiedDiffFor("Bucket", "my-xr-bucket") + + // noColor is the base options for the grouping rows (which assert on + // headers/summaries/footer, not on colored diff bodies). The formatting + // rows below set their own options to exercise prefixes/context/compact. + noColor := DefaultDiffOptions() + noColor.UseColors = false + + tests := map[string]struct { + groups []dt.XRDiffGroup + options DiffOptions + expectedOutputs []string + notExpected []string + }{ + "RenderAllDiffTypes": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + addedDiff.GetDiffKey(): addedDiff, + modifiedDiff.GetDiffKey(): modifiedDiff, + removedDiff.GetDiffKey(): removedDiff, + equalDiff.GetDiffKey(): equalDiff, + }), + options: DiffOptions{ + UseColors: false, + AddPrefix: "+ ", + DeletePrefix: "- ", + ContextPrefix: " ", + ContextLines: 3, + ChunkSeparator: "...", + Compact: false, + }, + expectedOutputs: []string{ + "+++ TestResource/added-resource", + "--- TestResource/removed-resource", + "~~~ TestResource/modified-resource", + "+ apiVersion: example.org/v1", + "- spec:", + "- field: old-value", + "+ spec:", + "+ field: new-value", + }, + notExpected: []string{ + "TestResource/equal-resource", // Equal resources should not be rendered + }, + }, + "CompactMode": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + modifiedDiff.GetDiffKey(): modifiedDiff, + }), + options: DiffOptions{ + UseColors: false, + AddPrefix: "+ ", + DeletePrefix: "- ", + ContextPrefix: " ", + ContextLines: 1, // Fewer context lines for compact mode + ChunkSeparator: "...", + Compact: true, + }, + expectedOutputs: []string{ + "~~~ TestResource/modified-resource", + "- spec:", + "- field: old-value", + "+ spec:", + "+ field: new-value", + }, + notExpected: []string{ + " apiVersion: example.org/v1", // Should be omitted due to context line limit + " metadata:", + }, + }, + "EmptyDiffs": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{}), + options: DiffOptions{ + UseColors: false, + AddPrefix: "+ ", + DeletePrefix: "- ", + ContextPrefix: " ", + ContextLines: 3, + ChunkSeparator: "...", + Compact: false, + }, + expectedOutputs: []string{}, + }, + "OnlyEqualDiffs": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + equalDiff.GetDiffKey(): equalDiff, + }), + options: DiffOptions{ + UseColors: false, + AddPrefix: "+ ", + DeletePrefix: "- ", + ContextPrefix: " ", + ContextLines: 3, + ChunkSeparator: "...", + Compact: false, + }, + expectedOutputs: []string{}, + notExpected: []string{"TestResource/equal-resource"}, + }, + "SummaryOutput": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + addedDiff.GetDiffKey(): addedDiff, + modifiedDiff.GetDiffKey(): modifiedDiff, + removedDiff.GetDiffKey(): removedDiff, + }), + options: DiffOptions{ + UseColors: false, + AddPrefix: "+ ", + DeletePrefix: "- ", + ContextPrefix: " ", + ContextLines: 3, + ChunkSeparator: "...", + Compact: false, + }, + expectedOutputs: []string{ + "Summary:", "1 added", "1 modified", "1 removed", + }, + }, + + // --- Grouping by input XR (identity-bearing groups) --- + + // A single identity-bearing group renders flat, exactly as before + // grouping existed: no section header and no aggregate footer, since + // there is nothing to disambiguate. + "SingleChangedXRRendersFlat": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "my-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + }, + options: noColor, + expectedOutputs: []string{ + "~~~ Bucket/my-xr-bucket", + "Summary: 1 modified", + }, + notExpected: []string{"===", "Total:"}, + }, + // Multiple identity-bearing groups: each gets a header in input order, an + // unchanged group says "No changes.", and an aggregate footer tallies all. + "MultipleXRsWithUnchanged": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "first-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "second-xr", map[string]*dt.ResourceDiff{}), + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/first-xr ===", + "=== XNopResource/second-xr ===", + "No changes.", + "Total: 1 modified across 2 XRs (1 unchanged)", + }, + }, + // Multiple XRs where one errored: the errored XR gets an inline Error + // section on stdout and is tallied in the footer. + "MultipleXRsWithError": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "ok-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + { + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNopResource", Name: "broken-xr"}, + Err: &dt.OutputError{ResourceID: "XNopResource/broken-xr", Message: "cannot get composition"}, + }, + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/ok-xr ===", + "=== XNopResource/broken-xr ===", + "Error: cannot get composition", + "Total: 1 modified across 2 XRs (1 error)", + }, + }, + // An identity-less group (the composition renderer's reuse) renders flat: + // no section header, no aggregate footer. + "IdentityLessRendersFlat": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + options: noColor, + expectedOutputs: []string{ + "~~~ Bucket/my-xr-bucket", + "Summary: 1 modified", + }, + notExpected: []string{"===", "Total:"}, + }, + // Changed + unchanged + errored in one batch: the footer reports all + // three qualifiers together (the design doc's illustrative example). + "AllThreeFooterQualifiers": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "changed-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "unchanged-xr", map[string]*dt.ResourceDiff{}), + { + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNopResource", Name: "broken-xr"}, + Err: &dt.OutputError{ResourceID: "XNopResource/broken-xr", Message: "boom"}, + }, + }, + options: noColor, + expectedOutputs: []string{ + "Total: 1 modified across 3 XRs (1 unchanged, 1 error)", + }, + }, + // A group whose only diff is equal renders "No changes." and counts as + // unchanged in the footer. + "GroupedEqualOnlyIsUnchanged": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "equal-xr", map[string]*dt.ResourceDiff{ + "equal": { + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + ResourceName: "bucket-eq", + DiffType: dt.DiffTypeEqual, + }, + }), + xrGroup("XNopResource", "changed-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/equal-xr ===", + "No changes.", + "Total: 1 modified across 2 XRs (1 unchanged)", + }, + }, + // A mixed batch (identity-less group alongside identity-bearing ones): + // the identity-less group's diffs fold into the aggregate with no header. + // Two identity-bearing groups ensure the footer fires so the fold-in is + // observable in the total. + "MixedIdentityLessFoldsInWithoutHeader": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "real-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "real-xr-2", map[string]*dt.ResourceDiff{ + "q": modifiedDiffFor("Queue", "real-queue"), + }), + {Diffs: map[string]*dt.ResourceDiff{ + "extra": modifiedDiffFor("Topic", "loose-topic"), + }}, + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/real-xr ===", + "=== XNopResource/real-xr-2 ===", + "~~~ Topic/loose-topic", // identity-less diff still rendered + "Total: 3 modified across 3 XRs", // all three counted + }, + notExpected: []string{ + "=== /", "=== (unknown)", // no header for the identity-less group + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + // Create a buffer to capture output + var buffer bytes.Buffer + + // Set the buffer as stdout in options + opts := tt.options + opts.Stdout = &buffer + opts.Stderr = &bytes.Buffer{} // discard stderr for these tests + + // Create a renderer with options pointing to buffer + renderer := NewDiffRenderer(logger, opts) + + // Call the method under test + err := renderer.RenderDiffs(tt.groups, nil, nil) + if err != nil { + t.Fatalf("RenderDiffs() failed with error: %v", err) + } + + // Get the output as a string + output := buffer.String() + + // Check for expected output + for _, expected := range tt.expectedOutputs { + if !strings.Contains(output, expected) { + t.Errorf("Expected output to contain %q but it didn't\nOutput: %s", expected, output) + } + } + + // Check for things that should not be in the output + for _, notExpected := range tt.notExpected { + if strings.Contains(output, notExpected) { + t.Errorf("Output should not contain %q but it did\nOutput: %s", notExpected, output) + } + } + }) + } +} + +func TestDefaultDiffRenderer_RenderDiffs_WithErrors(t *testing.T) { + tests := map[string]struct { + errs []dt.OutputError + expected []string + }{ + "ErrorsWithResourceID": { + errs: []dt.OutputError{ + {ResourceID: "Resource/my-resource", Message: "failed to render"}, + {ResourceID: "OtherResource/other", Message: "connection refused"}, + }, + expected: []string{ + "ERROR: Resource/my-resource: failed to render", + "ERROR: OtherResource/other: connection refused", + }, + }, + "ErrorsWithEmptyResourceID": { + errs: []dt.OutputError{ + {ResourceID: "", Message: "cluster connection timeout"}, + {ResourceID: "Resource/has-id", Message: "specific error"}, + }, + expected: []string{ + "ERROR: : cluster connection timeout", + "ERROR: Resource/has-id: specific error", + }, + }, + "NoErrors": { + errs: []dt.OutputError{}, + expected: []string{}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + // Errors go to stderr now, so capture stderr + var stderr bytes.Buffer + + opts := DefaultDiffOptions() + opts.Stdout = &bytes.Buffer{} // discard stdout + opts.Stderr = &stderr + + renderer := NewDiffRenderer(logger, opts) + + err := renderer.RenderDiffs(identitylessGroups(map[string]*dt.ResourceDiff{}), tt.errs, nil) + if err != nil { + t.Fatalf("RenderDiffs() failed with error: %v", err) + } + + output := stderr.String() + + for _, expectedMsg := range tt.expected { + if !strings.Contains(output, expectedMsg) { + t.Errorf("Expected stderr to contain %q but it didn't\nStderr: %s", expectedMsg, output) + } + } + }) + } +} + +// modifiedDiffFor builds a minimal modified diff for a composed resource of the +// given kind/name, used to populate identity-bearing groups in grouped tests. +func modifiedDiffFor(kind, name string) *dt.ResourceDiff { + return &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: kind}, + ResourceName: name, + DiffType: dt.DiffTypeModified, + LineDiffs: []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffDelete, Text: "spec:\n field: old"}, + {Type: diffmatchpatch.DiffInsert, Text: "spec:\n field: new"}, + }, + } +} + +// xrGroup builds an identity-bearing XRDiffGroup for the given input XR kind/name. +func xrGroup(kind, name string, diffs map[string]*dt.ResourceDiff) dt.XRDiffGroup { + return dt.XRDiffGroup{ + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: kind, Name: name}, + Diffs: diffs, + } +} + +func TestGetLineDiff(t *testing.T) { + tests := map[string]struct { + oldText string + newText string + expected []diffmatchpatch.Operation + }{ + "NoChanges": { + oldText: "line1\nline2\nline3\n", + newText: "line1\nline2\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + }, + }, + "LineAdded": { + oldText: "line1\nline2\n", + newText: "line1\nline2\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + diffmatchpatch.DiffInsert, + }, + }, + "LineRemoved": { + oldText: "line1\nline2\nline3\n", + newText: "line1\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + diffmatchpatch.DiffDelete, + diffmatchpatch.DiffEqual, + }, + }, + "LineModified": { + oldText: "line1\nline2\nline3\n", + newText: "line1\nmodified2\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + diffmatchpatch.DiffDelete, + diffmatchpatch.DiffInsert, + diffmatchpatch.DiffEqual, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := GetLineDiff(tt.oldText, tt.newText) + + // Check that we have the expected diff types + if len(result) != len(tt.expected) { + t.Errorf("GetLineDiff() returned %d diffs, want %d", len(result), len(tt.expected)) + + for i, diff := range result { + t.Logf("Diff %d: Type=%s, Text=%q", i, diff.Type, diff.Text) + } + + return + } + + // Verify the types match in sequence + for i, expectedType := range tt.expected { + if result[i].Type != expectedType { + t.Errorf("GetLineDiff() diff[%d] has type %s, want %s", i, result[i].Type, expectedType) + } + } + }) + } +} diff --git a/internal/diff/renderer/dry_run_warnings.go b/internal/diff/renderer/dry_run_warnings.go new file mode 100644 index 00000000..ddcc55d4 --- /dev/null +++ b/internal/diff/renderer/dry_run_warnings.go @@ -0,0 +1,211 @@ +package renderer + +import ( + "cmp" + "fmt" + "io" + "maps" + "slices" + "strconv" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// dryRunSkipSummary is how one dt.DryRunSkipReason is summarised for a human: the warning's message, +// and the context key its DryRunInfo.Detail is reported under (none for a reason without a cause). +type dryRunSkipSummary struct { + message string + detailKey string +} + +// storageVersionNote is appended to the summary of additions whose CRD stores them at a version other +// than the one requested (dt.DryRunInfo.StorageVersion). Their local prediction defaulted against the +// requested version only, so the storage version's defaults, and any conversion through it, are not +// in their diffs. The versions are reported under the `versions` context key. +const storageVersionNote = "; they are stored at a version other than the one requested, whose defaults and conversion the cluster may apply but their diffs do not" + +// summaryFor returns how the additions of group g are summarised, and false for a group that is not +// worth telling the user about. Additions stored at a version other than the one requested get +// storageVersionNote appended to their reason's message. +// +// dt.DryRunSkipDisabled is summarised only for additions stored at another version: +// --dry-run-on=existing is the user's own choice, so the additions it leaves unverified are not news to +// them, but that the cluster stores some at a version the prediction did not default against is. +func summaryFor(g dryRunGroup) (dryRunSkipSummary, bool) { + var s dryRunSkipSummary + + switch g.reason { + case dt.DryRunSkipForbidden: + s = dryRunSkipSummary{ + message: "skipped apiserver verification of added resources: not authorized to create them, so their diffs omit server-side defaulting and admission", + detailKey: "reason", + } + case dt.DryRunSkipNamespaceNotFound: + s = dryRunSkipSummary{ + message: "skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission", + detailKey: "cause", + } + case dt.DryRunSkipWebhookUnavailable: + s = dryRunSkipSummary{ + message: "skipped apiserver verification of added resources: the cluster could not complete admission", + detailKey: "cause", + } + case dt.DryRunSkipDisabled: + if g.storageVersion == "" { + return dryRunSkipSummary{}, false + } + + // The user's own choice has no cause, so there is no detail key. + s = dryRunSkipSummary{message: "did not verify added resources against the apiserver (--dry-run-on=existing)"} + default: + return dryRunSkipSummary{}, false + } + + if g.storageVersion != "" { + s.message += storageVersionNote + } + + return s, true +} + +// dryRunGroup is what one summary warning stands for: additions of one GVK in one namespace that were +// not verified for one reason and one cause, and are stored at one version. +// +// storageVersion never splits a group in practice: one GVK has one CRD, and so one storage version. It +// is part of the key so that the note a warning carries is always true of every resource it counts. +type dryRunGroup struct { + gvk string + version string // the requested version, already part of gvk; kept for the `versions` key + namespace string + reason dt.DryRunSkipReason + detail string + storageVersion string +} + +// dryRunWarnings derives, from the DryRunInfo carried on the supplied diffs, the warnings that tell a +// human which added resources could not be verified against the apiserver, and why. +// +// It is the only place a human sees this: the text renderer does not show DryRunInfo. So there is one +// warning per GVK + namespace + distinct cause, rather than per reason, because grouping any coarser +// would keep one cause and silently drop the rest. Each warning counts the resources behind it under +// the `count` context key and, for additions stored at a version other than the one requested, names +// both versions under the `versions` context key. A resource that appears in more than one set (two +// overlapping inputs that render the same object) is one resource and is counted once. +// +// The result is sorted by GVK, namespace, reason, cause and storage version, since diffs arrive in map +// order. It is nil +// when there is nothing to report. +// +// The per-resource dryRun field in structured output stays the machine-readable record: a warning has +// no resource anchor, so it cannot tell a pipeline WHICH additions were degraded. +func dryRunWarnings(diffSets ...map[string]*dt.ResourceDiff) []dt.OutputWarning { + resources := make(map[dryRunGroup]map[string]struct{}) + + for _, diffs := range diffSets { + for _, d := range diffs { + if d.DryRun == nil { + continue + } + + g := dryRunGroup{ + gvk: d.Gvk.String(), + version: d.Gvk.Version, + namespace: d.Namespace, + reason: d.DryRun.SkipReason, + detail: d.DryRun.Detail, + storageVersion: d.DryRun.StorageVersion, + } + + if _, summarised := summaryFor(g); !summarised { + continue + } + + if resources[g] == nil { + resources[g] = make(map[string]struct{}) + } + + resources[g][d.GetDiffKey()] = struct{}{} + } + } + + if len(resources) == 0 { + return nil + } + + groups := slices.SortedFunc(maps.Keys(resources), func(a, b dryRunGroup) int { + return cmp.Or( + cmp.Compare(a.gvk, b.gvk), + cmp.Compare(a.namespace, b.namespace), + cmp.Compare(a.reason, b.reason), + cmp.Compare(a.detail, b.detail), + cmp.Compare(a.storageVersion, b.storageVersion), + ) + }) + + warnings := make([]dt.OutputWarning, 0, len(groups)) + + for _, g := range groups { + s, _ := summaryFor(g) + + context := map[string]string{ + "gvk": g.gvk, + "namespace": g.namespace, + "count": strconv.Itoa(len(resources[g])), + } + if s.detailKey != "" { + context[s.detailKey] = g.detail + } + + if g.storageVersion != "" { + context["versions"] = fmt.Sprintf("requested %s, stored %s", g.version, g.storageVersion) + } + + warnings = append(warnings, dt.OutputWarning{Message: s.message, Context: context}) + } + + return warnings +} + +// xrDryRunWarnings is dryRunWarnings over the diffs an xr run emits, across every input. +// +// A failed input's diffs are withheld, so nothing is summarised for it: the summary says how far to +// trust the diffs that are shown, and a failed input shows none. Its error is reported instead. +func xrDryRunWarnings(groups []dt.XRDiffGroup) []dt.OutputWarning { + sets := make([]map[string]*dt.ResourceDiff, 0, len(groups)) + for _, g := range groups { + sets = append(sets, g.Diffs) + } + + return dryRunWarnings(sets...) +} + +// compDryRunWarnings is dryRunWarnings over the downstream diffs a comp run emits, across every +// composition: one run-wide summary rather than one per composition, matching where warnings[] lives. +// As for xr, a failed XR's diffs are withheld and so are not summarised. +func compDryRunWarnings(output *CompDiffOutput) []dt.OutputWarning { + var sets []map[string]*dt.ResourceDiff + + for _, comp := range output.Compositions { + for _, impact := range comp.ImpactAnalysis { + sets = append(sets, impact.Diffs) + } + } + + return dryRunWarnings(sets...) +} + +// writeWarnings writes each warning to w as its human-readable stderr line. +// +// Only warnings derived at render time belong here. Those raised during the run were written when they +// were raised (see diffprocessor.WarningLogger), and writing them again would print them twice. +func writeWarnings(w io.Writer, warnings []dt.OutputWarning) error { + for _, warning := range warnings { + if _, err := fmt.Fprintln(w, warning.FormatWarning()); err != nil { + return errors.Wrap(err, "failed to write warning to stderr") + } + } + + return nil +} diff --git a/internal/diff/renderer/dry_run_warnings_test.go b/internal/diff/renderer/dry_run_warnings_test.go new file mode 100644 index 00000000..47b8e642 --- /dev/null +++ b/internal/diff/renderer/dry_run_warnings_test.go @@ -0,0 +1,310 @@ +package renderer + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +const ( + forbiddenSummary = "skipped apiserver verification of added resources: not authorized to create them, so their diffs omit server-side defaulting and admission" + namespaceSummary = "skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission" + webhookSummary = "skipped apiserver verification of added resources: the cluster could not complete admission" + storageNote = "; they are stored at a version other than the one requested, whose defaults and conversion the cluster may apply but their diffs do not" + disabledSummary = "did not verify added resources against the apiserver (--dry-run-on=existing)" + testResourceGVK = "example.org/v1, Kind=TestResource" + otherResourceGVK = "example.org/v1, Kind=OtherResource" +) + +// unverifiedAddition builds the diff of an added resource whose dry-run create was skipped for reason, +// with the cluster's explanation detail. +func unverifiedAddition(kind, name, namespace string, reason dt.DryRunSkipReason, detail string) *dt.ResourceDiff { + return &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: kind}, + Namespace: namespace, + ResourceName: name, + DiffType: dt.DiffTypeAdded, + DryRun: &dt.DryRunInfo{SkipReason: reason, Detail: detail}, + } +} + +// storedAt makes d an addition requested at version and stored, by its CRD, at storageVersion. +func storedAt(d *dt.ResourceDiff, version, storageVersion string) *dt.ResourceDiff { + d.Gvk.Version = version + d.DryRun.StorageVersion = storageVersion + + return d +} + +// diffMap keys diffs the way the diff calculator does. +func diffMap(diffs ...*dt.ResourceDiff) map[string]*dt.ResourceDiff { + out := make(map[string]*dt.ResourceDiff, len(diffs)) + for _, d := range diffs { + out[d.GetDiffKey()] = d + } + + return out +} + +// TestDryRunWarnings covers how the advisory for unverified additions is derived from the DryRunInfo +// on the diffs: one warning per GVK + namespace + distinct cause, counting the resources behind it, and +// no cause ever dropped. The text renderer shows no DryRunInfo, so this warning is the only place a +// human learns that an addition was not verified, or why. +func TestDryRunWarnings(t *testing.T) { + tests := map[string]struct { + reason string + diffSets []map[string]*dt.ResourceDiff + want []dt.OutputWarning + }{ + "ForbiddenCollapsesPerGVKAndNamespace": { + reason: "Four denied additions across three GVK+namespace groups raise three warnings, each counting its resources.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipForbidden, "no create"), + unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipForbidden, "no create"), + unverifiedAddition("TestResource", "c", "ns-b", dt.DryRunSkipForbidden, "no create"), + unverifiedAddition("OtherResource", "d", "ns-a", dt.DryRunSkipForbidden, "no create"), + )}, + want: []dt.OutputWarning{ + {Message: forbiddenSummary, Context: map[string]string{"gvk": otherResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "1"}}, + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "2"}}, + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-b", "reason": "no create", "count": "1"}}, + }, + }, + "DistinctCausesAreAllKept": { + reason: "Two different apiserver causes in one GVK+namespace group are two warnings. Grouping on reason alone would keep one and silently drop the other.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipWebhookUnavailable, `failed calling webhook "policy.example.org"`), + unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipWebhookUnavailable, `failed calling webhook "quota.example.org"`), + unverifiedAddition("TestResource", "c", "ns-a", dt.DryRunSkipWebhookUnavailable, `failed calling webhook "policy.example.org"`), + )}, + want: []dt.OutputWarning{ + {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `failed calling webhook "policy.example.org"`, "count": "2"}}, + {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `failed calling webhook "quota.example.org"`, "count": "1"}}, + }, + }, + "MissingNamespaceAcrossInputsCountsEachResourceOnce": { + reason: "Additions from several inputs share one warning. A resource two overlapping inputs both render is one resource, so it is counted once.", + diffSets: []map[string]*dt.ResourceDiff{ + diffMap( + unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + unverifiedAddition("TestResource", "shared", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + ), + diffMap( + unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + unverifiedAddition("TestResource", "shared", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + ), + }, + want: []dt.OutputWarning{ + {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `namespaces "ns-a" not found`, "count": "3"}}, + }, + }, + "ClusterScopedResourceKeepsAnEmptyNamespace": { + reason: "A cluster-scoped addition still carries the namespace key, empty, so every summary has the same context keys.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "", dt.DryRunSkipForbidden, "no create"), + )}, + want: []dt.OutputWarning{ + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "", "reason": "no create", "count": "1"}}, + }, + }, + "ReasonsAreOrderedDeterministically": { + reason: "Diffs arrive in map order, so the summary is sorted: by GVK, then namespace, then reason, then cause.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipWebhookUnavailable, "timeout"), + unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipForbidden, "no create"), + unverifiedAddition("TestResource", "c", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone"), + )}, + want: []dt.OutputWarning{ + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "1"}}, + {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "1"}}, + {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "timeout", "count": "1"}}, + }, + }, + "DisabledIsNotWarnedAbout": { + reason: "--dry-run-on=existing is the user's own choice, so the additions it leaves unverified are not news to them.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipDisabled, ""), + )}, + want: nil, + }, + "StorageVersionIsNotedOnTheGroupsWarning": { + reason: "Additions requested at a version their CRD does not store say which version it stores, on the warning their group already raises, without splitting the group.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + storedAt(unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipForbidden, "no create"), "v1alpha1", "v1"), + storedAt(unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipForbidden, "no create"), "v1alpha1", "v1"), + )}, + want: []dt.OutputWarning{ + {Message: forbiddenSummary + storageNote, Context: map[string]string{ + "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "reason": "no create", "versions": "requested v1alpha1, stored v1", "count": "2", + }}, + }, + }, + "StorageVersionNoteStaysWithItsOwnVersion": { + reason: "Additions of one kind at the storage version and at another version are already two GVKs, so only the one at the other version carries the note.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + storedAt(unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone"), "v1alpha1", "v1"), + unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone"), + )}, + want: []dt.OutputWarning{ + {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "1"}}, + {Message: namespaceSummary + storageNote, Context: map[string]string{ + "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "cause": "gone", "versions": "requested v1alpha1, stored v1", "count": "1", + }}, + }, + }, + "DisabledIsWarnedAboutForAStorageVersion": { + reason: "--dry-run-on=existing is the user's choice, but that the cluster stores the additions at another version is not, so it is news worth a warning.", + diffSets: []map[string]*dt.ResourceDiff{diffMap( + storedAt(unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipDisabled, ""), "v1alpha1", "v1"), + unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipDisabled, ""), + )}, + want: []dt.OutputWarning{ + {Message: disabledSummary + storageNote, Context: map[string]string{ + "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "versions": "requested v1alpha1, stored v1", "count": "1", + }}, + }, + }, + "VerifiedDiffsRaiseNothing": { + reason: "A diff without DryRunInfo was verified, and there is nothing to say about it.", + diffSets: []map[string]*dt.ResourceDiff{diffMap(&dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, + Namespace: "ns-a", + ResourceName: "a", + DiffType: dt.DiffTypeAdded, + })}, + want: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if d := cmp.Diff(tc.want, dryRunWarnings(tc.diffSets...)); d != "" { + t.Errorf("\n%s\ndryRunWarnings() mismatch (-want +got):\n%s", tc.reason, d) + } + }) + } +} + +// TestRenderers_EmitDryRunWarnings covers every entry point a command renders through. Each derives the +// summary from what it is handed, writes it to stderr in every output mode, and adds it to warnings[] in +// structured modes after the warnings raised during the run. The summary spans inputs, so two XRs' +// additions are counted together; a failed XR shows no diffs and contributes nothing. +func TestRenderers_EmitDryRunWarnings(t *testing.T) { + raised := dt.OutputWarning{Message: "raised during the run"} + summary := dt.OutputWarning{ + Message: namespaceSummary, + Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "2"}, + } + + first := unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone") + second := unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone") + + xrGroups := []dt.XRDiffGroup{ + {XR: corev1.ObjectReference{Kind: "XR", Name: "one"}, Diffs: diffMap(first)}, + {XR: corev1.ObjectReference{Kind: "XR", Name: "two"}, Diffs: diffMap(second)}, + {XR: corev1.ObjectReference{Kind: "XR", Name: "broken"}, Err: &dt.OutputError{ResourceID: "XR/broken", Message: "boom"}}, + } + + compOutput := func() *CompDiffOutput { + return &CompDiffOutput{ + Compositions: []CompositionDiff{{ + Name: "xrs.example.org", + ImpactAnalysis: []XRImpact{ + {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "one"}, Status: XRStatusChanged, Diffs: diffMap(first)}, + {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "two"}, Status: XRStatusChanged, Diffs: diffMap(second)}, + {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "broken"}, Status: XRStatusError}, + }, + }}, + Warnings: []dt.OutputWarning{raised}, + } + } + + type renderFn func(opts DiffOptions) error + + tests := map[string]struct { + format OutputFormat + render renderFn + // structured is false for the human renderers, which have no warnings[] to check. + structured bool + }{ + "XRText": { + format: OutputFormatDiff, + render: func(opts DiffOptions) error { + return NewDiffRenderer(tu.TestLogger(t, false), opts).RenderDiffs(xrGroups, nil, []dt.OutputWarning{raised}) + }, + }, + "XRJSON": { + format: OutputFormatJSON, + structured: true, + render: func(opts DiffOptions) error { + return NewStructuredDiffRenderer(tu.TestLogger(t, false), opts).RenderDiffs(xrGroups, nil, []dt.OutputWarning{raised}) + }, + }, + "CompText": { + format: OutputFormatDiff, + render: func(opts DiffOptions) error { + logger := tu.TestLogger(t, false) + return NewDefaultCompDiffRenderer(logger, NewDiffRenderer(logger, opts), opts).RenderCompDiff(compOutput()) + }, + }, + "CompJSON": { + format: OutputFormatJSON, + structured: true, + render: func(opts DiffOptions) error { + return NewStructuredCompDiffRenderer(tu.TestLogger(t, false), opts).RenderCompDiff(compOutput()) + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + + opts := DefaultDiffOptions() + opts.UseColors = false + opts.Format = tc.format + opts.Stdout = &stdout + opts.Stderr = &stderr + + if err := tc.render(opts); err != nil { + t.Fatalf("render failed: %v", err) + } + + // Exactly once on stderr: the comp text renderer reuses the XR text renderer per + // composition, and a summary emitted at each level would repeat. + if got := strings.Count(stderr.String(), summary.FormatWarning()); got != 1 { + t.Errorf("stderr carries the summary %d times, want 1:\n%s", got, stderr.String()) + } + + // Warnings raised during the run reached stderr when they were raised; a renderer that + // re-emitted them would print them twice. + if strings.Contains(stderr.String(), raised.FormatWarning()) { + t.Errorf("stderr repeats a warning raised during the run:\n%s", stderr.String()) + } + + if !tc.structured { + return + } + + var out struct { + Warnings []dt.OutputWarning `json:"warnings"` + } + + if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { + t.Fatalf("cannot parse structured output: %v\n%s", err, stdout.String()) + } + + if d := cmp.Diff([]dt.OutputWarning{raised, summary}, out.Warnings); d != "" { + t.Errorf("warnings[] mismatch (-want +got):\n%s", d) + } + }) + } +} diff --git a/internal/diff/renderer/structured_renderer.go b/internal/diff/renderer/structured_renderer.go new file mode 100644 index 00000000..db05088e --- /dev/null +++ b/internal/diff/renderer/structured_renderer.go @@ -0,0 +1,725 @@ +package renderer + +import ( + "cmp" + "encoding/json" + "fmt" + "maps" + "reflect" + "slices" + + corev1 "k8s.io/api/core/v1" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// OutputFormat represents the desired output format for diffs. +type OutputFormat string + +const ( + // OutputFormatDiff is the default human-readable diff format. + OutputFormatDiff OutputFormat = "diff" + // OutputFormatJSON outputs structured JSON. + OutputFormatJSON OutputFormat = "json" + // OutputFormatYAML outputs structured YAML. + OutputFormatYAML OutputFormat = "yaml" +) + +// OutputVersion selects the schema of structured (JSON/YAML) output. Every structured document +// names its version in its top-level outputVersion field, so a consumer can detect the shape. +type OutputVersion string + +const ( + // OutputVersionV1 is the original schema: xr emits the deprecated flat changes[] beside xrs[], and + // comp's impactAnalysis[] entries inline the XR's identity, wrap changes in downstreamChanges and + // carry a per-XR (and per-composition) error string. + OutputVersionV1 OutputVersion = "v1" + // OutputVersionV2 is the converged schema: xrs[] and impactAnalysis[] hold the same entry type + // (xrEntryWire), xr drops the flat changes[], and comp carries structured errors[] per XR and per + // composition, with failed compositions also reported in the top-level errors[]. + OutputVersionV2 OutputVersion = "v2" +) + +// XRStatus represents the processing status of an XR in composition diffs. +type XRStatus string + +const ( + // XRStatusChanged indicates the XR has downstream resource changes. + XRStatusChanged XRStatus = "changed" + // XRStatusUnchanged indicates the XR has no downstream resource changes. + XRStatusUnchanged XRStatus = "unchanged" + // XRStatusError indicates an error occurred while processing the XR. + XRStatusError XRStatus = "error" + // XRStatusFiltered indicates the XR matched the composition by name but was excluded from + // evaluation because it would not adopt the composition change being diffed. The specific cause + // is carried separately in XRImpact.FilterReason (outcome and reason are intentionally divorced + // so the reason set can grow without expanding the status enum). The XR is surfaced in impact + // analysis with no downstream changes so users see the skip explicitly. + XRStatusFiltered XRStatus = "filtered" +) + +// FilterReason explains why an XRImpact has XRStatusFiltered. It is only meaningful when +// XRImpact.Status == XRStatusFiltered. +type FilterReason string + +const ( + // FilterReasonManualPolicy indicates the XR was excluded because it has a Manual + // compositionUpdatePolicy and --include-manual was not set. Such XRs are pinned to a specific + // revision and would not adopt the composition change automatically. + FilterReasonManualPolicy FilterReason = "manual_policy" + // FilterReasonRevisionSelectorMismatch indicates the XR was excluded because it has an Automatic + // compositionUpdatePolicy with a compositionRevisionSelector that does not match the labels of + // the composition change being diffed. Such XRs would not select the resulting revision. + FilterReasonRevisionSelectorMismatch FilterReason = "revision_selector_mismatch" + // FilterReasonDeleting indicates the XR was excluded because it has a metadata.deletionTimestamp. + // Crossplane's composite reconciler takes its deletion path for such an XR — it tears down + // composed resources rather than composing them — so the XR will never adopt the resulting + // revision, and rendering it produces no composed resources to diff against. + FilterReasonDeleting FilterReason = "deleting" +) + +// OutputError is an alias for dt.OutputError for convenience. +// Use this type for error handling in structured output. +type OutputError = dt.OutputError + +// StructuredDiffOutput represents the structured output format for diffs. +// Note: Only JSON tags are used because sigs.k8s.io/yaml uses JSON tags for YAML serialization. +// +// This is the v1 document (OutputVersionV1): the v2 document (structuredDiffOutputV2) plus the +// deprecated flat Changes. It is only ever derived from the v2 one, by structuredDiffOutputV2.v1. +type StructuredDiffOutput struct { + // OutputVersion names the schema of this document. + OutputVersion OutputVersion `json:"outputVersion"` + + // Summary counts exactly what Changes lists: each distinct change once, however many input XRs + // reach it. Per-input counts are on each Xrs entry; where inputs overlap those can sum to more. + Summary Summary `json:"summary"` + + // Changes is the flat, ungrouped list of all resource changes across every + // input XR. + // + // Deprecated: use Xrs for per-input-XR grouping. This field is retained for + // backward compatibility and will be removed in a future major release. + Changes []ChangeDetail `json:"changes"` + + // Errors is the union of resource-processing errors across all input XRs + // (the top-level/global error list). Each errored XR also surfaces its + // error inside its own Xrs entry; this list stays complete for consumers + // that read errors here. + Errors []dt.OutputError `json:"errors,omitempty"` + + // Xrs groups changes by the input XR/claim that produced them, one entry + // per input in input order. This is the recommended view; the flat Changes + // field above is deprecated. + Xrs []xrEntryWire `json:"xrs"` + + // Warnings is the list of non-fatal advisories raised during the run — things worth knowing that + // did not invalidate the diff. Unlike Errors they do NOT affect the exit code, so a consumer + // gating on failure should read Errors. Warnings are not grouped per input XR because they + // originate deep in the call stack, below the point where the owning input is known. They are + // sorted by message, then context, followed by the renderer's summary of unverified additions (see + // dryRunWarnings). + Warnings []dt.OutputWarning `json:"warnings,omitempty"` +} + +// structuredDiffOutputV2 is the v2 (OutputVersionV2) xr document, and the one the renderer always +// builds: v1 is derived from it (see v1). Its xrs[] entries are the xrEntryWire that comp's +// impactAnalysis[] also holds. +type structuredDiffOutputV2 struct { + OutputVersion OutputVersion `json:"outputVersion"` + // Summary counts each distinct change once, however many input XRs reach it, as in v1. + Summary Summary `json:"summary"` + Errors []dt.OutputError `json:"errors,omitempty"` + Xrs []xrEntryWire `json:"xrs"` + Warnings []dt.OutputWarning `json:"warnings,omitempty"` +} + +// v1 projects the document to v1, which differs only by adding the deprecated flat changes[] (see +// flatChangeSet). That list is passed in because it is not recoverable from the document: it holds +// each distinct change once, however many input XRs reach it. +func (d structuredDiffOutputV2) v1(changes []ChangeDetail) StructuredDiffOutput { + return StructuredDiffOutput{ + OutputVersion: OutputVersionV1, + Summary: d.Summary, + Changes: changes, + Errors: d.Errors, + Xrs: d.Xrs, + Warnings: d.Warnings, + } +} + +// xrEntryWire is the per-XR entry, shared by xr's xrs[] (in both versions) and comp's v2 +// impactAnalysis[], so one parser handles both. comp's v1 entry is projected from it (see +// xrEntryWire.v1). Build it only with toXREntry. +// +// XR is projected to apiVersion/kind/name/namespace (see toXREntry). Summary and Changes are always +// present, zero and empty unless Status is changed. FilterReason and FilterDetail are set only by comp, +// for a filtered XR. Errors is set only when Status is error, and holds the same OutputError the +// command lists in its top-level errors[]. +type xrEntryWire struct { + XR corev1.ObjectReference `json:"xr"` + Status XRStatus `json:"status"` + FilterReason FilterReason `json:"filterReason,omitempty"` + FilterDetail string `json:"filterDetail,omitempty"` + Summary Summary `json:"summary"` + Changes []ChangeDetail `json:"changes"` + Errors []dt.OutputError `json:"errors,omitempty"` +} + +// toXREntry builds the per-XR entry both commands emit. An errored XR carries err; a changed XR its +// non-equal diffs. Other statuses get the zero summary and an empty change list. +func toXREntry(ref corev1.ObjectReference, status XRStatus, diffs map[string]*dt.ResourceDiff, err *dt.OutputError) xrEntryWire { + // Project to exactly apiVersion/kind/name/namespace. We use ObjectReference as the type (so + // consumers can unmarshal "xr" into the platform-standard struct), but deliberately omit its other + // fields: fieldPath is meaningless for an XR; uid is unstable across delete/recreate and would + // mislead consumers tracking an XR across runs (stable identity is kind/namespace/name); and + // resourceVersion, if ever wanted, is a property of the diff run, not per-XR identity. + entry := xrEntryWire{ + XR: corev1.ObjectReference{ + APIVersion: ref.APIVersion, + Kind: ref.Kind, + Name: ref.Name, + Namespace: ref.Namespace, + }, + Status: status, + Changes: []ChangeDetail{}, + } + + switch status { + case XRStatusChanged: + entry.Summary, entry.Changes = buildChangeSet(diffs) + case XRStatusError: + if err != nil { + entry.Errors = []dt.OutputError{*err} + } + case XRStatusUnchanged, XRStatusFiltered: + // No changes or errors to report. + } + + return entry +} + +// xrGroupStatus derives an xr input's status: error if it failed, changed if it has any non-equal +// diff, unchanged otherwise. +func xrGroupStatus(g dt.XRDiffGroup) XRStatus { + switch { + case g.Err != nil: + return XRStatusError + case slices.ContainsFunc(slices.Collect(maps.Values(g.Diffs)), func(d *dt.ResourceDiff) bool { return d.DiffType != dt.DiffTypeEqual }): + return XRStatusChanged + default: + return XRStatusUnchanged + } +} + +// buildXREntries converts the processor's per-input-XR groups into v2 xrs[] entries, in input order. +func buildXREntries(groups []dt.XRDiffGroup) []xrEntryWire { + out := make([]xrEntryWire, 0, len(groups)) + + for _, g := range groups { + ref := corev1.ObjectReference{APIVersion: g.XR.APIVersion, Kind: g.XR.Kind, Name: g.XR.Name, Namespace: g.XR.Namespace} + out = append(out, toXREntry(ref, xrGroupStatus(g), g.Diffs, g.Err)) + } + + return out +} + +// Summary contains aggregated counts of changes. +type Summary struct { + Added int `json:"added"` + Modified int `json:"modified"` + Removed int `json:"removed"` +} + +// increment bumps the counter for the given diff type. Equal (and any unknown) +// types are no-ops, since equal diffs are excluded from structured output; the +// explicit case keeps the exhaustive-switch linter satisfied. +func (s *Summary) increment(t dt.DiffType) { + switch t { + case dt.DiffTypeAdded: + s.Added++ + case dt.DiffTypeModified: + s.Modified++ + case dt.DiffTypeRemoved: + s.Removed++ + case dt.DiffTypeEqual: + // Equal diffs are not counted. + } +} + +// ChangeDetail represents a single resource change. +// +// This is the shared per-resource wire shape for both commands and both versions: it is reached +// via xrEntryWire.Changes, and in comp's v1 via DownstreamChanges.Changes. A field added here +// therefore surfaces in every output without per-command plumbing. +type ChangeDetail struct { + Type string `json:"type"` + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Diff map[string]any `json:"diff"` + + // DryRun is present only when this resource's desired state was not + // verified against the apiserver. See dt.DryRunInfo for why absence is the + // success case and what it does not promise for removals. + DryRun *dt.DryRunInfo `json:"dryRun,omitempty"` +} + +// CompDiffOutput is the top-level output for composition diffs (internal representation). +// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. +type CompDiffOutput struct { + Compositions []CompositionDiff + Errors []dt.OutputError // top-level errors (e.g., XRs that failed impact analysis) + // Warnings are non-fatal advisories raised during the run. Like the xr command's, they do not + // affect the exit code and are not attributed to a single composition — they are raised below the + // point where the owning composition is known. They have already been written to stderr when + // raised; this field carries them into structured output. The summary of unverified additions is + // not among them: the renderer derives it from the diffs (see compDryRunWarnings). + Warnings []dt.OutputWarning +} + +// CompositionDiff represents the diff result for a single composition (internal). +// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. +type CompositionDiff struct { + Name string + // Error is the per-composition processing error (nil if successful). Its ResourceID is + // "Composition/". + Error *dt.OutputError + CompositionDiff *dt.ResourceDiff // the actual composition diff (nil if unchanged) + AffectedResources AffectedResourcesSummary + ImpactAnalysis []XRImpact + // ImpactAnalysisSkipped records that the affected XRs were deliberately not evaluated, because + // the composition changed by less than --analyze-on asked to analyse. Distinguishes "we did not + // look" from "we looked and found no affected XRs", which would otherwise be indistinguishable. + // + // It does not mean ImpactAnalysis is empty. Composites excluded by update policy, revision + // selector or deletion are identified locally, with no render, so they are reported whether or not + // the analysis ran — in --resource mode as XRStatusFiltered entries here, and always via the + // AffectedResources filter counters (issue #478). What a skip suppresses is the renders, and + // therefore any changed/unchanged/errored verdict about the composites that were kept. + // + // Why the composites went unevaluated is not recoverable from this field alone — read + // RevisionImpact.ChangeScope alongside it. A "none" scope means no CompositionRevision is created + // and so nothing could have changed; anything else means a revision is created and simply was not + // evaluated. + ImpactAnalysisSkipped bool + // MaskedChangesOnly records that the composition differs from the cluster's only in fields + // excluded from the rendered diff — the user's --ignore-paths, or the renderer's display-only + // suppressions. CompositionDiff is nil in that case, but the composition did change, so the + // renderer must not report it as unchanged. + MaskedChangesOnly bool + // RevisionImpact is what applying this composition does to CompositionRevisions, independent of + // whether anything renders differently. Populated whenever the comparison completed, including + // when the composites were not evaluated; left zero for a composition that failed to process, + // where nothing about it was established (see RevisionImpact.determined). + RevisionImpact RevisionImpact +} + +// RevisionImpact describes what applying a composition does to CompositionRevisions and the +// composites tracking them, independent of whether any composed resource renders differently. +// +// This is deliberately a typed field rather than a warning. Revision churn is a per-composition +// fact a CI consumer may want to gate on, and warnings are documented as neither attributed to a +// composition nor intended for gating — so a flat warning could not say which of several diffed +// compositions creates a revision. +type RevisionImpact struct { + // ChangeScope is how much of the composition differs, in the terms Crossplane uses to decide + // whether a revision is needed: "none", "metadata" or "spec". Crossplane's Composition.Hash() + // covers labels and annotations as well as spec, so "metadata" still means a revision is created. + ChangeScope string `json:"changeScope"` + // CreatesRevision records whether applying this composition produces a new CompositionRevision. + CreatesRevision bool `json:"createsRevision"` + // RepointedComposites counts the composites that would adopt that revision and reconcile as a + // result: those with an Automatic (or defaulted) compositionUpdatePolicy whose + // compositionRevisionSelector, if any, selects the resulting revision, and which are not being + // deleted. A Manual-policy composite is never counted — it is pinned by compositionRevisionRef, so + // --include-manual keeping it for analysis does not make it re-point (issue #479). + // + // Note this counts composites that re-point, which is not the same as composites whose rendered + // output changes; re-pointing alone usually renders identically. + RepointedComposites int `json:"repointedComposites"` + // PredictedRevisionName is what that revision would be called: "-", derived + // from Crossplane's own Composition.Hash(). When CreatesRevision is false it names the existing + // revision the composites already track, which is the same name by construction — an unchanged + // composition hashes to the same value. + // + // It is what makes a downstream change interpretable: a composed resource whose template reads the + // revision name shows a diff with no other visible cause, and this is the cause. The composites are + // rendered with this value seeded onto their compositionRevisionRef, so such a change is detected + // rather than assumed away. + // + // Absent with CreatesRevision true means the name could not be predicted, which happens when the + // composition differs from the cluster's only by kubectl's last-applied-configuration annotation: + // its post-apply value is a function of how the user applies rather than of the file, so the hash — + // and therefore the name — is unknowable. A warning says so, and nothing is seeded in that case, so + // a revision-observing change would go undetected. Note also that for a composition applied + // client-side *with* other edits the suffix is predicted from the file as supplied and so may differ + // from the eventual one; the change is still detected, its predicted value is just imprecise. + PredictedRevisionName string `json:"predictedRevisionName,omitempty"` +} + +// determined reports whether the comparison that populates a RevisionImpact actually ran. ChangeScope +// is set unconditionally alongside the other fields and every real scope is non-empty, so an empty +// scope means nothing here was established. That is the case for a composition that failed to process: +// serializing the zero value would put changeScope outside its documented enum and would assert +// createsRevision: false, a positive claim about an apply the tool never evaluated (issue #479). +func (i RevisionImpact) determined() bool { + return i.ChangeScope != "" +} + +// HasChanges returns true if this composition diff has any changes, which is what drives +// ExitCodeDiffDetected. +// +// Deliberately excluded: RevisionImpact. A composition whose only difference is in masked fields +// creates a CompositionRevision but has nothing to show and nothing rendering differently, and a +// GitOps loop re-applying the same manifests would otherwise fail its diff gate on every run. A +// consumer that does want to gate on revision churn reads revisionImpact.createsRevision from the +// structured output. +func (c *CompositionDiff) HasChanges() bool { + if c.CompositionDiff != nil && c.CompositionDiff.DiffType != dt.DiffTypeEqual { + return true + } + + for _, impact := range c.ImpactAnalysis { + if impact.Status == XRStatusChanged { + return true + } + } + + return false +} + +// AffectedResourcesSummary contains counts of affected resources by status. +type AffectedResourcesSummary struct { + Total int `json:"total"` + WithChanges int `json:"withChanges"` + Unchanged int `json:"unchanged"` + WithErrors int `json:"withErrors"` + // FilteredByPolicy counts XRs excluded because of a Manual compositionUpdatePolicy + // (FilterReasonManualPolicy). + FilteredByPolicy int `json:"filteredByPolicy,omitempty"` + // FilteredBySelector counts XRs excluded because their compositionRevisionSelector does not match + // the diffed composition's labels (FilterReasonRevisionSelectorMismatch). Kept separate from + // FilteredByPolicy so the breakdown is visible even in default-discovery mode, where individual + // XR impacts are not surfaced. + FilteredBySelector int `json:"filteredBySelector,omitempty"` + // FilteredByDeletion counts XRs excluded because they are being deleted + // (FilterReasonDeleting). Kept separate from the other filter counters for the same reason: + // the breakdown stays visible in default-discovery mode. + FilteredByDeletion int `json:"filteredByDeletion,omitempty"` +} + +// XRImpact represents the impact analysis for a single XR (internal). +// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. +// Embeds corev1.ObjectReference for the common resource identity fields. +type XRImpact struct { + corev1.ObjectReference + + Status XRStatus + // FilterReason explains a Status == XRStatusFiltered outcome; empty otherwise. + FilterReason FilterReason + // FilterDetail is an optional human-readable explanation for a filtered outcome (e.g. which + // selector failed to match which labels), surfaced to help users self-diagnose the exclusion. + FilterDetail string + // Error is the structured processing error for an XRStatusError impact (nil otherwise), the same + // OutputError the command lists in its top-level errors. + Error *dt.OutputError + Diffs map[string]*dt.ResourceDiff // downstream diffs (nil if unchanged/error) +} + +// --- JSON Output Types (used by StructuredCompDiffRenderer) --- +// Note: Only JSON tags are used because sigs.k8s.io/yaml uses JSON tags for YAML serialization. + +// compDiffWire is the serialized (JSON/YAML) v1 (OutputVersionV1) shape for composition diffs; +// compDiffWireV2 is the v2 one. +type compDiffWire struct { + OutputVersion OutputVersion `json:"outputVersion"` + Compositions []compositionDiffWire `json:"compositions"` + Errors []dt.OutputError `json:"errors,omitempty"` + Warnings []dt.OutputWarning `json:"warnings,omitempty"` +} + +type compositionDiffWire struct { + Name string `json:"name"` + Error string `json:"error,omitempty"` + CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` + AffectedResources AffectedResourcesSummary `json:"affectedResources"` + ImpactAnalysis []xrImpactWire `json:"impactAnalysis"` + // ImpactAnalysisSkipped tells consumers that no changed/unchanged/errored verdict was reached for + // the composites that would adopt the resulting revision, rather than that none were found. It + // does not imply impactAnalysis is empty: composites excluded by update policy, revision selector + // or deletion are identified without rendering, so they are still reported here and in + // affectedResources (issue #478). Read revisionImpact.changeScope alongside it to tell "nothing + // could have changed" from "a revision is created but was not evaluated". + ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` + // MaskedChangesOnly tells consumers that an absent compositionChanges does not mean the + // composition is unchanged: it differs only in fields excluded from the diff. + MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` + // RevisionImpact is what applying this composition does to CompositionRevisions, independent of + // whether anything renders differently. Present whenever the comparison completed — including when + // impactAnalysis was skipped, which is the point of it — and absent for a composition that failed + // to process, where the `error` field says why nothing about the revision was determined. + RevisionImpact *RevisionImpact `json:"revisionImpact,omitempty"` +} + +// compDiffWireV2 is the v2 (OutputVersionV2) composition diff document. Its Errors also lists each +// failed composition's error, which v1 reports only on the composition. +type compDiffWireV2 struct { + OutputVersion OutputVersion `json:"outputVersion"` + Compositions []compositionDiffWireV2 `json:"compositions"` + Errors []dt.OutputError `json:"errors,omitempty"` + Warnings []dt.OutputWarning `json:"warnings,omitempty"` +} + +// compositionDiffWireV2 is compositionDiffWire with the per-composition error as structured errors[] +// and impactAnalysis[] entries of the shared xrEntryWire type. See compositionDiffWire for the +// meaning of the other fields. +type compositionDiffWireV2 struct { + Name string `json:"name"` + Errors []dt.OutputError `json:"errors,omitempty"` + CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` + AffectedResources AffectedResourcesSummary `json:"affectedResources"` + ImpactAnalysis []xrEntryWire `json:"impactAnalysis"` + ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` + MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` + RevisionImpact *RevisionImpact `json:"revisionImpact,omitempty"` +} + +// xrImpactWire is the v1 (OutputVersionV1) impactAnalysis[] entry; v2 uses xrEntryWire instead. +type xrImpactWire struct { + corev1.ObjectReference `json:",inline"` + + Status XRStatus `json:"status"` + FilterReason FilterReason `json:"filterReason,omitempty"` + FilterDetail string `json:"filterDetail,omitempty"` + Error string `json:"error,omitempty"` + DownstreamChanges *DownstreamChanges `json:"downstreamChanges,omitempty"` +} + +// DownstreamChanges contains the downstream resource changes for an XR. It is part of the v1 +// (OutputVersionV1) impactAnalysis[] entry only; v2's xrEntryWire carries summary and changes directly. +type DownstreamChanges struct { + Summary Summary `json:"summary"` + Changes []ChangeDetail `json:"changes"` +} + +// StructuredDiffRenderer renders diffs in structured formats (JSON/YAML). +type StructuredDiffRenderer struct { + logger logging.Logger + opts DiffOptions +} + +// NewStructuredDiffRenderer creates a new structured renderer with the specified format. +func NewStructuredDiffRenderer(logger logging.Logger, opts DiffOptions) DiffRenderer { + return &StructuredDiffRenderer{ + logger: logger, + opts: opts, + } +} + +// RenderDiffs renders the diffs in the configured structured format. +// +// It emits two views built from the same groups: the deprecated flat +// changes[]/summary (merged across all groups) for backward compatibility, and +// the per-input-XR xrs[] grouping. The top-level errors[] is the union passed +// by the caller. +func (r *StructuredDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.OutputError, warnings []dt.OutputWarning) error { + r.logger.Debug("Rendering diffs in structured format", + "format", r.opts.Format, + "groupCount", len(groups), + "errorCount", len(errs), + "warningCount", len(warnings)) + + // Flat, deprecated view: every input's changes, one copy of each distinct change (see + // flatChangeSet). The summary counts exactly what changes[] lists. + summary, changes := flatChangeSet(groups) + + // The summary of unverified additions is derived here, from the diffs, and follows the warnings + // raised during the run rather than being interleaved with them. See the DiffRenderer interface. + dryRun := xrDryRunWarnings(groups) + allWarnings := append(slices.Clone(warnings), dryRun...) + + // Grouped view: one entry per input XR, in input order, each complete for its input. Where inputs + // overlap, a shared resource appears under each, so the xrs[] summaries can sum to more than the + // top-level summary, which counts resources rather than inputs' views of them. + v2 := structuredDiffOutputV2{ + OutputVersion: OutputVersionV2, + Summary: summary, + Errors: errs, + Xrs: buildXREntries(groups), + Warnings: allWarnings, + } + + // Anything but v2, including the empty value, is v1. + var output any = v2 + if r.opts.OutputVersion != OutputVersionV2 { + output = v2.v1(changes) + } + + var ( + data []byte + err error + ) + + switch r.opts.Format { + case OutputFormatJSON: + data, err = json.MarshalIndent(output, "", " ") + case OutputFormatYAML: + data, err = sigsyaml.Marshal(output) + case OutputFormatDiff: + return errors.Errorf("unsupported output format for structured renderer: %s", r.opts.Format) + } + + if err != nil { + return errors.Wrap(err, "failed to marshal diff output") + } + + _, err = r.opts.Stdout.Write(data) + if err != nil { + return errors.Wrap(err, "failed to write structured output") + } + + // Add newline for cleaner terminal output + _, err = r.opts.Stdout.Write([]byte("\n")) + if err != nil { + return errors.Wrap(err, "failed to write newline") + } + + // Write errors to stderr for human visibility (they're also included in the structured output). + // Of the warnings, only the ones derived here are written: the rest already went to stderr when + // they were raised. + if err := writeWarnings(r.opts.Stderr, dryRun); err != nil { + return err + } + + for _, e := range errs { + if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { + return errors.Wrap(err, "failed to write error to stderr") + } + } + + return nil +} + +// buildChangeSet walks a diff map into the (summary, changes) pair shared by +// every structured view: the flat top-level changes[], each xrs[] entry, and +// each comp downstreamChanges block. Equal diffs are skipped (they contribute +// to neither the summary nor the change list). Changes are sorted by the +// canonical order (see diffSortFunc) for deterministic output. +func buildChangeSet(diffs map[string]*dt.ResourceDiff) (Summary, []ChangeDetail) { + sorted := slices.AppendSeq(make([]*dt.ResourceDiff, 0, len(diffs)), maps.Values(diffs)) + slices.SortFunc(sorted, diffSortFunc) + + summary := Summary{} + changes := make([]ChangeDetail, 0, len(sorted)) + + for _, diff := range sorted { + if diff.DiffType == dt.DiffTypeEqual { + continue + } + + summary.increment(diff.DiffType) + changes = append(changes, *resourceDiffToChangeDetail(diff)) + } + + return summary, changes +} + +// flatChangeSet builds the deprecated flat view: every group's non-equal changes, in canonical order +// (see diffSortFunc), keeping one copy of a change identical to one already listed for the same +// resource. It is lossless on its own terms: two inputs that render one resource differently are both +// listed, rather than one silently replacing the other in a map keyed by resource (issue #476). That +// keeps the flat view correct whatever the input validator allows, including a validator that treats +// inputs individually, where they may legitimately disagree about a resource. Identical renderings, the +// same change reached twice, still count once. +func flatChangeSet(groups []dt.XRDiffGroup) (Summary, []ChangeDetail) { + var all []*dt.ResourceDiff + + for _, g := range groups { + for _, d := range g.Diffs { + if d.DiffType != dt.DiffTypeEqual { + all = append(all, d) + } + } + } + + // Stable, so that differing changes to one resource stay in input order. + slices.SortStableFunc(all, diffSortFunc) + + summary := Summary{} + changes := make([]ChangeDetail, 0, len(all)) + listed := make(map[string][]ChangeDetail, len(all)) + + for _, d := range all { + change := *resourceDiffToChangeDetail(d) + key := d.GetDiffKey() + + if slices.ContainsFunc(listed[key], func(c ChangeDetail) bool { return reflect.DeepEqual(c, change) }) { + continue + } + + listed[key] = append(listed[key], change) + summary.increment(d.DiffType) + changes = append(changes, change) + } + + return summary, changes +} + +// diffSortFunc is the canonical ordering for structured change lists: primarily +// by the human-meaningful Kind/Name (matching the human renderer's getKindName +// sort), with the full apiVersion/kind/namespace/name key as a stable +// tiebreaker so same-kind/same-name resources in different namespaces still +// sort deterministically. +func diffSortFunc(a, b *dt.ResourceDiff) int { + return cmp.Or( + cmp.Compare(a.Gvk.Kind, b.Gvk.Kind), + cmp.Compare(a.ResourceName, b.ResourceName), + cmp.Compare(a.GetDiffKey(), b.GetDiffKey()), + ) +} + +// resourceDiffToChangeDetail converts a ResourceDiff to a ChangeDetail for +// structured (JSON/YAML) output. +// +// It reads the pre-cleaned views populated during diff generation, so +// --ignore-paths and the unconditional-cleanup fields are already stripped; +// the renderer performs no cleanup of its own. +func resourceDiffToChangeDetail(diff *dt.ResourceDiff) *ChangeDetail { + change := &ChangeDetail{ + Type: diff.DiffType.ToWord(), + APIVersion: diff.Gvk.GroupVersion().String(), + Kind: diff.Gvk.Kind, + Name: diff.ResourceName, + Namespace: diff.Namespace, + Diff: make(map[string]any), + // Nil for every resource whose desired state reached the apiserver, which + // omitempty turns into an absent key — see dt.DryRunInfo. + DryRun: diff.DryRun, + } + + switch diff.DiffType { + case dt.DiffTypeAdded: + if diff.Desired.Clean != nil { + change.Diff[dt.DiffKeySpec] = diff.Desired.Clean.Object + } + case dt.DiffTypeRemoved: + if diff.Current.Clean != nil { + change.Diff[dt.DiffKeySpec] = diff.Current.Clean.Object + } + case dt.DiffTypeModified: + if diff.Current.Clean != nil && diff.Desired.Clean != nil { + change.Diff[dt.DiffKeyOld] = diff.Current.Clean.Object + change.Diff[dt.DiffKeyNew] = diff.Desired.Clean.Object + } + case dt.DiffTypeEqual: + // Equal diffs have no detail to show + } + + return change +} diff --git a/internal/diff/renderer/structured_renderer_test.go b/internal/diff/renderer/structured_renderer_test.go new file mode 100644 index 00000000..2bba915c --- /dev/null +++ b/internal/diff/renderer/structured_renderer_test.go @@ -0,0 +1,849 @@ +package renderer + +import ( + "bytes" + "context" + "encoding/json" + "maps" + "reflect" + "slices" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + sigsyaml "sigs.k8s.io/yaml" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +// testDiffFixture defines a reusable test case with input diffs and expected output. +type testDiffFixture struct { + name string + diffs map[string]*dt.ResourceDiff + errs []dt.OutputError + expectedSummary Summary + expectedChanges int + // Optional: additional validation beyond summary/changes count + validate func(t *testing.T, output *StructuredDiffOutput) +} + +// sharedDiffFixtures returns test fixtures that should be run through both JSON and YAML renderers. +func sharedDiffFixtures() []testDiffFixture { + // Create reusable test resources + addedResource := tu.NewResource("nop.crossplane.io/v1alpha1", "NopResource", "new-resource"). + InNamespace("default"). + WithSpec(map[string]any{ + "forProvider": map[string]any{ + "field": "value", + }, + }). + Build() + + modifiedCurrentResource := tu.NewResource("example.org/v1alpha1", "XExample", "modified-resource"). + InNamespace("production"). + WithSpec(map[string]any{ + "oldValue": "something", + }). + Build() + + modifiedDesiredResource := tu.NewResource("example.org/v1alpha1", "XExample", "modified-resource"). + InNamespace("production"). + WithSpec(map[string]any{ + "newValue": "something-else", + }). + Build() + + removedResource := tu.NewResource("example.org/v1alpha1", "XNopResource", "removed-resource"). + WithSpec(map[string]any{ + "coolField": "goodbye", + }). + Build() + + return []testDiffFixture{ + { + name: "AllDiffTypes", + diffs: map[string]*dt.ResourceDiff{ + "added": { + DiffType: dt.DiffTypeAdded, + ResourceName: "new-resource", + Namespace: "default", + Gvk: schema.GroupVersionKind{ + Group: "nop.crossplane.io", + Version: "v1alpha1", + Kind: "NopResource", + }, + Desired: dt.ResourceViews{Raw: addedResource, Clean: addedResource}, + }, + "modified": { + DiffType: dt.DiffTypeModified, + ResourceName: "modified-resource", + Namespace: "production", + Gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "XExample", + }, + Current: dt.ResourceViews{Raw: modifiedCurrentResource, Clean: modifiedCurrentResource}, + Desired: dt.ResourceViews{Raw: modifiedDesiredResource, Clean: modifiedDesiredResource}, + }, + "removed": { + DiffType: dt.DiffTypeRemoved, + ResourceName: "removed-resource", + Gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "XNopResource", + }, + Current: dt.ResourceViews{Raw: removedResource, Clean: removedResource}, + }, + "equal": { + DiffType: dt.DiffTypeEqual, + ResourceName: "unchanged-resource", + Gvk: schema.GroupVersionKind{ + Kind: "NopResource", + }, + }, + }, + expectedSummary: Summary{Added: 1, Modified: 1, Removed: 1}, + expectedChanges: 3, // equal should be excluded + validate: func(t *testing.T, output *StructuredDiffOutput) { + t.Helper() + + // Find and verify added resource + var addedChange *ChangeDetail + + for i := range output.Changes { + if output.Changes[i].Type == dt.DiffTypeWordAdded { + addedChange = &output.Changes[i] + break + } + } + + if addedChange == nil { + t.Fatal("Added resource not found in changes") + } + + if addedChange.Kind != "NopResource" { + t.Errorf("Expected Kind 'NopResource', got '%s'", addedChange.Kind) + } + + if addedChange.Name != "new-resource" { + t.Errorf("Expected Name 'new-resource', got '%s'", addedChange.Name) + } + + if addedChange.Namespace != "default" { + t.Errorf("Expected Namespace 'default', got '%s'", addedChange.Namespace) + } + }, + }, + { + name: "EmptyDiffs", + diffs: map[string]*dt.ResourceDiff{}, + expectedSummary: Summary{Added: 0, Modified: 0, Removed: 0}, + expectedChanges: 0, + }, + { + name: "OnlyEqualDiffs", + diffs: map[string]*dt.ResourceDiff{ + "equal1": { + DiffType: dt.DiffTypeEqual, + ResourceName: "resource-1", + Gvk: schema.GroupVersionKind{Kind: "NopResource"}, + }, + "equal2": { + DiffType: dt.DiffTypeEqual, + ResourceName: "resource-2", + Gvk: schema.GroupVersionKind{Kind: "NopResource"}, + }, + }, + expectedSummary: Summary{Added: 0, Modified: 0, Removed: 0}, + expectedChanges: 0, // equal diffs should be excluded + }, + { + name: "WithErrors", + diffs: map[string]*dt.ResourceDiff{}, + errs: []dt.OutputError{ + {ResourceID: "example.org/v1/XResource/my-xr", Message: "failed to render XR: missing composition"}, + {Message: "cluster connection timeout"}, + }, + expectedSummary: Summary{Added: 0, Modified: 0, Removed: 0}, + expectedChanges: 0, + validate: func(t *testing.T, output *StructuredDiffOutput) { + t.Helper() + + if len(output.Errors) != 2 { + t.Fatalf("Expected 2 errors, got %d", len(output.Errors)) + } + + if output.Errors[0].ResourceID != "example.org/v1/XResource/my-xr" { + t.Errorf("Expected first error ResourceID 'example.org/v1/XResource/my-xr', got '%s'", output.Errors[0].ResourceID) + } + + if output.Errors[1].Message != "cluster connection timeout" { + t.Errorf("Expected second error message 'cluster connection timeout', got '%s'", output.Errors[1].Message) + } + }, + }, + } +} + +func TestStructuredDiffRenderer_RenderDiffs(t *testing.T) { + formats := []OutputFormat{OutputFormatJSON, OutputFormatYAML} + fixtures := sharedDiffFixtures() + + for _, format := range formats { + for _, fixture := range fixtures { + testName := string(format) + "/" + fixture.name + t.Run(testName, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var buf bytes.Buffer + + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} // discard stderr for these tests + + renderer := NewStructuredDiffRenderer(logger, opts) + + err := renderer.RenderDiffs(identitylessGroups(fixture.diffs), fixture.errs, nil) + if err != nil { + t.Fatalf("RenderDiffs() failed: %v", err) + } + + // Parse the output based on format + var output StructuredDiffOutput + + switch format { + case OutputFormatJSON: + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, buf.String()) + } + case OutputFormatYAML: + if err := sigsyaml.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("Failed to parse YAML output: %v\nOutput: %s", err, buf.String()) + } + case OutputFormatDiff: + t.Fatal("OutputFormatDiff should not be used with StructuredDiffRenderer") + } + + // Verify summary + if output.Summary.Added != fixture.expectedSummary.Added { + t.Errorf("Summary.Added = %d, want %d", output.Summary.Added, fixture.expectedSummary.Added) + } + + if output.Summary.Modified != fixture.expectedSummary.Modified { + t.Errorf("Summary.Modified = %d, want %d", output.Summary.Modified, fixture.expectedSummary.Modified) + } + + if output.Summary.Removed != fixture.expectedSummary.Removed { + t.Errorf("Summary.Removed = %d, want %d", output.Summary.Removed, fixture.expectedSummary.Removed) + } + + // Verify change count + if len(output.Changes) != fixture.expectedChanges { + t.Errorf("len(Changes) = %d, want %d", len(output.Changes), fixture.expectedChanges) + } + + // Verify errors round-trip if present + if len(fixture.errs) > 0 { + if diff := cmp.Diff(fixture.errs, output.Errors); diff != "" { + t.Errorf("Errors mismatch (-want +got):\n%s", diff) + } + } + + // Run additional validation if provided + if fixture.validate != nil { + fixture.validate(t, &output) + } + }) + } + } +} + +// TestStructuredDiffRenderer_GroupsByXR verifies the per-input-XR xrs[] view is +// built alongside the (deprecated) flat changes[]/summary view. +func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { + changed := &dt.ResourceDiff{ + DiffType: dt.DiffTypeModified, + ResourceName: "bucket-a", + Namespace: "default", + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + Current: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "bucket-a"). + WithSpec(map[string]any{"region": "us-east-1"}).Build()}, + Desired: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "bucket-a"). + WithSpec(map[string]any{"region": "us-west-2"}).Build()}, + } + + // Group whose only diff is equal -> unchanged, empty changes. + equalOnly := &dt.ResourceDiff{ + DiffType: dt.DiffTypeEqual, + ResourceName: "bucket-b", + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + } + + // Two XRs that both render Bucket/shared: same diff key, different desired + // state. See TestStructuredDiffRenderer_GroupsByXR/SameKeyFromTwoXRs. + sharedDiff := func(newRegion string) *dt.ResourceDiff { + return &dt.ResourceDiff{ + DiffType: dt.DiffTypeModified, + ResourceName: "shared", + Namespace: "default", + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + Current: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "shared"). + WithSpec(map[string]any{"region": "us-east-1"}).Build()}, + Desired: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "shared"). + WithSpec(map[string]any{"region": newRegion}).Build()}, + } + } + + xrRef := func(name string) corev1.ObjectReference { + return corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: name, Namespace: "default"} + } + bucketChange := ChangeDetail{ + Type: dt.DiffTypeWordModified, + APIVersion: "example.org/v1", + Kind: "Bucket", + Name: "bucket-a", + Namespace: "default", + Diff: map[string]any{ + dt.DiffKeyOld: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "bucket-a"}, "spec": map[string]any{"region": "us-east-1"}}, + dt.DiffKeyNew: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "bucket-a"}, "spec": map[string]any{"region": "us-west-2"}}, + }, + } + sharedChange := func(newRegion string) ChangeDetail { + return ChangeDetail{ + Type: dt.DiffTypeWordModified, + APIVersion: "example.org/v1", + Kind: "Bucket", + Name: "shared", + Namespace: "default", + Diff: map[string]any{ + dt.DiffKeyOld: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "shared"}, "spec": map[string]any{"region": "us-east-1"}}, + dt.DiffKeyNew: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "shared"}, "spec": map[string]any{"region": newRegion}}, + }, + } + } + + unionErrs := []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}} + + cases := map[string]struct { + groups []dt.XRDiffGroup + errs []dt.OutputError + version OutputVersion + // The whole output asserted as one value, as the document type of the selected version + // (StructuredDiffOutput for v1, structuredDiffOutputV2 for v2). For v1: the flat + // back-compat view (aggregate summary + merged changes + union errors) plus the + // per-input-XR xrs[] view, in input order. + want any + // wantKeys is the document's top-level JSON keys, which pins that v1 differs from before + // only by outputVersion and that v2 drops the flat changes[]. + wantKeys []string + }{ + "ChangedUnchangedAndErrored": { + groups: []dt.XRDiffGroup{ + { + XR: xrRef("changed-xr"), + Diffs: map[string]*dt.ResourceDiff{"changed": changed}, + }, + { + XR: xrRef("unchanged-xr"), + Diffs: map[string]*dt.ResourceDiff{"equal": equalOnly}, + }, + { + XR: xrRef("broken-xr"), + Err: &dt.OutputError{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}, + }, + }, + errs: unionErrs, + want: StructuredDiffOutput{ + OutputVersion: OutputVersionV1, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{bucketChange}, + Errors: unionErrs, + Xrs: []xrEntryWire{ + { + XR: xrRef("changed-xr"), + Status: XRStatusChanged, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{bucketChange}, + }, + { + XR: xrRef("unchanged-xr"), + Status: XRStatusUnchanged, + Changes: []ChangeDetail{}, + }, + { + XR: xrRef("broken-xr"), + Status: XRStatusError, + Changes: []ChangeDetail{}, + Errors: []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}}, + }, + }, + }, + wantKeys: []string{"changes", "errors", "outputVersion", "summary", "xrs"}, + }, + // An explicit v1 is the same document as the default. + "ExplicitV1": { + groups: []dt.XRDiffGroup{ + {XR: xrRef("unchanged-xr"), Diffs: map[string]*dt.ResourceDiff{"equal": equalOnly}}, + }, + version: OutputVersionV1, + want: StructuredDiffOutput{ + OutputVersion: OutputVersionV1, + Changes: []ChangeDetail{}, + Xrs: []xrEntryWire{ + {XR: xrRef("unchanged-xr"), Status: XRStatusUnchanged, Changes: []ChangeDetail{}}, + }, + }, + wantKeys: []string{"changes", "outputVersion", "summary", "xrs"}, + }, + // v2 drops the flat changes[] and emits the shared per-XR entry for each status xr produces. + "V2ChangedUnchangedAndErrored": { + groups: []dt.XRDiffGroup{ + { + XR: xrRef("changed-xr"), + Diffs: map[string]*dt.ResourceDiff{"changed": changed}, + }, + { + XR: xrRef("unchanged-xr"), + Diffs: map[string]*dt.ResourceDiff{"equal": equalOnly}, + }, + { + XR: xrRef("broken-xr"), + Err: &dt.OutputError{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}, + }, + }, + errs: unionErrs, + version: OutputVersionV2, + want: structuredDiffOutputV2{ + OutputVersion: OutputVersionV2, + Summary: Summary{Modified: 1}, + Errors: unionErrs, + Xrs: []xrEntryWire{ + { + XR: xrRef("changed-xr"), + Status: XRStatusChanged, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{bucketChange}, + }, + { + XR: xrRef("unchanged-xr"), + Status: XRStatusUnchanged, + Changes: []ChangeDetail{}, + }, + { + XR: xrRef("broken-xr"), + Status: XRStatusError, + Changes: []ChangeDetail{}, + Errors: []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}}, + }, + }, + }, + wantKeys: []string{"errors", "outputVersion", "summary", "xrs"}, + }, + // One resource reached through two inputs' renders with the same rendering, through one + // controller. It is one change, so the flat view (summary and changes[]) counts and lists it once, + // while each xrs[] entry still reports everything its input produces. So the xrs[] summaries can + // sum to more than the top-level summary when inputs overlap; that is the point of the split — + // the flat view counts resources, the grouped view is complete per input. + "SameResourceReachedThroughTwoInputs": { + groups: []dt.XRDiffGroup{ + { + XR: xrRef("app-claim"), + Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("us-west-2")}, + }, + { + XR: xrRef("app-xr"), + Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("us-west-2")}, + }, + }, + want: StructuredDiffOutput{ + OutputVersion: OutputVersionV1, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{sharedChange("us-west-2")}, + Xrs: []xrEntryWire{ + { + XR: xrRef("app-claim"), + Status: XRStatusChanged, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{sharedChange("us-west-2")}, + }, + { + XR: xrRef("app-xr"), + Status: XRStatusChanged, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{sharedChange("us-west-2")}, + }, + }, + }, + }, + // Issue #476: two input XRs rendering one resource differently. The flat view lists both, + // in input order: it is lossless on its own terms, not only because today's validator rejects + // such input. That keeps the renderer correct under any InputValidator, including one that + // treats inputs individually, where two inputs may legitimately disagree about a resource. + "SameKeyFromTwoXRs": { + groups: []dt.XRDiffGroup{ + { + XR: xrRef("app-a"), + Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("us-west-2")}, + }, + { + XR: xrRef("app-b"), + Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("eu-west-1")}, + }, + }, + want: StructuredDiffOutput{ + OutputVersion: OutputVersionV1, + Summary: Summary{Modified: 2}, + Changes: []ChangeDetail{sharedChange("us-west-2"), sharedChange("eu-west-1")}, + Xrs: []xrEntryWire{ + { + XR: xrRef("app-a"), + Status: XRStatusChanged, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{sharedChange("us-west-2")}, + }, + { + XR: xrRef("app-b"), + Status: XRStatusChanged, + Summary: Summary{Modified: 1}, + Changes: []ChangeDetail{sharedChange("eu-west-1")}, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var buf bytes.Buffer + + opts := DefaultDiffOptions() + opts.Format = OutputFormatJSON + opts.Stdout = &buf + opts.Stderr = &bytes.Buffer{} + opts.OutputVersion = tc.version + + r := NewStructuredDiffRenderer(tu.TestLogger(t, false), opts) + if err := r.RenderDiffs(tc.groups, tc.errs, nil); err != nil { + t.Fatalf("RenderDiffs() failed: %v", err) + } + + output := reflect.New(reflect.TypeOf(tc.want)) + if err := json.Unmarshal(buf.Bytes(), output.Interface()); err != nil { + t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, buf.String()) + } + + if diff := cmp.Diff(tc.want, output.Elem().Interface()); diff != "" { + t.Errorf("structured output mismatch (-want +got):\n%s", diff) + } + + if tc.wantKeys != nil { + var doc map[string]json.RawMessage + if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { + t.Fatalf("Failed to parse JSON: %v", err) + } + + if diff := cmp.Diff(tc.wantKeys, slices.Sorted(maps.Keys(doc))); diff != "" { + t.Errorf("top-level keys mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +// TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr verifies that errors are +// written to stderr for human visibility in addition to being included in the +// structured output for machine parsing. +func TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr(t *testing.T) { + errs := []dt.OutputError{ + {ResourceID: "example.org/v1/XResource/my-xr", Message: "failed to render XR: missing composition"}, + {Message: "cluster connection timeout"}, + } + + for _, format := range []OutputFormat{OutputFormatJSON, OutputFormatYAML} { + t.Run(string(format), func(t *testing.T) { + logger := tu.TestLogger(t, false) + + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + opts := DefaultDiffOptions() + opts.Format = format + opts.Stdout = &stdout + opts.Stderr = &stderr + + renderer := NewStructuredDiffRenderer(logger, opts) + + err := renderer.RenderDiffs(identitylessGroups(map[string]*dt.ResourceDiff{}), errs, nil) + if err != nil { + t.Fatalf("RenderDiffs() failed: %v", err) + } + + // Verify errors are in stderr + stderrOut := stderr.String() + for _, e := range errs { + if !strings.Contains(stderrOut, e.FormatError()) { + t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + } + } + + // Verify errors are ALSO in structured output (stdout) + var output StructuredDiffOutput + + switch format { + case OutputFormatJSON: + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, stdout.String()) + } + case OutputFormatYAML: + if err := sigsyaml.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("Failed to parse YAML output: %v\nOutput: %s", err, stdout.String()) + } + case OutputFormatDiff: + t.Fatal("OutputFormatDiff should not be used with StructuredDiffRenderer") + } + + if diff := cmp.Diff(errs, output.Errors); diff != "" { + t.Errorf("Structured output errors mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestStructuredDiffRenderer_RespectsIgnorePaths verifies that --ignore-paths +// and the unconditional cleanup performed for the human diff are also honored +// when rendering structured (JSON/YAML) output. +// +// Each case runs GenerateDiffWithOptions to classify the diff (matching real +// CLI flow) and then renders through the structured renderer, then asserts on +// the parsed structured output. +func TestStructuredDiffRenderer_RespectsIgnorePaths(t *testing.T) { + const ( + ignoredAnnotation = "argocd.argoproj.io/tracking-id" + ignoredLabel = "argocd.argoproj.io/instance" + ) + + ignorePaths := []string{ + "metadata.annotations[" + ignoredAnnotation + "]", + "metadata.labels[" + ignoredLabel + "]", + } + + // xExample returns a builder for a namespaced XExample resource named "r1". + // Callers chain expressive builder methods (WithSpecField, WithAnnotations, + // WithServerMetadata, WithStatus, WithOwnerReference, …) to add exactly the + // fields the case needs, then call Build(). + xExample := func() *tu.ResourceBuilder { + return tu.NewResource("example.org/v1alpha1", "XExample", "r1"). + InNamespace("default") + } + + // cleanSpec is the object body we expect the renderer to emit for a resource + // whose only surviving content is its identity plus spec.configData — i.e. + // after cleanup has stripped ignored annotations/labels and all server-side + // fields. Built with the same builder used for inputs, minus the noise, so + // the expectation reads as "this is the shape that should come out". + cleanSpec := func(configData string) map[string]any { + return xExample().WithSpecField("configData", configData).Build().Object + } + + cases := []struct { + name string + current *un.Unstructured // pass nil for Added + desired *un.Unstructured // pass nil for Removed + ignorePaths []string + wantSummary Summary + wantChanges int + // wantDiffDetail is the expected Changes[0].Diff payload, as decoded from + // the rendered JSON/YAML (so it holds nested map[string]any structures). + // Every leaf value in these fixtures is a string, so the comparison isn't + // affected by JSON's number decoding (all numbers would decode to + // float64). Left nil when wantChanges is 0. + wantDiffDetail map[string]any + }{ + { + // Only user-supplied ignored path differs -> classified Equal. + name: "OnlyIgnoredAnnotation_UnchangedInSummary", + current: xExample().WithSpecField("configData", "same"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}).Build(), + desired: xExample().WithSpecField("configData", "same"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}).Build(), + ignorePaths: ignorePaths, + wantSummary: Summary{}, + wantChanges: 0, + }, + { + // Only ownerReferences differ -> classified Equal + // via unconditional cleanup, without any user --ignore-paths. + name: "OnlyOwnerReferences_UnchangedInSummary", + current: xExample().WithSpecField("configData", "same"). + WithOwnerReference("Owner", "old", "v1", "u1").Build(), + desired: xExample().WithSpecField("configData", "same"). + WithOwnerReference("Owner", "new", "v1", "u2").Build(), + wantSummary: Summary{}, + wantChanges: 0, + }, + { + // Mixed ignored + non-ignored change. Modified count + // increments once; diff.old/new carry only the cleaned bodies — the + // ignored annotation and label are absent, the spec change survives. + name: "IgnoredPlusNonIgnored_CountOneAndIgnoredStripped", + current: xExample().WithSpecField("configData", "old"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). + WithLabels(map[string]string{ignoredLabel: "app-old"}).Build(), + desired: xExample().WithSpecField("configData", "new"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). + WithLabels(map[string]string{ignoredLabel: "app-new"}).Build(), + ignorePaths: ignorePaths, + wantSummary: Summary{Modified: 1}, + wantChanges: 1, + wantDiffDetail: map[string]any{ + dt.DiffKeyOld: cleanSpec("old"), + dt.DiffKeyNew: cleanSpec("new"), + }, + }, + { + // Unconditional-cleanup fields (server metadata, managedFields, + // ownerReferences, status) must not leak into the JSON diff even + // without user-supplied --ignore-paths. + name: "ServerSideFieldsStripped", + current: xExample().WithSpecField("configData", "old"). + WithServerMetadata(). + WithFieldManagers("kubectl"). + WithOwnerReference("Owner", "o", "v1", "u"). + WithStatus(map[string]any{"phase": "Ready"}).Build(), + desired: xExample().WithSpecField("configData", "new"). + WithServerMetadata(). + WithFieldManagers("kubectl"). + WithOwnerReference("Owner", "o", "v1", "u"). + WithStatus(map[string]any{"phase": "Ready"}).Build(), + wantSummary: Summary{Modified: 1}, + wantChanges: 1, + wantDiffDetail: map[string]any{ + dt.DiffKeyOld: cleanSpec("old"), + dt.DiffKeyNew: cleanSpec("new"), + }, + }, + { + // Added resource — diff.spec carries only the cleaned body, + // no ignored annotation and no server-side fields. + name: "AddedResource_IgnoresPathsInSpec", + current: nil, + desired: xExample().WithSpecField("configData", "new"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). + WithServerMetadata(). + WithFieldManagers("kubectl").Build(), + ignorePaths: ignorePaths, + wantSummary: Summary{Added: 1}, + wantChanges: 1, + wantDiffDetail: map[string]any{ + dt.DiffKeySpec: cleanSpec("new"), + }, + }, + { + // Removed resource — diff.spec carries only the cleaned body, + // no ignored annotation and no ownerReferences. + name: "RemovedResource_IgnoresPathsInSpec", + current: xExample().WithSpecField("configData", "old"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). + WithServerMetadata(). + WithOwnerReference("Owner", "o", "v1", "u").Build(), + desired: nil, + ignorePaths: ignorePaths, + wantSummary: Summary{Removed: 1}, + wantChanges: 1, + wantDiffDetail: map[string]any{ + dt.DiffKeySpec: cleanSpec("old"), + }, + }, + } + + // Run every case through both structured formats, JSON and YAML: each must honour the same + // cleanup, so a case that passed for one alone would prove nothing about the other. + formats := []OutputFormat{OutputFormatJSON, OutputFormatYAML} + + for _, format := range formats { + for _, tc := range cases { + t.Run(string(format)+"/"+tc.name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + // Run the same classify-then-render pipeline the CLI uses, so + // we exercise classification + rendering together rather than + // only the renderer. Cleanup (ignore-paths + server-side fields) + // happens here, during generation, and is stored on the diff's + // clean views; the renderer just emits them. + // + // Note: this uses tc.ignorePaths verbatim and does NOT prepend + // the CLI's built-in default ignore path + // (metadata.annotations[kubectl.kubernetes.io/last-applied-configuration], + // added in defaultProcessorOptions, cmd_utils.go). That default + // wiring is covered end-to-end by the IgnorePathsArgoCD + // integration test in diff_integration_test.go. + diffOpts := DefaultDiffOptions() + diffOpts.IgnorePaths = tc.ignorePaths + + rd, err := GenerateDiffWithOptions(context.Background(), tc.current, tc.desired, logger, diffOpts) + if err != nil { + t.Fatalf("GenerateDiffWithOptions: %v", err) + } + + var buf bytes.Buffer + + // The renderer no longer performs cleanup, so it needs no + // IgnorePaths — the diff already carries cleaned views. + renderOpts := DefaultDiffOptions() + renderOpts.Format = format + renderOpts.Stdout = &buf + renderOpts.Stderr = &bytes.Buffer{} + + r := NewStructuredDiffRenderer(logger, renderOpts) + if err := r.RenderDiffs(identitylessGroups(map[string]*dt.ResourceDiff{"r1": rd}), nil, nil); err != nil { + t.Fatalf("RenderDiffs() failed: %v", err) + } + + var output StructuredDiffOutput + + switch format { + case OutputFormatJSON: + if err := json.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("json.Unmarshal: %v\noutput: %s", err, buf.String()) + } + case OutputFormatYAML: + if err := sigsyaml.Unmarshal(buf.Bytes(), &output); err != nil { + t.Fatalf("yaml.Unmarshal: %v\noutput: %s", err, buf.String()) + } + case OutputFormatDiff: + t.Fatal("diff format not supported by structured renderer") + } + + if diff := cmp.Diff(tc.wantSummary, output.Summary); diff != "" { + t.Errorf("summary mismatch (-want +got):\n%s", diff) + } + + if len(output.Changes) != tc.wantChanges { + t.Errorf("len(Changes) = %d, want %d\noutput: %s", len(output.Changes), tc.wantChanges, buf.String()) + } + + // When a change is expected, the emitted diff detail must match + // the cleaned bodies exactly — this catches both leaked ignored/ + // server-side fields and any missing non-ignored content. + if tc.wantDiffDetail != nil { + if len(output.Changes) == 0 { + t.Fatalf("expected one change with diff detail, got none\noutput: %s", buf.String()) + } + + if diff := cmp.Diff(tc.wantDiffDetail, output.Changes[0].Diff); diff != "" { + t.Errorf("diff detail mismatch (-want +got):\n%s", diff) + } + } + }) + } + } +} diff --git a/internal/diff/renderer/types/types.go b/internal/diff/renderer/types/types.go new file mode 100644 index 00000000..2b5819a0 --- /dev/null +++ b/internal/diff/renderer/types/types.go @@ -0,0 +1,460 @@ +// Package types provides types used in the renderer in order to facilitate code reuse in test +package types + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// XRDiffGroup pairs one input XR/claim with the diffs its render produced (or +// the error that prevented them). It is built by the diff processor's +// per-input loop and passed to the renderer so structured output can group by +// input XR (see the xrs[] output field) and the human renderer can render +// per-XR sections. +// +// XR carries the input XR/claim identity (apiVersion, kind, name, namespace). +// It is left zero-valued for the composition renderer's internal reuse of the +// human diff renderer, which has no single owning XR; the human renderer treats +// an identity-less group as a flat, header-less block (preserving comp output). +// +// Err is a pre-converted *OutputError (not a raw error) because the conversion +// (NewOutputError, which extracts typed validation failures) lives in the +// diffprocessor package; having the renderer convert would require a +// renderer -> diffprocessor import cycle. It is nil unless this XR failed. +// +// This type lives in the leaf types package (rather than the renderer package +// alongside the DiffRenderer interface) so that testutils — which provides the +// DiffRenderer mock — can reference it without importing renderer, which would +// close an import cycle through renderer's in-package tests. +type XRDiffGroup struct { + XR corev1.ObjectReference + // NameGenerated is true when the input XR had only a generateName, so XR.Name — and the name + // its render used — is a placeholder this tool synthesized, not one the API server would assign. + // Two such XRs sharing a generateName are distinct in the cluster but indistinguishable here; see + // the diff processor's render-overlap check. + NameGenerated bool + Diffs map[string]*ResourceDiff + Err *OutputError +} + +// ResourceViews holds the two representations of a single resource involved in +// a diff: the Raw object (as rendered, or as fetched from the cluster) and the +// Clean object (Raw after cleanupForDiff has stripped ignored paths and +// server-side/non-diff-relevant fields). +// +// Raw is load-bearing beyond rendering (removal detection and XR +// reconstruction in the diffprocessor). Clean is what structured output emits, +// and is populated by diff generation only when there is something to render +// (i.e. not for equal diffs). Either field may be nil: for an added resource +// the current side is zero-valued, for a removed resource the desired side is. +type ResourceViews struct { + Raw *un.Unstructured + Clean *un.Unstructured +} + +// ResourceDiff represents the diff for a specific resource. +type ResourceDiff struct { + Gvk schema.GroupVersionKind + Namespace string + ResourceName string + DiffType DiffType + LineDiffs []diffmatchpatch.Diff + Current ResourceViews // the resource's current (cluster) state, raw + clean + Desired ResourceViews // the resource's desired (rendered) state, raw + clean + + // DryRun is set ONLY when this resource's desired state did not go through + // the apiserver even though it could have — see DryRunInfo. Nil is the + // success case and the common case. + DryRun *DryRunInfo +} + +// DryRunSkipReason explains why a resource's desired state was not round-tripped +// through the apiserver. +type DryRunSkipReason string + +const ( + // DryRunSkipDisabled means the user selected --dry-run-on=existing, so + // additions are deliberately not sent to the apiserver. + DryRunSkipDisabled DryRunSkipReason = "disabled" + + // DryRunSkipForbidden means a SelfSubjectAccessReview reported that we may + // not create this kind here, so the dry-run create was never attempted. + // Nothing was learned about the resource itself; this is a limitation of the + // credentials in use, not a finding about the resource. + DryRunSkipForbidden DryRunSkipReason = "forbidden" + + // DryRunSkipWebhookUnavailable means the apiserver could not complete the + // admission chain — typically an unreachable admission webhook with + // failurePolicy: Fail, though the predicate is broader than that (any + // InternalError / ServiceUnavailable / Timeout from the dry-run create). + // The constant is named for the dominant cause; DryRunInfo.Detail always + // carries the apiserver's own message, so the specific cause is not lost. + DryRunSkipWebhookUnavailable DryRunSkipReason = "webhookUnavailable" + + // DryRunSkipNamespaceNotFound means the resource's target namespace does not + // exist yet, so the apiserver refused to admit it. + // + // This is deliberately a skip rather than a reported rejection. A namespace + // and the resources inside it are commonly applied together, so its absence + // at diff time says nothing about whether the apply will succeed — unlike a + // quota or webhook refusal, which describes the object itself and will still + // hold. Consumers that want to treat a bootstrap-order problem as a failure + // can gate on this value specifically. + DryRunSkipNamespaceNotFound DryRunSkipReason = "namespaceNotFound" +) + +// DryRunInfo records that a resource's desired state was NOT verified against +// the apiserver, and why. It exists so a consumer can tell a high-fidelity +// addition diff (defaulting and mutating admission applied) from one that is +// only what the render pipeline produced. +// +// It is emitted only in the degraded case. Absence therefore means the desired +// state DID go through the apiserver — with one deliberate exception: removal +// diffs never carry it, because they are produced by +// CalculateRemovedResourceDiffs straight from cluster state and have no desired +// state to preview at all. Read absence as "nothing was skipped", not as a +// positive fidelity guarantee for a resource that was never a candidate. +// +// Warnings cover the same ground for humans, but cannot replace this: an +// OutputWarning has no resource anchor, so it cannot tell a pipeline WHICH +// additions were degraded. +type DryRunInfo struct { + // Performed is always false when this struct is present, since the struct + // is emitted only when the dry-run did not happen. That redundancy is + // deliberate: it keeps the emitted JSON self-describing, so a consumer + // reading a dryRun object does not have to know that mere presence implies + // degradation, and it leaves room to emit the struct unconditionally later + // without a schema break. + Performed bool `json:"performed"` + + SkipReason DryRunSkipReason `json:"skipReason,omitempty"` + + // Detail is the underlying cause as reported by the cluster (the + // SelfSubjectAccessReview's reason, or the apiserver's error message). + Detail string `json:"detail,omitempty"` + + // StorageVersion is the version the resource's CRD stores it at, set only when that is not the + // version it was requested at. The local prediction defaults against the requested version + // only, while the cluster may also apply the storage version's defaults and convert the + // resource through it, so the diff may omit both. Empty for a built-in type, a single-version + // CRD, or a request at the storage version (#528). + StorageVersion string `json:"storageVersion,omitempty"` +} + +// DiffType represents the type of diff (added, removed, modified). +type DiffType string + +// generatedSuffixLen mirrors the hash length crossplane's +// internal/names.ChildName uses when generating a child name. Names produced +// by `crossplane internal render` for composed resources are +// "<12 lowercase hex>"; we synthesize XR names +// in the same shape (see SynthesizeGeneratedName). +const generatedSuffixLen = 12 + +// xrSynthesisSeed is the input we hash to derive the 12-hex suffix appended +// to an XR's generateName when synthesizing a metadata.name. Picking +// upstream-shape (12 hex via sha256, see SynthesizeGeneratedName) means the +// resulting XR name matches the shape of binary-generated composed names AND +// the suffix value itself is recognisable on its own — which we rely on for +// downstream detection in LooksLikeGeneratedName. +const xrSynthesisSeed = "crossplane-diff/synthesized-xr" + +// xrSynthesisSuffix returns the deterministic 12-hex suffix we use for XR +// name synthesis. Two surfaces depend on it: +// +// 1. Direct shape match — the synthesized XR name itself is +// "", caught by LooksLikeGeneratedName via the +// shape branch. +// 2. Embedded match — composition templates that interpolate the XR's +// metadata.name into a downstream resource's name carry the suffix +// verbatim (e.g. "" or +// "-"); those resources may not +// have their own generateName set, so we detect via Contains. +func xrSynthesisSuffix() string { + h := sha256.Sum256([]byte(xrSynthesisSeed)) + return hex.EncodeToString(h[:])[:generatedSuffixLen] +} + +// SynthesizeGeneratedName builds a metadata.name in the same shape upstream's +// internal/names.ChildName produces: "<12 hex>". +// +// We use this when an XR has bare generateName and we need a metadata.name +// for the binary's validation. Picking upstream-shape lets the same +// detector (LooksLikeGeneratedName) catch both this name and the binary's +// own composed-resource names. +func SynthesizeGeneratedName(parent string) string { + if !strings.HasSuffix(parent, "-") { + parent += "-" + } + + return parent + xrSynthesisSuffix() +} + +// LooksLikeGeneratedName reports whether name was produced by either our +// XR synthesis path or the binary's nameGenerator. True when: +// +// - name has the deterministic shape upstream's nameGenerator emits — +// "<12 lowercase hex>" (catches binary-generated +// composed-resource names whose template carries a generateName); or +// - name embeds xrSynthesisSuffix anywhere (catches the synthesized XR +// itself and any downstream resource whose template interpolated the +// XR's metadata.name into its own). +func LooksLikeGeneratedName(name, generateName string) bool { + if name == "" { + return false + } + + if generateName != "" { + gen := generateName + if !strings.HasSuffix(gen, "-") { + gen += "-" + } + + if suffix, ok := strings.CutPrefix(name, gen); ok && isLowerHex(suffix, generatedSuffixLen) { + return true + } + } + + return strings.Contains(name, xrSynthesisSuffix()) +} + +// GeneratedDisplayName returns the user-facing label for a name produced by +// either synthesis path. Caller is responsible for checking +// LooksLikeGeneratedName first. +func GeneratedDisplayName(name, generateName string) string { + if generateName != "" { + gen := generateName + if !strings.HasSuffix(gen, "-") { + gen += "-" + } + + if suffix, ok := strings.CutPrefix(name, gen); ok && isLowerHex(suffix, generatedSuffixLen) { + return generateName + "(generated)" + } + } + + // xrSynthesisSuffix was interpolated into name; cut at the suffix and + // drop the leading dash from what remains so the display reads cleanly. + before, _, _ := strings.Cut(name, xrSynthesisSuffix()) + + return strings.TrimSuffix(before, "-") + "-(generated)" +} + +// isLowerHex reports whether s is exactly length characters of lowercase +// hex — matching what hex.EncodeToString produces. encoding/hex.DecodeString +// alone accepts uppercase too, so we lowercase-check first; otherwise a +// stdlib decode handles the rest. +func isLowerHex(s string, length int) bool { + if len(s) != length || s != strings.ToLower(s) { + return false + } + + _, err := hex.DecodeString(s) + + return err == nil +} + +const ( + // DiffTypeAdded an added section. + DiffTypeAdded DiffType = "+" + // DiffTypeRemoved a removed section. + DiffTypeRemoved DiffType = "-" + // DiffTypeModified a modified section. + DiffTypeModified DiffType = "~" + // DiffTypeEqual an unchanged section. + DiffTypeEqual DiffType = "=" +) + +// DiffTypeWord constants for human-readable JSON output. +// These are used in structured output (JSON/YAML) for better readability. +const ( + DiffTypeWordAdded = "added" + DiffTypeWordRemoved = "removed" + DiffTypeWordModified = "modified" + DiffTypeWordEqual = "equal" +) + +// DiffKey constants for structured diff output. +// These are the keys used in the diff map to hold resource states. +const ( + DiffKeyOld = "old" // Current state for modified resources + DiffKeyNew = "new" // Desired state for modified resources + DiffKeySpec = "spec" // Full spec for added/removed resources +) + +// ToWord converts a DiffType symbol to its human-readable word. +func (d DiffType) ToWord() string { + switch d { + case DiffTypeAdded: + return DiffTypeWordAdded + case DiffTypeRemoved: + return DiffTypeWordRemoved + case DiffTypeModified: + return DiffTypeWordModified + case DiffTypeEqual: + return DiffTypeWordEqual + default: + return string(d) + } +} + +// Colors for terminal output. +const ( + // ColorRed an ANSI "begin red" character. + ColorRed = "\x1b[31m" + // ColorGreen an ANSI "begin green" character. + ColorGreen = "\x1b[32m" + // ColorYellow an ANSI "begin yellow" character. + ColorYellow = "\x1b[33m" + // ColorReset an ANSI "reset color" character. + ColorReset = "\x1b[0m" +) + +// GetDiffKey returns a key that can be used to identify this object for use in a map. +func (d *ResourceDiff) GetDiffKey() string { + return MakeDiffKey(d.Gvk.GroupVersion().String(), d.Gvk.Kind, d.Namespace, d.ResourceName) +} + +// MakeDiffKey creates a unique key for a resource diff. +// Format: apiVersion/kind/namespace/name (namespace may be empty for cluster-scoped resources). +func MakeDiffKey(apiVersion, kind, namespace, name string) string { + return fmt.Sprintf("%s/%s/%s/%s", apiVersion, kind, namespace, name) +} + +// MakeDiffKeyFromResource creates a unique key for a resource diff from an Unstructured resource. +// This is a convenience wrapper around MakeDiffKey that extracts all fields from the resource. +func MakeDiffKeyFromResource(res *un.Unstructured) string { + return MakeDiffKey(res.GetAPIVersion(), res.GetKind(), res.GetNamespace(), res.GetName()) +} + +// OutputError represents an error in structured output. +// Used consistently by both XR diff and comp diff for machine-readable error handling. +// Note: Only JSON tags are used because sigs.k8s.io/yaml uses JSON tags for YAML serialization. +// +// ResourceID and ValidationFailures play complementary roles: +// +// - ResourceID identifies the input the diff command was processing +// (the XR or claim file the user fed in). It is "/" +// so machine consumers can correlate an error to a specific +// input across batched runs. +// +// - ValidationFailures, when non-empty, gives a structured +// per-resource breakdown of *what* the schema validator rejected +// within that input's render tree. It contains one entry per +// resource (the input itself plus any composed resource) that +// failed validation, with full GVK / namespace / typed errors, +// so consumers can drive UI or programmatic decisions without +// parsing the human-readable Message. +// +// The two fields therefore partially overlap when the input itself is +// among the failing resources — that's intentional. ValidationFailures +// is the complete failure list (so consumers iterating it never miss +// an XR-level rejection); ResourceID independently anchors the error +// to a user-supplied input. ValidationFailures is set only for schema +// validation errors; it is nil for tool, IO, and render errors. +type OutputError struct { + ResourceID string `json:"resourceID,omitempty"` + Message string `json:"message"` + ValidationFailures []ResourceValidationFailure `json:"validationFailures,omitempty"` +} + +// ResourceValidationFailure is the per-resource view inside an +// OutputError.ValidationFailures slice. It mirrors the shape of +// crossplane/cli's pkg/validate.ResourceValidationResult so the +// information transfers cleanly, but is defined here so crossplane-diff's +// JSON output schema is owned by us — consumers depend on this struct, +// not the upstream type. +type ResourceValidationFailure struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + // Status is the validator-assigned outcome for this resource. + // Today the surfaced values are "invalid" and "missingSchema"; + // "valid" entries are filtered out by the converter so machine + // consumers iterating ValidationFailures see only the failure rows. + Status string `json:"status"` + Errors []FieldValidationError `json:"errors,omitempty"` +} + +// FieldValidationError is the wire shape for a single field-level +// validation error. Mirrors pkg/validate.FieldValidationError; defined +// here so consumers of our JSON output bind to a stable schema we +// control rather than the upstream type. +type FieldValidationError struct { + // Type categorizes the error: "schema", "cel", "unknownField", + // or "defaulting". + Type string `json:"type"` + // Field is the JSONPath of the offending field (e.g. + // "spec.forProvider.region"), set when the validator can pinpoint + // a path. Empty for errors with no field locality (e.g. + // defaulting failures). + Field string `json:"field,omitempty"` + // Message is the validator-emitted human-readable description. + // For k8s-derived schema errors this typically embeds the field + // path and bad value already. + Message string `json:"message"` + // Value, when set, is the offending value as the validator saw + // it. Type is preserved (string, number, bool, struct), so JSON + // consumers can present or compare it directly. + Value any `json:"value,omitempty"` +} + +// FormatError returns a human-readable error string. +// If ResourceID is empty, it uses "" to indicate a system-level error +// not tied to any specific resource (e.g., cluster connection issues). +func (e OutputError) FormatError() string { + resourceID := e.ResourceID + if resourceID == "" { + resourceID = "" + } + + return fmt.Sprintf("ERROR: %s: %s", resourceID, e.Message) +} + +// OutputWarning is a non-fatal advisory: something the user should know that does not invalidate the +// diff or stop the run. Warnings follow the same dual-emission contract as OutputError — written to +// stderr for humans and included in structured output for machines — but deliberately do NOT affect +// the exit code. A consumer gating on failure checks errors[], not warnings[]. +// +// Context carries the log key/value pairs from the emitting call site (e.g. which composition, how +// many credentials were fetched). It is a map rather than being flattened into Message so machine +// consumers can read the individual values instead of parsing prose. Unlike OutputError there is no +// ResourceID: warnings originate deep in the call stack, where the user-supplied input that led +// there is not known, so the anchoring information lives in Context under whatever key the call +// site used. +type OutputWarning struct { + Message string `json:"message"` + Context map[string]string `json:"context,omitempty"` +} + +// FormatWarning renders the warning as a single human-readable stderr line, mirroring +// OutputError.FormatError. Context pairs are appended in sorted key order so output is stable +// across runs (Go map iteration is not). +func (w OutputWarning) FormatWarning() string { + if len(w.Context) == 0 { + return fmt.Sprintf("WARNING: %s", w.Message) + } + + keys := make([]string, 0, len(w.Context)) + for k := range w.Context { + keys = append(keys, k) + } + + sort.Strings(keys) + + pairs := make([]string, 0, len(keys)) + for _, k := range keys { + pairs = append(pairs, fmt.Sprintf("%s=%s", k, w.Context[k])) + } + + return fmt.Sprintf("WARNING: %s (%s)", w.Message, strings.Join(pairs, ", ")) +} diff --git a/internal/diff/testutils/envtestreaper/reaper.go b/internal/diff/testutils/envtestreaper/reaper.go new file mode 100644 index 00000000..1bbd3645 --- /dev/null +++ b/internal/diff/testutils/envtestreaper/reaper.go @@ -0,0 +1,120 @@ +// Package envtestreaper kills envtest servers that earlier test runs left behind. +// +// envtest runs kube-apiserver and etcd as children of the test binary, and only a deferred Environment.Stop ends +// them. When the binary dies without running its defers (the -timeout panic, a kill, a Ctrl+C), they are reparented to +// PID 1 and run until someone kills them, piling up across runs (#524). Calling Reap from TestMain kills them when the +// next run starts. +package envtestreaper + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/shirou/gopsutil/v4/process" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +// isServer reports whether name is that of a long-running server envtest starts. +func isServer(name string) bool { + return name == "kube-apiserver" || name == "etcd" +} + +// candidate is an orphaned process named like an envtest server. +type candidate struct { + proc *process.Process + // exe is the absolute path of the process's executable. + exe string +} + +// Reap SIGKILLs every envtest server orphaned by an earlier run, and reports through logf what it killed and what it +// could not. A process whose parent is PID 1 has lost the test binary that started it, so it cannot belong to a live +// run. Reap is best-effort: it never fails, and it skips any process it cannot inspect. +func Reap(logf func(format string, args ...any)) { + pids, err := process.Pids() + if err != nil { + logf("envtest reaper: cannot list processes: %v", err) + return + } + + // Look up the executable only of orphans named like an envtest server. + var candidates []candidate + + for _, pid := range pids { + // Not process.NewProcess, which first checks the PID exists and fetches its creation time: most of the + // scan's cost, for nothing Kill uses. A PID that has exited meanwhile fails Ppid and is skipped. + p := &process.Process{Pid: pid} + + if ppid, err := p.Ppid(); err != nil || ppid != 1 { + continue + } + + if name, err := p.Name(); err != nil || !isServer(name) { + continue + } + + if exe, err := p.Exe(); err == nil { + candidates = append(candidates, candidate{proc: p, exe: exe}) + } + } + + var killed []string + + for _, c := range selectServers(candidates, binaryDirs()) { + if err := c.proc.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) { + logf("envtest reaper: cannot kill %s (pid %d): %v", c.exe, c.proc.Pid, err) + continue + } + + killed = append(killed, fmt.Sprintf("%s (pid %d)", filepath.Base(c.exe), c.proc.Pid)) + } + + if len(killed) > 0 { + logf("envtest reaper: killed %d envtest server(s) orphaned by an earlier run: %s", + len(killed), strings.Join(killed, ", ")) + } +} + +// selectServers returns the candidates whose executable is an envtest server directly in one of dirs. +func selectServers(candidates []candidate, dirs []string) []candidate { + var selected []candidate + + for _, c := range candidates { + exe := filepath.Clean(c.exe) + if filepath.IsAbs(exe) && isServer(filepath.Base(exe)) && slices.Contains(dirs, filepath.Dir(exe)) { + selected = append(selected, c) + } + } + + return selected +} + +// binaryDirs returns every directory a test run could have started envtest's servers from: KUBEBUILDER_ASSETS, each +// version in setup-envtest's store, and envtest's built-in default (where the Earthfile installs them). All are +// included, not only the one envtest would use now, because an orphan may come from a run configured differently. +// Each is also included with its symlinks resolved, as the process table may report the resolved path. +func binaryDirs() []string { + dirs := []string{"/usr/local/kubebuilder/bin"} + + if v := os.Getenv("KUBEBUILDER_ASSETS"); v != "" { + dirs = append(dirs, filepath.Clean(v)) + } + + if store, err := envtest.SetupEnvtestDefaultBinaryAssetsDirectory(); err == nil { + versions, _ := os.ReadDir(store) // A missing store has no versions. + for _, v := range versions { + dirs = append(dirs, filepath.Join(store, v.Name())) + } + } + + for _, d := range dirs { + if r, err := filepath.EvalSymlinks(d); err == nil && r != d { + dirs = append(dirs, r) + } + } + + return dirs +} diff --git a/internal/diff/testutils/envtestreaper/reaper_test.go b/internal/diff/testutils/envtestreaper/reaper_test.go new file mode 100644 index 00000000..e43215d4 --- /dev/null +++ b/internal/diff/testutils/envtestreaper/reaper_test.go @@ -0,0 +1,47 @@ +package envtestreaper + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestSelectServers(t *testing.T) { + const store = "/Users/u/Library/Application Support/io.kubebuilder.envtest/k8s/1.32.0-darwin-arm64" + + dirs := []string{store, "/usr/local/kubebuilder/bin"} + + // Reap passes only PPID-1 processes named like a server; the executable path decides the rest. + candidates := []candidate{ + {exe: store + "/kube-apiserver"}, // orphaned envtest apiserver + {exe: store + "/etcd"}, // orphaned envtest etcd + {exe: "/usr/local/kubebuilder/bin/etcd"}, // orphaned envtest etcd (Earthly layout) + {exe: store + "/../1.32.0-darwin-arm64/etcd"}, // unclean path to an envtest binary + {exe: "/opt/homebrew/bin/etcd"}, // a real etcd daemon + {exe: "/usr/local/bin/kube-apiserver"}, // a real apiserver + {exe: store + "/kubectl"}, // a non-server envtest binary + {exe: "/usr/local/kubebuilder/bin-x/etcd"}, // a sibling dir sharing a prefix + {exe: store + "/sub/etcd"}, // nested below an envtest dir + {exe: "etcd"}, // a bare name, not a path + {exe: ""}, // unknown executable + } + + selected := selectServers(candidates, dirs) + + got := make([]string, 0, len(selected)) + for _, c := range selected { + got = append(got, c.exe) + } + + want := []string{ + store + "/kube-apiserver", + store + "/etcd", + "/usr/local/kubebuilder/bin/etcd", + store + "/../1.32.0-darwin-arm64/etcd", + } + + if diff := cmp.Diff(want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("selectServers() -want +got:\n%s", diff) + } +} diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go new file mode 100644 index 00000000..24524075 --- /dev/null +++ b/internal/diff/testutils/mock_builder.go @@ -0,0 +1,2230 @@ +package testutils + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +const ( + fieldSpec = "spec" + jsonTypeObject = "object" + jsonTypeString = "string" +) + +// MockBuilder provides a fluent API for building mock objects used in testing. +// This helps reduce duplication in test setup code while making the intent clearer. + +// region Kubernetes API layer mock builders + +// ====================================================================================== +// Kubernetes API Layer Mock Builders +// ====================================================================================== + +// MockResourceClientBuilder helps build kubernetes.ResourceClient mocks. +type MockResourceClientBuilder struct { + mock *MockResourceClient + + // resourceScopes accumulates GVK -> namespaced across successive + // WithNamespacedResource / WithClusterScopedResource calls so the two + // can be combined on one builder. + resourceScopes map[schema.GroupVersionKind]bool +} + +// NewMockResourceClient creates a new MockResourceClientBuilder. +func NewMockResourceClient() *MockResourceClientBuilder { + return &MockResourceClientBuilder{ + mock: &MockResourceClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockResourceClientBuilder) WithInitialize(fn func(context.Context) error) *MockResourceClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize sets a successful Initialize implementation. +func (b *MockResourceClientBuilder) WithSuccessfulInitialize() *MockResourceClientBuilder { + return b.WithInitialize(func(context.Context) error { + return nil + }) +} + +// WithFoundGVKs sets the GetGVKsForGroupKind behavior. +func (b *MockResourceClientBuilder) WithFoundGVKs(gvks []schema.GroupVersionKind) *MockResourceClientBuilder { + b.mock.GetGVKsForGroupKindFn = func(_ context.Context, _, _ string) ([]schema.GroupVersionKind, error) { + return gvks, nil + } + + return b +} + +// WithoutFoundGVKs sets the GetGVKsForGroupKind behavior. +func (b *MockResourceClientBuilder) WithoutFoundGVKs(err string) *MockResourceClientBuilder { + b.mock.GetGVKsForGroupKindFn = func(_ context.Context, _, _ string) ([]schema.GroupVersionKind, error) { + return nil, errors.New(err) + } + + return b +} + +// WithGetResource sets the GetResource behavior. +func (b *MockResourceClientBuilder) WithGetResource(fn func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error)) *MockResourceClientBuilder { + b.mock.GetResourceFn = fn + return b +} + +// WithResourcesExist sets up GetResource to return resources from a map. +// Resources are keyed using MakeDiffKey (apiVersion/kind/namespace/name) to correctly handle +// same-named resources in different namespaces. +func (b *MockResourceClientBuilder) WithResourcesExist(resources ...*un.Unstructured) *MockResourceClientBuilder { + resourceMap := make(map[string]*un.Unstructured) + + // Build a map for fast lookup using the same key format as diff keys + for _, res := range resources { + key := types.MakeDiffKeyFromResource(res) + resourceMap[key] = res + } + + return b.WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, namespace, name string) (*un.Unstructured, error) { + key := types.MakeDiffKey(gvk.GroupVersion().String(), gvk.Kind, namespace, name) + if res, found := resourceMap[key]; found { + return res, nil + } + + return nil, apierrors.NewNotFound( + schema.GroupResource{ + Group: gvk.Group, + Resource: strings.ToLower(gvk.Kind) + "s", + }, + name, + ) + }) +} + +// WithResourceNotFound sets GetResource to always return "not found". +func (b *MockResourceClientBuilder) WithResourceNotFound() *MockResourceClientBuilder { + return b.WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + // Create a proper Kubernetes "not found" error + return nil, apierrors.NewNotFound( + schema.GroupResource{ + Group: gvk.Group, + Resource: strings.ToLower(gvk.Kind) + "s", // Naive pluralization similar to the real code + }, + name, + ) + }) +} + +// WithGetResourceError sets GetResource to always return the given error +// (for non-NotFound transport-failure scenarios; use WithResourceNotFound +// for clean 404 cases). +func (b *MockResourceClientBuilder) WithGetResourceError(err error) *MockResourceClientBuilder { + return b.WithGetResource(func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) { + return nil, err + }) +} + +// WithListResources sets the ListResources behavior. +func (b *MockResourceClientBuilder) WithListResources(fn func(context.Context, schema.GroupVersionKind, string) ([]*un.Unstructured, error)) *MockResourceClientBuilder { + b.mock.ListResourcesFn = fn + return b +} + +// WithEmptyListResources mimics an empty but successful response. +func (b *MockResourceClientBuilder) WithEmptyListResources() *MockResourceClientBuilder { + b.WithListResources(func(context.Context, schema.GroupVersionKind, string) ([]*un.Unstructured, error) { + return []*un.Unstructured{}, nil + }) + // Also set up GetResourcesByLabel to return empty (for composition revision queries) + return b.WithGetResourcesByLabel(func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error) { + return []*un.Unstructured{}, nil + }) +} + +// WithListResourcesFailure mimics a failed response. +func (b *MockResourceClientBuilder) WithListResourcesFailure(errorStr string) *MockResourceClientBuilder { + return b.WithListResources(func(context.Context, schema.GroupVersionKind, string) ([]*un.Unstructured, error) { + return nil, errors.New(errorStr) + }) +} + +// WithGetResourcesByLabel sets the GetResourcesByLabel behavior. +func (b *MockResourceClientBuilder) WithGetResourcesByLabel(fn func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error)) *MockResourceClientBuilder { + b.mock.GetResourcesByLabelFn = fn + return b +} + +// WithResourcesFoundByLabel sets GetResourcesByLabel to return resources for a specific label value. +// This method is stateful - multiple calls accumulate mappings for different label values. +// The label parameter specifies which label key to match on (e.g., "crossplane.io/composition-name"). +// +// An optional expectNamespace pins the lookup namespace: when supplied, the +// resources are returned only if GetResourcesByLabel is called with that exact +// namespace. Pass metav1.NamespaceAll to require a cluster-wide (all-namespaces) +// lookup, as for matchLabels ExtraResources +// (crossplane-contrib/crossplane-diff#376) — a regression that scopes the lookup +// to a specific namespace then yields zero results. When omitted, the namespace +// is not considered and only the label value is matched. +func (b *MockResourceClientBuilder) WithResourcesFoundByLabel(resources []*un.Unstructured, label, value string, expectNamespace ...string) *MockResourceClientBuilder { + matches := func(namespace string, selector metav1.LabelSelector) bool { + labelValue, exists := selector.MatchLabels[label] + if !exists || labelValue != value { + return false + } + + return len(expectNamespace) == 0 || namespace == expectNamespace[0] + } + + // If we don't have an existing GetResourcesByLabel function, create a new one + if b.mock.GetResourcesByLabelFn == nil { + return b.WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, namespace string, selector metav1.LabelSelector) ([]*un.Unstructured, error) { + if matches(namespace, selector) { + return resources, nil + } + + return []*un.Unstructured{}, nil + }) + } + + // If we already have a GetResourcesByLabel function, we need to wrap it + // This allows multiple calls to accumulate state + originalFn := b.mock.GetResourcesByLabelFn + + return b.WithGetResourcesByLabel(func(ctx context.Context, gvk schema.GroupVersionKind, namespace string, selector metav1.LabelSelector) ([]*un.Unstructured, error) { + if matches(namespace, selector) { + return resources, nil + } + + // Fall through to the original function + return originalFn(ctx, gvk, namespace, selector) + }) +} + +// WithGetAllResourcesByLabels sets the GetAllResourcesByLabels behavior. +func (b *MockResourceClientBuilder) WithGetAllResourcesByLabels(fn func(context.Context, []schema.GroupVersionKind, []metav1.LabelSelector) ([]*un.Unstructured, error)) *MockResourceClientBuilder { + b.mock.GetAllResourcesByLabelsFn = fn + return b +} + +// WithIsNamespacedResource sets the IsNamespacedResource behavior. +func (b *MockResourceClientBuilder) WithIsNamespacedResource(fn func(context.Context, schema.GroupVersionKind) (bool, error)) *MockResourceClientBuilder { + b.mock.IsNamespacedResourceFn = fn + return b +} + +// WithNamespacedResource sets specific GVKs to be namespaced. +// +// Composes with WithClusterScopedResource: each call adds to the set of known +// scopes rather than replacing it, so a single mock can describe a mix of +// namespaced and cluster-scoped kinds. GVKs that no call has described still +// return an error, keeping tests explicit about what they rely on. +func (b *MockResourceClientBuilder) WithNamespacedResource(gvks ...schema.GroupVersionKind) *MockResourceClientBuilder { + return b.withResourceScopes(gvks, true) +} + +// WithClusterScopedResource sets specific GVKs to be cluster-scoped. +// +// Composes with WithNamespacedResource — see that method for details. +func (b *MockResourceClientBuilder) WithClusterScopedResource(gvks ...schema.GroupVersionKind) *MockResourceClientBuilder { + return b.withResourceScopes(gvks, false) +} + +// withResourceScopes records the scope of each GVK and installs a lookup that +// consults the accumulated map. Successive calls share the map, which is what +// lets the two exported helpers be combined. +func (b *MockResourceClientBuilder) withResourceScopes(gvks []schema.GroupVersionKind, namespaced bool) *MockResourceClientBuilder { + if b.resourceScopes == nil { + b.resourceScopes = make(map[schema.GroupVersionKind]bool) + } + + for _, gvk := range gvks { + b.resourceScopes[gvk] = namespaced + } + + scopes := b.resourceScopes + + return b.WithIsNamespacedResource(func(_ context.Context, gvk schema.GroupVersionKind) (bool, error) { + if isNamespaced, exists := scopes[gvk]; exists { + return isNamespaced, nil + } + // Default to error for unconfigured resources to make tests explicit + return false, errors.Errorf("IsNamespacedResource not configured for %s in mock", gvk.String()) + }) +} + +// Build returns the built mock. +func (b *MockResourceClientBuilder) Build() *MockResourceClient { + return b.mock +} + +// MockSchemaClientBuilder helps build kubernetes.SchemaClient mocks. +type MockSchemaClientBuilder struct { + mock *MockSchemaClient +} + +// NewMockSchemaClient creates a new MockSchemaClientBuilder. +func NewMockSchemaClient() *MockSchemaClientBuilder { + return &MockSchemaClientBuilder{ + mock: &MockSchemaClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockSchemaClientBuilder) WithInitialize(fn func(context.Context) error) *MockSchemaClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithGetCRD sets the GetCRD behavior. +func (b *MockSchemaClientBuilder) WithGetCRD(fn func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error)) *MockSchemaClientBuilder { + b.mock.GetCRDFn = fn + return b +} + +// WithCRDNotFound sets GetCRD to return a not found error. +func (b *MockSchemaClientBuilder) WithCRDNotFound() *MockSchemaClientBuilder { + return b.WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.New("CRD not found") + }) +} + +// WithSuccessfulCRDFetch sets GetCRD to return a specific CRD. +func (b *MockSchemaClientBuilder) WithSuccessfulCRDFetch(crd *extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + return b.WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return crd, nil + }) +} + +// WithFoundCRD sets GetCRD to return a specific CRD when the group and kind match. +func (b *MockSchemaClientBuilder) WithFoundCRD(group, kind string, crd *extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + // If we don't have an existing GetCRD function, create a new one + if b.mock.GetCRDFn == nil { + return b.WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == group && gvk.Kind == kind { + return crd, nil + } + + return nil, errors.New("CRD not found") + }) + } + + // If we already have a GetCRD function, wrap it to add this mapping + originalFn := b.mock.GetCRDFn + + return b.WithGetCRD(func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == group && gvk.Kind == kind { + return crd, nil + } + + return originalFn(ctx, gvk) + }) +} + +// WithFoundCRDs sets GetCRD to return specific CRDs when the group and kind match, with a fallback error. +func (b *MockSchemaClientBuilder) WithFoundCRDs(crdMappings map[schema.GroupKind]*extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + return b.WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + groupKind := schema.GroupKind{Group: gvk.Group, Kind: gvk.Kind} + if crd, found := crdMappings[groupKind]; found { + return crd, nil + } + + return nil, errors.New("CRD not found") + }) +} + +// WithGetCRDByName sets the GetCRDByName behavior. +func (b *MockSchemaClientBuilder) WithGetCRDByName(fn func(string) (*extv1.CustomResourceDefinition, error)) *MockSchemaClientBuilder { + b.mock.GetCRDByNameFn = fn + return b +} + +// WithSuccessfulCRDByNameFetch sets GetCRDByName to return a specific CRD for a given name. +func (b *MockSchemaClientBuilder) WithSuccessfulCRDByNameFetch(name string, crd *extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + return b.WithGetCRDByName(func(searchName string) (*extv1.CustomResourceDefinition, error) { + if searchName == name { + return crd, nil + } + + return nil, errors.Errorf("CRD with name %s not found", searchName) + }) +} + +// WithIsCRDRequired sets the IsCRDRequired behavior. +func (b *MockSchemaClientBuilder) WithIsCRDRequired(fn func(context.Context, schema.GroupVersionKind) bool) *MockSchemaClientBuilder { + b.mock.IsCRDRequiredFn = fn + return b +} + +// WithResourcesRequiringCRDs sets only the specified GVKs to require CRDs. +func (b *MockSchemaClientBuilder) WithResourcesRequiringCRDs(crdsRequiredGVKs ...schema.GroupVersionKind) *MockSchemaClientBuilder { + requiresCRD := make(map[schema.GroupVersionKind]bool) + for _, gvk := range crdsRequiredGVKs { + requiresCRD[gvk] = true + } + + return b.WithIsCRDRequired(func(_ context.Context, gvk schema.GroupVersionKind) bool { + // Only require CRDs for specified GVKs + return requiresCRD[gvk] + }) +} + +// WithAllResourcesRequiringCRDs sets all resources to require CRDs. +func (b *MockSchemaClientBuilder) WithAllResourcesRequiringCRDs() *MockSchemaClientBuilder { + return b.WithIsCRDRequired(func(context.Context, schema.GroupVersionKind) bool { + return true + }) +} + +// WithNoResourcesRequiringCRDs sets all resources to not require CRDs. +func (b *MockSchemaClientBuilder) WithNoResourcesRequiringCRDs() *MockSchemaClientBuilder { + return b.WithIsCRDRequired(func(context.Context, schema.GroupVersionKind) bool { + return false + }) +} + +// WithValidateResource sets the ValidateResource behavior. +func (b *MockSchemaClientBuilder) WithValidateResource(fn func(context.Context, *un.Unstructured) error) *MockSchemaClientBuilder { + b.mock.ValidateResourceFn = fn + return b +} + +// WithGetAllCRDs sets the GetAllCRDs behavior. +func (b *MockSchemaClientBuilder) WithGetAllCRDs(fn func() []*extv1.CustomResourceDefinition) *MockSchemaClientBuilder { + b.mock.GetAllCRDsFn = fn + return b +} + +// WithCachingBehavior creates a mock that simulates caching CRDs when GetCRD or LoadCRDsFromXRDs is called. +func (b *MockSchemaClientBuilder) WithCachingBehavior() *MockSchemaClientBuilder { + cachedCRDs := make(map[string]*extv1.CustomResourceDefinition) + + // Override GetCRD to track cached CRDs + originalGetCRD := b.mock.GetCRDFn + b.mock.GetCRDFn = func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + // Check cache first + key := gvk.String() + if crd, ok := cachedCRDs[key]; ok { + return crd, nil + } + + // Call original function + if originalGetCRD != nil { + crd, err := originalGetCRD(ctx, gvk) + if err == nil && crd != nil { + // Cache the CRD + cachedCRDs[key] = crd + } + + return crd, err + } + + return nil, errors.New("GetCRD not implemented") + } + + // Override LoadCRDsFromXRDs to simulate caching CRDs converted from XRDs + originalLoadCRDs := b.mock.LoadCRDsFromXRDsFn + b.mock.LoadCRDsFromXRDsFn = func(ctx context.Context, xrds []*un.Unstructured) error { + // Call original function first + if originalLoadCRDs != nil { + err := originalLoadCRDs(ctx, xrds) + if err != nil { + return err + } + } + + // Simulate caching CRDs converted from XRDs + // For test purposes, create a simple CRD from each XRD + for _, xrd := range xrds { + group, _, _ := un.NestedString(xrd.Object, fieldSpec, "group") + if group == "" { + continue // Skip invalid XRDs + } + + names, ok, _ := un.NestedMap(xrd.Object, fieldSpec, "names") + if !ok { + continue + } + + kind, _ := names["kind"].(string) + if kind == "" { + continue + } + + // Create a simple CRD for this XRD + crd := &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: xrd.GetName(), + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: group, + Names: extv1.CustomResourceDefinitionNames{ + Kind: kind, + }, + Scope: extv1.NamespaceScoped, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + }, + } + + gvk := schema.GroupVersionKind{Group: group, Version: "v1", Kind: kind} + cachedCRDs[gvk.String()] = crd + } + + return nil + } + + // Override GetAllCRDs to return cached CRDs + b.mock.GetAllCRDsFn = func() []*extv1.CustomResourceDefinition { + result := make([]*extv1.CustomResourceDefinition, 0, len(cachedCRDs)) + for _, crd := range cachedCRDs { + result = append(result, crd) + } + + return result + } + + return b +} + +// Build returns the built mock. +func (b *MockSchemaClientBuilder) Build() *MockSchemaClient { + return b.mock +} + +// MockApplyClientBuilder helps build kubernetes.ApplyClient mocks. +type MockApplyClientBuilder struct { + mock *MockApplyClient +} + +// NewMockApplyClient creates a new MockApplyClientBuilder. +func NewMockApplyClient() *MockApplyClientBuilder { + return &MockApplyClientBuilder{ + mock: &MockApplyClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockApplyClientBuilder) WithInitialize(fn func(context.Context) error) *MockApplyClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithApply sets the Apply behavior. +func (b *MockApplyClientBuilder) WithApply(fn func(context.Context, *un.Unstructured) (*un.Unstructured, error)) *MockApplyClientBuilder { + b.mock.ApplyFn = fn + return b +} + +// WithDryRunApply sets the DryRunApply behavior. +func (b *MockApplyClientBuilder) WithDryRunApply(fn func(context.Context, *un.Unstructured, string) (*un.Unstructured, error)) *MockApplyClientBuilder { + b.mock.DryRunApplyFn = fn + return b +} + +// WithSuccessfulDryRun sets DryRunApply to return the input resource. +func (b *MockApplyClientBuilder) WithSuccessfulDryRun() *MockApplyClientBuilder { + return b.WithDryRunApply(func(_ context.Context, obj *un.Unstructured, _ string) (*un.Unstructured, error) { + return obj, nil + }) +} + +// WithFailedDryRun sets DryRunApply to return an error. +func (b *MockApplyClientBuilder) WithFailedDryRun(errMsg string) *MockApplyClientBuilder { + return b.WithDryRunApply(func(context.Context, *un.Unstructured, string) (*un.Unstructured, error) { + return nil, errors.New(errMsg) + }) +} + +// WithDryRunCreate sets the DryRunCreate behavior. +func (b *MockApplyClientBuilder) WithDryRunCreate(fn func(context.Context, *un.Unstructured) (*un.Unstructured, error)) *MockApplyClientBuilder { + b.mock.DryRunCreateFn = fn + return b +} + +// WithSuccessfulDryRunCreate sets DryRunCreate to return the input resource. +func (b *MockApplyClientBuilder) WithSuccessfulDryRunCreate() *MockApplyClientBuilder { + return b.WithDryRunCreate(func(_ context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + return obj, nil + }) +} + +// WithFailedDryRunCreate sets DryRunCreate to return an error. +func (b *MockApplyClientBuilder) WithFailedDryRunCreate(errMsg string) *MockApplyClientBuilder { + return b.WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, errors.New(errMsg) + }) +} + +// Build returns the built mock. +func (b *MockApplyClientBuilder) Build() *MockApplyClient { + return b.mock +} + +// MockAccessCheckerBuilder helps build kubernetes.AccessChecker mocks. +type MockAccessCheckerBuilder struct { + mock *MockAccessChecker +} + +// NewMockAccessChecker creates a new MockAccessCheckerBuilder. With no further calls the mock allows +// everything — see MockAccessChecker.Can for why that is the right default. +func NewMockAccessChecker() *MockAccessCheckerBuilder { + return &MockAccessCheckerBuilder{ + mock: &MockAccessChecker{}, + } +} + +// WithCan sets the Can behavior. +func (b *MockAccessCheckerBuilder) WithCan(fn func(context.Context, schema.GroupVersionKind, string, dtypes.Verb) (bool, string, error)) *MockAccessCheckerBuilder { + b.mock.CanFn = fn + return b +} + +// WithAllowed makes every authorization check succeed. +func (b *MockAccessCheckerBuilder) WithAllowed() *MockAccessCheckerBuilder { + return b.WithCan(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ dtypes.Verb) (bool, string, error) { + return true, "", nil + }) +} + +// WithDenied makes every authorization check report that we are not permitted, with the given +// reason. This is what turns a Forbidden dry-run into a graceful per-resource degradation rather +// than a reported cluster rejection. +func (b *MockAccessCheckerBuilder) WithDenied(reason string) *MockAccessCheckerBuilder { + return b.WithCan(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ dtypes.Verb) (bool, string, error) { + return false, reason, nil + }) +} + +// WithDeniedVerb denies only the named verb and allows everything else. A user holding patch but not +// create is the exact configuration this feature exists to cope with, so it needs to be expressible. +func (b *MockAccessCheckerBuilder) WithDeniedVerb(deniedVerb dtypes.Verb, reason string) *MockAccessCheckerBuilder { + return b.WithCan(func(_ context.Context, _ schema.GroupVersionKind, _ string, verb dtypes.Verb) (bool, string, error) { + if verb == deniedVerb { + return false, reason, nil + } + + return true, "", nil + }) +} + +// WithFailedCheck makes the authorization check itself fail, leaving a Forbidden dry-run +// unclassifiable. +func (b *MockAccessCheckerBuilder) WithFailedCheck(err error) *MockAccessCheckerBuilder { + return b.WithCan(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ dtypes.Verb) (bool, string, error) { + return false, "", err + }) +} + +// Build returns the built mock. +func (b *MockAccessCheckerBuilder) Build() *MockAccessChecker { + return b.mock +} + +// MockTypeConverterBuilder helps build kubernetes.TypeConverter mocks. +type MockTypeConverterBuilder struct { + mock *MockTypeConverter +} + +// NewMockTypeConverter creates a new MockTypeConverterBuilder. +func NewMockTypeConverter() *MockTypeConverterBuilder { + return &MockTypeConverterBuilder{ + mock: &MockTypeConverter{}, + } +} + +// WithGVKToGVR sets the GVKToGVR behavior. +func (b *MockTypeConverterBuilder) WithGVKToGVR(fn func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error)) *MockTypeConverterBuilder { + b.mock.GVKToGVRFn = fn + return b +} + +// WithDefaultGVKToGVR sets a default implementation for GVKToGVR. +func (b *MockTypeConverterBuilder) WithDefaultGVKToGVR() *MockTypeConverterBuilder { + return b.WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + // Simple default implementation that converts Kind to lowercase and adds 's' + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: strings.ToLower(gvk.Kind) + "s", + }, nil + }) +} + +// WithGetResourceNameForGVK sets the GetResourceNameForGVK behavior. +func (b *MockTypeConverterBuilder) WithGetResourceNameForGVK(fn func(context.Context, schema.GroupVersionKind) (string, error)) *MockTypeConverterBuilder { + b.mock.GetResourceNameForGVKFn = fn + return b +} + +// WithDefaultGetResourceNameForGVK sets a default implementation for GetResourceNameForGVK. +func (b *MockTypeConverterBuilder) WithDefaultGetResourceNameForGVK() *MockTypeConverterBuilder { + return b.WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { + // Simple default implementation that converts Kind to lowercase and adds 's' + return strings.ToLower(gvk.Kind) + "s", nil + }) +} + +// WithResourceNameForGVK sets GetResourceNameForGVK to return a specific resource name for a given GVK. +func (b *MockTypeConverterBuilder) WithResourceNameForGVK(gvk schema.GroupVersionKind, resourceName string) *MockTypeConverterBuilder { + // If we don't have an existing GetResourceNameForGVK function, create a new one + if b.mock.GetResourceNameForGVKFn == nil { + return b.WithGetResourceNameForGVK(func(_ context.Context, testGVK schema.GroupVersionKind) (string, error) { + if testGVK.Group == gvk.Group && testGVK.Version == gvk.Version && testGVK.Kind == gvk.Kind { + return resourceName, nil + } + + return "", errors.New("unexpected GVK in test") + }) + } + + // If we already have a GetResourceNameForGVK function, wrap it to add this mapping + originalFn := b.mock.GetResourceNameForGVKFn + + return b.WithGetResourceNameForGVK(func(ctx context.Context, testGVK schema.GroupVersionKind) (string, error) { + if testGVK.Group == gvk.Group && testGVK.Version == gvk.Version && testGVK.Kind == gvk.Kind { + return resourceName, nil + } + + return originalFn(ctx, testGVK) + }) +} + +// WithResourceNameForGVKs sets GetResourceNameForGVK to return specific resource names for given GVKs, with a fallback error. +func (b *MockTypeConverterBuilder) WithResourceNameForGVKs(gvkMappings map[schema.GroupVersionKind]string) *MockTypeConverterBuilder { + return b.WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { + if resourceName, found := gvkMappings[gvk]; found { + return resourceName, nil + } + + return "", errors.New("unexpected GVK in test") + }) +} + +// Build returns the built mock. +func (b *MockTypeConverterBuilder) Build() *MockTypeConverter { + return b.mock +} + +// endregion + +// region Crossplane API layer mock builders + +// ====================================================================================== +// Crossplane API Layer Mock Builders +// ====================================================================================== + +// MockCompositionClientBuilder helps build crossplane.CompositionClient mocks. +type MockCompositionClientBuilder struct { + mock *MockCompositionClient + + // composites accumulates the per-composition XR sets registered by + // WithResourcesForComposition, keyed by "/". It exists so repeated + // calls compose instead of overwriting each other: a multi-composition test needs a different XR + // set per composition, and silently keeping only the last registration made the earlier + // compositions look net-new (empty affected set) rather than failing the test. + composites map[string][]*un.Unstructured +} + +// NewMockCompositionClient creates a new MockCompositionClientBuilder. +func NewMockCompositionClient() *MockCompositionClientBuilder { + return &MockCompositionClientBuilder{ + mock: &MockCompositionClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockCompositionClientBuilder) WithInitialize(fn func(context.Context) error) *MockCompositionClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize mocks a successful call to Initialize. +func (b *MockCompositionClientBuilder) WithSuccessfulInitialize() *MockCompositionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return nil + } + + return b +} + +// WithFailedInitialize mocks a failed call to Initialize. +func (b *MockCompositionClientBuilder) WithFailedInitialize(errMsg string) *MockCompositionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return errors.New(errMsg) + } + + return b +} + +// WithFindMatchingComposition sets the FindMatchingComposition behavior. +func (b *MockCompositionClientBuilder) WithFindMatchingComposition(fn func(context.Context, *un.Unstructured) (dtypes.ResolvedComposition, error)) *MockCompositionClientBuilder { + b.mock.FindMatchingCompositionFn = fn + return b +} + +// WithSuccessfulCompositionMatch sets FindMatchingComposition to return a specific composition, with no +// revision resolved. +func (b *MockCompositionClientBuilder) WithSuccessfulCompositionMatch(comp *xpextv1.Composition) *MockCompositionClientBuilder { + return b.WithFindMatchingComposition(func(context.Context, *un.Unstructured) (dtypes.ResolvedComposition, error) { + return dtypes.ResolvedComposition{Composition: comp}, nil + }) +} + +// WithNoMatchingComposition sets FindMatchingComposition to return "not found". +func (b *MockCompositionClientBuilder) WithNoMatchingComposition() *MockCompositionClientBuilder { + return b.WithFindMatchingComposition(func(context.Context, *un.Unstructured) (dtypes.ResolvedComposition, error) { + return dtypes.ResolvedComposition{}, errors.New("composition not found") + }) +} + +// WithListCompositions sets the ListCompositions behavior. +func (b *MockCompositionClientBuilder) WithListCompositions(fn func(context.Context) ([]*xpextv1.Composition, error)) *MockCompositionClientBuilder { + b.mock.ListCompositionsFn = fn + return b +} + +// WithGetComposition sets the GetComposition behavior. +func (b *MockCompositionClientBuilder) WithGetComposition(fn func(context.Context, string) (*xpextv1.Composition, error)) *MockCompositionClientBuilder { + b.mock.GetCompositionFn = fn + return b +} + +// WithSuccessfulCompositionFetch sets up a successful composition fetch. +func (b *MockCompositionClientBuilder) WithSuccessfulCompositionFetch(comp *xpextv1.Composition) *MockCompositionClientBuilder { + return b.WithGetComposition(func(_ context.Context, name string) (*xpextv1.Composition, error) { + if name == comp.GetName() { + return comp, nil + } + + return nil, errors.New("composition not found") + }) +} + +// WithSuccessfulCompositionFetches sets up successful composition fetches for multiple compositions. +func (b *MockCompositionClientBuilder) WithSuccessfulCompositionFetches(comps []*xpextv1.Composition) *MockCompositionClientBuilder { + compositionMap := make(map[string]*xpextv1.Composition) + for _, comp := range comps { + compositionMap[comp.GetName()] = comp + } + + return b.WithGetComposition(func(_ context.Context, name string) (*xpextv1.Composition, error) { + if composition, found := compositionMap[name]; found { + return composition, nil + } + + return nil, errors.New("composition not found") + }) +} + +// WithFindComposites sets the FindComposites behavior. +func (b *MockCompositionClientBuilder) WithFindComposites(fn func(context.Context, *un.Unstructured, dtypes.FindCompositesOptions) ([]*un.Unstructured, error)) *MockCompositionClientBuilder { + b.mock.FindCompositesFn = fn + return b +} + +// WithResourcesForComposition sets FindComposites (default-discovery mode) to return specific resources +// for a given composition name and namespace. Refs-mode calls return an explicit error identifying this +// helper as default-discovery only — use WithFindComposites directly if you need to mock both modes. +func (b *MockCompositionClientBuilder) WithResourcesForComposition(compositionName, namespace string, resources []*un.Unstructured) *MockCompositionClientBuilder { + if b.composites == nil { + b.composites = map[string][]*un.Unstructured{} + } + + b.composites[compositionName+"/"+namespace] = resources + + // The closure reads the map at call time, so registrations added after this one are visible too. + return b.WithFindComposites(func(_ context.Context, comp *un.Unstructured, opts dtypes.FindCompositesOptions) ([]*un.Unstructured, error) { + if len(opts.Refs) > 0 { + return nil, errors.New("WithResourcesForComposition only handles default-discovery (empty Refs)") + } + + if resources, ok := b.composites[comp.GetName()+"/"+opts.Namespace]; ok { + return resources, nil + } + + return nil, errors.Errorf("no resources found for composition %s in namespace %s", comp.GetName(), opts.Namespace) + }) +} + +// WithFindResourcesError sets FindComposites (default-discovery mode) to return an error. Refs-mode calls +// return an explicit error identifying this helper as default-discovery only — use WithFindComposites +// directly if you need to mock both modes. +func (b *MockCompositionClientBuilder) WithFindResourcesError(errMsg string) *MockCompositionClientBuilder { + return b.WithFindComposites(func(_ context.Context, _ *un.Unstructured, opts dtypes.FindCompositesOptions) ([]*un.Unstructured, error) { + if len(opts.Refs) > 0 { + return nil, errors.New("WithFindResourcesError only handles default-discovery (empty Refs)") + } + + return nil, errors.New(errMsg) + }) +} + +// WithCompositesByRef sets FindComposites (refs mode) to return the given resources for any +// non-empty-Refs request. Default-discovery calls return an explicit error identifying this helper +// as refs-only — use WithFindComposites directly if you need to mock both modes. Mirror of +// WithResourcesForComposition for the refs-mode side. +func (b *MockCompositionClientBuilder) WithCompositesByRef(matched ...*un.Unstructured) *MockCompositionClientBuilder { + return b.WithFindComposites(func(_ context.Context, _ *un.Unstructured, opts dtypes.FindCompositesOptions) ([]*un.Unstructured, error) { + if len(opts.Refs) == 0 { + return nil, errors.New("WithCompositesByRef only handles refs-mode (non-empty Refs)") + } + + return matched, nil + }) +} + +// WithNoMatchingComposites sets FindComposites to return an empty matched set regardless of mode +// (default-discovery or refs). Use for "nothing matches anywhere" tests where neither default nor +// refs lookup should produce results. Mirrors WithNoMatchingComposition for the FindMatchingComposition +// method. +func (b *MockCompositionClientBuilder) WithNoMatchingComposites() *MockCompositionClientBuilder { + return b.WithFindComposites(func(context.Context, *un.Unstructured, dtypes.FindCompositesOptions) ([]*un.Unstructured, error) { + return nil, nil + }) +} + +// WithComposition is an alias for WithSuccessfulCompositionMatch for convenience. +func (b *MockCompositionClientBuilder) WithComposition(comp *xpextv1.Composition) *MockCompositionClientBuilder { + return b.WithSuccessfulCompositionMatch(comp) +} + +// Build returns the built mock. +func (b *MockCompositionClientBuilder) Build() *MockCompositionClient { + return b.mock +} + +// MockFunctionClientBuilder helps build crossplane.FunctionClient mocks. +type MockFunctionClientBuilder struct { + mock *MockFunctionClient +} + +// NewMockFunctionClient creates a new MockFunctionClientBuilder. +func NewMockFunctionClient() *MockFunctionClientBuilder { + return &MockFunctionClientBuilder{ + mock: &MockFunctionClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockFunctionClientBuilder) WithInitialize(fn func(context.Context) error) *MockFunctionClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize mocks a successful call to Initialize. +func (b *MockFunctionClientBuilder) WithSuccessfulInitialize() *MockFunctionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return nil + } + + return b +} + +// WithFailedInitialize mocks a failed call to Initialize. +func (b *MockFunctionClientBuilder) WithFailedInitialize(errMsg string) *MockFunctionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return errors.New(errMsg) + } + + return b +} + +// WithGetFunctionsFromPipeline sets the GetFunctionsFromPipeline behavior. +func (b *MockFunctionClientBuilder) WithGetFunctionsFromPipeline(fn func(*xpextv1.Composition) ([]pkgv1.Function, error)) *MockFunctionClientBuilder { + b.mock.GetFunctionsFromPipelineFn = fn + return b +} + +// WithSuccessfulFunctionsFetch sets GetFunctionsFromPipeline to return specific functions. +func (b *MockFunctionClientBuilder) WithSuccessfulFunctionsFetch(functions []pkgv1.Function) *MockFunctionClientBuilder { + return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return functions, nil + }) +} + +// WithNoFunctions sets GetFunctionsFromPipeline to return an empty function list. +func (b *MockFunctionClientBuilder) WithNoFunctions() *MockFunctionClientBuilder { + return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return []pkgv1.Function{}, nil + }) +} + +// WithFailedFunctionsFetch sets GetFunctionsFromPipeline to return an error. +func (b *MockFunctionClientBuilder) WithFailedFunctionsFetch(errMsg string) *MockFunctionClientBuilder { + return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return nil, errors.New(errMsg) + }) +} + +// WithListFunctions sets the ListFunctions behavior. +func (b *MockFunctionClientBuilder) WithListFunctions(fn func(context.Context) ([]pkgv1.Function, error)) *MockFunctionClientBuilder { + b.mock.ListFunctionsFn = fn + return b +} + +// WithFunctionsFetchCallback sets a callback that's called when GetFunctionsFromPipeline is invoked. +// This is useful for testing caching behavior by tracking how many times functions are fetched. +func (b *MockFunctionClientBuilder) WithFunctionsFetchCallback(callback func() ([]pkgv1.Function, error)) *MockFunctionClientBuilder { + return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return callback() + }) +} + +// Build returns the built mock. +func (b *MockFunctionClientBuilder) Build() *MockFunctionClient { + return b.mock +} + +// MockEnvironmentClientBuilder helps build crossplane.EnvironmentClient mocks. +type MockEnvironmentClientBuilder struct { + mock *MockEnvironmentClient +} + +// NewMockEnvironmentClient creates a new MockEnvironmentClientBuilder. +func NewMockEnvironmentClient() *MockEnvironmentClientBuilder { + return &MockEnvironmentClientBuilder{ + mock: &MockEnvironmentClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockEnvironmentClientBuilder) WithInitialize(fn func(context.Context) error) *MockEnvironmentClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize mocks a successful call to Initialize. +func (b *MockEnvironmentClientBuilder) WithSuccessfulInitialize() *MockEnvironmentClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return nil + } + + return b +} + +// WithFailedInitialize mocks a failed call to Initialize. +func (b *MockEnvironmentClientBuilder) WithFailedInitialize(errMsg string) *MockEnvironmentClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return errors.New(errMsg) + } + + return b +} + +// WithGetEnvironmentConfigs sets the GetEnvironmentConfigs behavior. +func (b *MockEnvironmentClientBuilder) WithGetEnvironmentConfigs(fn func(context.Context) ([]*un.Unstructured, error)) *MockEnvironmentClientBuilder { + b.mock.GetEnvironmentConfigsFn = fn + return b +} + +// WithSuccessfulEnvironmentConfigsFetch sets GetEnvironmentConfigs to return specific configs. +func (b *MockEnvironmentClientBuilder) WithSuccessfulEnvironmentConfigsFetch(configs []*un.Unstructured) *MockEnvironmentClientBuilder { + return b.WithGetEnvironmentConfigs(func(context.Context) ([]*un.Unstructured, error) { + return configs, nil + }) +} + +// WithNoEnvironmentConfigs sets GetEnvironmentConfigs to return an empty list. +func (b *MockEnvironmentClientBuilder) WithNoEnvironmentConfigs() *MockEnvironmentClientBuilder { + return b.WithGetEnvironmentConfigs(func(context.Context) ([]*un.Unstructured, error) { + return []*un.Unstructured{}, nil + }) +} + +// WithGetEnvironmentConfig sets the GetEnvironmentConfig behavior. +func (b *MockEnvironmentClientBuilder) WithGetEnvironmentConfig(fn func(context.Context, string) (*un.Unstructured, error)) *MockEnvironmentClientBuilder { + b.mock.GetEnvironmentConfigFn = fn + return b +} + +// Build returns the built mock. +func (b *MockEnvironmentClientBuilder) Build() *MockEnvironmentClient { + return b.mock +} + +// MockDefinitionClientBuilder helps build crossplane.DefinitionClient mocks. +type MockDefinitionClientBuilder struct { + mock *MockDefinitionClient + xrdMap map[schema.GroupVersionKind]*un.Unstructured +} + +// NewMockDefinitionClient creates a new MockDefinitionClientBuilder. +func NewMockDefinitionClient() *MockDefinitionClientBuilder { + return &MockDefinitionClientBuilder{ + mock: &MockDefinitionClient{}, + xrdMap: make(map[schema.GroupVersionKind]*un.Unstructured), + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockDefinitionClientBuilder) WithInitialize(fn func(context.Context) error) *MockDefinitionClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize mocks a successful call to Initialize. +func (b *MockDefinitionClientBuilder) WithSuccessfulInitialize() *MockDefinitionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return nil + } + + return b +} + +// WithFailedInitialize mocks a failed call to Initialize. +func (b *MockDefinitionClientBuilder) WithFailedInitialize(errMsg string) *MockDefinitionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return errors.New(errMsg) + } + + return b +} + +// WithGetXRDs sets the GetXRDs behavior. +func (b *MockDefinitionClientBuilder) WithGetXRDs(fn func(context.Context) ([]*un.Unstructured, error)) *MockDefinitionClientBuilder { + b.mock.GetXRDsFn = fn + return b +} + +// WithSuccessfulXRDsFetch sets GetXRDs to return specific XRDs. +func (b *MockDefinitionClientBuilder) WithSuccessfulXRDsFetch(xrds []*un.Unstructured) *MockDefinitionClientBuilder { + return b.WithGetXRDs(func(context.Context) ([]*un.Unstructured, error) { + return xrds, nil + }) +} + +// WithEmptyXRDsFetch sets GetXRDs to return an empty set of XRDs. +func (b *MockDefinitionClientBuilder) WithEmptyXRDsFetch() *MockDefinitionClientBuilder { + return b.WithGetXRDs(func(context.Context) ([]*un.Unstructured, error) { + return []*un.Unstructured{}, nil + }) +} + +// WithFailedXRDsFetch sets GetXRDs to return an error. +func (b *MockDefinitionClientBuilder) WithFailedXRDsFetch(errMsg string) *MockDefinitionClientBuilder { + return b.WithGetXRDs(func(context.Context) ([]*un.Unstructured, error) { + return nil, errors.New(errMsg) + }) +} + +// WithGetXRDForClaim sets the GetXRDForClaim behavior. +func (b *MockDefinitionClientBuilder) WithGetXRDForClaim(fn func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error)) *MockDefinitionClientBuilder { + b.mock.GetXRDForClaimFn = fn + return b +} + +// WithGetXRDForXR sets the GetXRDForXR behavior. +func (b *MockDefinitionClientBuilder) WithGetXRDForXR(fn func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error)) *MockDefinitionClientBuilder { + b.mock.GetXRDForXRFn = fn + return b +} + +// WithV1XRDForXR sets the GetXRDForXR behavior to return a v1 XRD. +func (b *MockDefinitionClientBuilder) WithV1XRDForXR() *MockDefinitionClientBuilder { + return b.WithGetXRDForXR(func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1", + }, + }, nil + }) +} + +// WithV2XRDForXR sets the GetXRDForXR behavior to return a v1 XRD. +func (b *MockDefinitionClientBuilder) WithV2XRDForXR() *MockDefinitionClientBuilder { + return b.WithGetXRDForXR(func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v2", + }, + }, nil + }) +} + +// WithXRDForXR sets the GetXRDForXR behavior to return the specified XR. +func (b *MockDefinitionClientBuilder) WithXRDForXR(unstructured *un.Unstructured) *MockDefinitionClientBuilder { + return b.WithGetXRDForXR(func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return unstructured, nil + }) +} + +// WithXRDForGVK adds an XRD to be returned for a specific GVK. +// This method can be called multiple times to add different XRDs for different GVKs. +// When Build() is called, GetXRDForXR will use the accumulated XRD mappings. +func (b *MockDefinitionClientBuilder) WithXRDForGVK(gvk schema.GroupVersionKind, xrd *un.Unstructured) *MockDefinitionClientBuilder { + b.xrdMap[gvk] = xrd + return b +} + +// WithXRDForClaim sets the GetXRDForXR behavior to return the specified XR. +func (b *MockDefinitionClientBuilder) WithXRDForClaim(unstructured *un.Unstructured) *MockDefinitionClientBuilder { + return b.WithGetXRDForClaim(func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return unstructured, nil + }) +} + +// WithIsClaimResource sets the IsClaimResource behavior. +func (b *MockDefinitionClientBuilder) WithIsClaimResource(fn func(context.Context, *un.Unstructured) bool) *MockDefinitionClientBuilder { + b.mock.IsClaimResourceFn = fn + return b +} + +// WithXRD sets the GetXRDForXR behavior to return the specified XRD for any GVK matching its group/kind. +func (b *MockDefinitionClientBuilder) WithXRD(xrd *xpextv1.CompositeResourceDefinition) *MockDefinitionClientBuilder { + // Convert the typed XRD to unstructured + xrdUnstructured, err := runtime.DefaultUnstructuredConverter.ToUnstructured(xrd) + if err != nil { + // This should never happen in tests, but if it does, we'll panic to make it obvious + panic(fmt.Sprintf("failed to convert XRD to unstructured: %v", err)) + } + + xrdObj := &un.Unstructured{Object: xrdUnstructured} + + return b.WithGetXRDForXR(func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + // Check if the GVK matches this XRD + xrdGroup, _, _ := un.NestedString(xrdObj.Object, fieldSpec, "group") + xrdKind, _, _ := un.NestedString(xrdObj.Object, fieldSpec, "names", "kind") + + if xrdGroup == gvk.Group && xrdKind == gvk.Kind { + return xrdObj, nil + } + + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + }) +} + +// WithXRDForXRNotFound sets GetXRDForXR to return not found error for any GVK. +func (b *MockDefinitionClientBuilder) WithXRDForXRNotFound() *MockDefinitionClientBuilder { + return b.WithGetXRDForXR(func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + }) +} + +// WithXRDForXRNotFoundForGVK sets GetXRDForXR to return not found for a specific GVK. +func (b *MockDefinitionClientBuilder) WithXRDForXRNotFoundForGVK(notFoundGVK schema.GroupVersionKind) *MockDefinitionClientBuilder { + existingFn := b.mock.GetXRDForXRFn + + return b.WithGetXRDForXR(func(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if gvk.Group == notFoundGVK.Group && gvk.Kind == notFoundGVK.Kind { + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + } + + // Fall through to existing function if set + if existingFn != nil { + return existingFn(ctx, gvk) + } + + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + }) +} + +// WithXRDForXRError sets GetXRDForXR to return the specified error. +func (b *MockDefinitionClientBuilder) WithXRDForXRError(err error) *MockDefinitionClientBuilder { + return b.WithGetXRDForXR(func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return nil, err + }) +} + +// Build returns the built mock. +// If WithXRDForGVK was used to add XRD mappings, this will configure GetXRDForXR +// to use those mappings. +func (b *MockDefinitionClientBuilder) Build() *MockDefinitionClient { + // If there are XRDs in the map and GetXRDForXR hasn't been explicitly set, + // configure it to use the map + if len(b.xrdMap) > 0 && b.mock.GetXRDForXRFn == nil { + // Capture the map in the closure + xrdMap := b.xrdMap + b.mock.GetXRDForXRFn = func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if xrd, found := xrdMap[gvk]; found { + return xrd, nil + } + + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + } + } + + return b.mock +} + +// MockResourceTreeClientBuilder helps build crossplane.ResourceTreeClient mocks. +type MockResourceTreeClientBuilder struct { + mock *MockResourceTreeClient +} + +// NewMockResourceTreeClient creates a new MockResourceTreeClientBuilder. +func NewMockResourceTreeClient() *MockResourceTreeClientBuilder { + return &MockResourceTreeClientBuilder{ + mock: &MockResourceTreeClient{}, + } +} + +// WithInitialize sets the Initialize behavior. +func (b *MockResourceTreeClientBuilder) WithInitialize(fn func(context.Context) error) *MockResourceTreeClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize mocks a successful call to Initialize. +func (b *MockResourceTreeClientBuilder) WithSuccessfulInitialize() *MockResourceTreeClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return nil + } + + return b +} + +// WithFailedInitialize mocks a failed call to Initialize. +func (b *MockResourceTreeClientBuilder) WithFailedInitialize(errMsg string) *MockResourceTreeClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return errors.New(errMsg) + } + + return b +} + +// WithGetResourceTree sets the GetResourceTree behavior. +func (b *MockResourceTreeClientBuilder) WithGetResourceTree(fn func(context.Context, *un.Unstructured) (*resource.Resource, error)) *MockResourceTreeClientBuilder { + b.mock.GetResourceTreeFn = fn + return b +} + +// WithSuccessfulResourceTreeFetch sets GetResourceTree to return a specific tree. +func (b *MockResourceTreeClientBuilder) WithSuccessfulResourceTreeFetch(resourceTree *resource.Resource) *MockResourceTreeClientBuilder { + return b.WithGetResourceTree(func(context.Context, *un.Unstructured) (*resource.Resource, error) { + return resourceTree, nil + }) +} + +// WithEmptyResourceTree sets GetResourceTree to return just the root with no children. +func (b *MockResourceTreeClientBuilder) WithEmptyResourceTree() *MockResourceTreeClientBuilder { + return b.WithGetResourceTree(func(_ context.Context, root *un.Unstructured) (*resource.Resource, error) { + return NewTreeNode(root).Build(), nil + }) +} + +// WithFailedResourceTreeFetch sets GetResourceTree to return an error. +func (b *MockResourceTreeClientBuilder) WithFailedResourceTreeFetch(errMsg string) *MockResourceTreeClientBuilder { + return b.WithGetResourceTree(func(context.Context, *un.Unstructured) (*resource.Resource, error) { + return nil, errors.New(errMsg) + }) +} + +// WithResourceTreeFromXRAndComposed creates a basic resource tree from an XR and composed resources. +func (b *MockResourceTreeClientBuilder) WithResourceTreeFromXRAndComposed(xr *un.Unstructured, composed []*un.Unstructured) *MockResourceTreeClientBuilder { + return b.WithGetResourceTree(func(_ context.Context, root *un.Unstructured) (*resource.Resource, error) { + // Make sure we're looking for the right XR + if root.GetName() != xr.GetName() || root.GetKind() != xr.GetKind() { + return nil, errors.Errorf("unexpected resource %s/%s", root.GetKind(), root.GetName()) + } + + // Create the resource tree with the XR as root and the composed resources as its children + children := make([]*TreeNodeBuilder, 0, len(composed)) + for _, comp := range composed { + children = append(children, NewTreeNode(comp)) + } + + return NewTreeNode(xr).WithChildren(children...).Build(), nil + }) +} + +// Build returns the built mock. +func (b *MockResourceTreeClientBuilder) Build() *MockResourceTreeClient { + return b.mock +} + +// TreeNodeBuilder builds a resource-tree node, the shape a ResourceTreeClient returns. +type TreeNodeBuilder struct { + node *resource.Resource +} + +// NewTreeNode starts a tree node holding a deep copy of obj, so the caller's object is never shared +// with, or mutated through, the tree. +func NewTreeNode(obj *un.Unstructured) *TreeNodeBuilder { + return &TreeNodeBuilder{node: &resource.Resource{Unstructured: *obj.DeepCopy()}} +} + +// WithChildren appends the given nodes as children of this one. +func (b *TreeNodeBuilder) WithChildren(children ...*TreeNodeBuilder) *TreeNodeBuilder { + for _, c := range children { + b.node.Children = append(b.node.Children, c.Build()) + } + + return b +} + +// WithError records err on the node, as upstream's tree client does for a node it could not fetch. +func (b *TreeNodeBuilder) WithError(err error) *TreeNodeBuilder { + b.node.Error = err + return b +} + +// Build returns the built node. +func (b *TreeNodeBuilder) Build() *resource.Resource { + return b.node +} + +// endregion + +// region DiffProcessor mock builder + +// ====================================================================================== +// DiffProcessor Mock Builder +// ====================================================================================== + +// DiffProcessorBuilder helps build mock DiffProcessor instances. +type DiffProcessorBuilder struct { + mock *MockDiffProcessor +} + +// NewMockDiffProcessor creates a new DiffProcessorBuilder. +func NewMockDiffProcessor() *DiffProcessorBuilder { + return &DiffProcessorBuilder{ + mock: &MockDiffProcessor{}, + } +} + +// WithInitialize adds an implementation for the Initialize method. +func (b *DiffProcessorBuilder) WithInitialize(fn func(context.Context) error) *DiffProcessorBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize sets a successful Initialize implementation. +func (b *DiffProcessorBuilder) WithSuccessfulInitialize() *DiffProcessorBuilder { + return b.WithInitialize(func(context.Context) error { + return nil + }) +} + +// WithFailedInitialize sets a failing Initialize implementation. +func (b *DiffProcessorBuilder) WithFailedInitialize(errMsg string) *DiffProcessorBuilder { + return b.WithInitialize(func(context.Context) error { + return errors.New(errMsg) + }) +} + +// WithPerformDiff adds an implementation for the PerformDiff method. +func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error)) *DiffProcessorBuilder { + b.mock.PerformDiffFn = fn + return b +} + +// WithSuccessfulPerformDiff sets a successful PerformDiff implementation with no diffs. +func (b *DiffProcessorBuilder) WithSuccessfulPerformDiff() *DiffProcessorBuilder { + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { + return false, nil + }) +} + +// WithSuccessfulPerformDiffWithChanges sets a successful PerformDiff implementation with diffs. +func (b *DiffProcessorBuilder) WithSuccessfulPerformDiffWithChanges() *DiffProcessorBuilder { + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { + return true, nil + }) +} + +// WithFailedPerformDiff sets a failing PerformDiff implementation. +func (b *DiffProcessorBuilder) WithFailedPerformDiff(errMsg string) *DiffProcessorBuilder { + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { + return false, errors.New(errMsg) + }) +} + +// Build creates and returns the configured mock DiffProcessor. +func (b *DiffProcessorBuilder) Build() *MockDiffProcessor { + return b.mock +} + +// endregion + +// region Resource builders + +// ====================================================================================== +// Resource Building Helpers +// ====================================================================================== + +// ResourceBuilder helps construct unstructured resources for testing. +type ResourceBuilder struct { + resource *un.Unstructured +} + +// NewResource creates a new ResourceBuilder. +func NewResource(apiVersion, kind, name string) *ResourceBuilder { + return &ResourceBuilder{ + resource: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]any{ + "name": name, + }, + }, + }, + } +} + +// InNamespace sets the namespace for the resource. +func (b *ResourceBuilder) InNamespace(namespace string) *ResourceBuilder { + if namespace != "" { + b.resource.SetNamespace(namespace) + } + + return b +} + +// WithGenerateName sets the namespace for the resource. +func (b *ResourceBuilder) WithGenerateName(generateName string) *ResourceBuilder { + if generateName != "" { + b.resource.SetGenerateName(generateName) + } + + return b +} + +// WithLabels adds labels to the resource. +func (b *ResourceBuilder) WithLabels(labels map[string]string) *ResourceBuilder { + if len(labels) > 0 { + b.resource.SetLabels(labels) + } + + return b +} + +// WithAnnotations adds annotations to the resource. +func (b *ResourceBuilder) WithAnnotations(annotations map[string]string) *ResourceBuilder { + if len(annotations) > 0 { + b.resource.SetAnnotations(annotations) + } + + return b +} + +// WithSpec sets the spec field of the resource. +func (b *ResourceBuilder) WithSpec(spec map[string]any) *ResourceBuilder { + if len(spec) > 0 { + _ = un.SetNestedMap(b.resource.Object, spec, fieldSpec) + } + + return b +} + +// WithSpecField sets a specific field in the spec. +func (b *ResourceBuilder) WithSpecField(name string, value any) *ResourceBuilder { + spec, _, _ := un.NestedMap(b.resource.Object, fieldSpec) + if spec == nil { + spec = map[string]any{} + } + + spec[name] = value + _ = un.SetNestedMap(b.resource.Object, spec, fieldSpec) + + return b +} + +// WithStatus sets the status field of the resource. +func (b *ResourceBuilder) WithStatus(status map[string]any) *ResourceBuilder { + if len(status) > 0 { + _ = un.SetNestedMap(b.resource.Object, status, "status") + } + + return b +} + +// WithStatusField sets a specific field in the status. +func (b *ResourceBuilder) WithStatusField(name string, value any) *ResourceBuilder { + status, _, _ := un.NestedMap(b.resource.Object, "status") + if status == nil { + status = map[string]any{} + } + + status[name] = value + _ = un.SetNestedMap(b.resource.Object, status, "status") + + return b +} + +// WithCondition appends a status condition of the given type and status (e.g. "Ready", "False") +// to status.conditions, with the given reason. +func (b *ResourceBuilder) WithCondition(condType, status, reason string) *ResourceBuilder { + conditions, _, _ := un.NestedSlice(b.resource.Object, "status", "conditions") + conditions = append(conditions, map[string]any{ + "type": condType, + "status": status, + "reason": reason, + "lastTransitionTime": "2024-01-01T00:00:00Z", + }) + _ = un.SetNestedSlice(b.resource.Object, conditions, "status", "conditions") + + return b +} + +// WithData sets the data field for Secret resources. +// The byte slices are base64-encoded as they would be in Kubernetes. +func (b *ResourceBuilder) WithData(data map[string][]byte) *ResourceBuilder { + if data == nil { + return b + } + + encoded := make(map[string]any, len(data)) + for k, v := range data { + encoded[k] = base64.StdEncoding.EncodeToString(v) + } + + b.resource.Object["data"] = encoded + + return b +} + +// WithOwnerReference appends an owner ref to a resource. +func (b *ResourceBuilder) WithOwnerReference(kind, name, apiVersion, uid string) *ResourceBuilder { + // Get existing owner references, or create an empty slice if none exist + ownerRefs := b.resource.GetOwnerReferences() + + // Create the new owner reference + newOwnerRef := metav1.OwnerReference{ + APIVersion: apiVersion, + Kind: kind, + Name: name, + UID: k8stypes.UID(uid), + } + + // Append the new owner reference + ownerRefs = append(ownerRefs, newOwnerRef) + + // Set the updated owner references on the resource + b.resource.SetOwnerReferences(ownerRefs) + + return b +} + +// WithControllerReference adds a controller owner reference (Controller=true, +// BlockOwnerDeletion=true) to the resource. This mirrors how Crossplane sets +// the controlling XR on a composed resource, so tests that exercise +// controller-ref-based logic (e.g. observed-resource scoping in +// FetchObservedResources) can build realistic fixtures. +func (b *ResourceBuilder) WithControllerReference(kind, name, apiVersion, uid string) *ResourceBuilder { + ownerRefs := b.resource.GetOwnerReferences() + + controller := true + newOwnerRef := metav1.OwnerReference{ + APIVersion: apiVersion, + Kind: kind, + Name: name, + UID: k8stypes.UID(uid), + Controller: &controller, + BlockOwnerDeletion: &controller, + } + + ownerRefs = append(ownerRefs, newOwnerRef) + b.resource.SetOwnerReferences(ownerRefs) + + return b +} + +// ControlledBy adds a controller owner reference to owner, by its apiVersion, kind, name and UID: what +// Crossplane sets on every resource a composite composes. +func (b *ResourceBuilder) ControlledBy(owner *un.Unstructured) *ResourceBuilder { + return b.WithControllerReference(owner.GetKind(), owner.GetName(), owner.GetAPIVersion(), string(owner.GetUID())) +} + +// WithUID sets the resource's metadata.uid. +func (b *ResourceBuilder) WithUID(uid string) *ResourceBuilder { + b.resource.SetUID(k8stypes.UID(uid)) + return b +} + +// WithDeletionTimestamp sets metadata.deletionTimestamp to the supplied raw value, marking the +// resource as being deleted. The value is set verbatim, and typed any rather than string, so tests can +// pass an explicit nil — which is how round-tripped Kubernetes YAML spells "unset" for timestamp +// fields, and therefore a case worth asserting is not mistaken for a deleting resource. +func (b *ResourceBuilder) WithDeletionTimestamp(timestamp any) *ResourceBuilder { + un.SetNestedField(b.resource.Object, timestamp, "metadata", "deletionTimestamp") //nolint:errcheck // test builder; a bad value is the point of some cases. + return b +} + +// WithCompositeOwner sets up the resource as a cpd resource with the given composite owner. +func (b *ResourceBuilder) WithCompositeOwner(owner string) *ResourceBuilder { + // Add standard Crossplane labels and annotations for a cpd resource + labels := b.resource.GetLabels() + if labels == nil { + labels = map[string]string{} + } + + labels["crossplane.io/composite"] = owner + b.resource.SetLabels(labels) + + return b +} + +// WithCompositionResourceName sets the composition resource name annotation. +func (b *ResourceBuilder) WithCompositionResourceName(name string) *ResourceBuilder { + annotations := b.resource.GetAnnotations() + if annotations == nil { + annotations = map[string]string{} + } + + annotations["crossplane.io/composition-resource-name"] = name + b.resource.SetAnnotations(annotations) + + return b +} + +// WithNamespace sets the namespace for the resource. +func (b *ResourceBuilder) WithNamespace(namespace string) *ResourceBuilder { + b.resource.SetNamespace(namespace) + return b +} + +// WithNestedField sets a field at an arbitrary nested path in the resource. +// The path is a sequence of field names to traverse (e.g., "spec", "crossplane", "compositionUpdatePolicy"). +func (b *ResourceBuilder) WithNestedField(value any, fields ...string) *ResourceBuilder { + _ = un.SetNestedField(b.resource.Object, value, fields...) + return b +} + +// WithCompositionRevisionSelector sets spec[.crossplane].compositionRevisionSelector on the +// resource. Pass CrossplaneAPIExtGroupV2 ("apiextensions.crossplane.io/v2") to use the v2 path +// (spec.crossplane.compositionRevisionSelector) or CrossplaneAPIExtGroupV1 for the legacy v1 path +// (spec.compositionRevisionSelector). matchLabels and matchExpressions are omitted when nil, so a +// selector with only expressions (or only labels) can be built. matchExpressions entries use the +// standard LabelSelectorRequirement shape: {"key": string, "operator": string, "values": []any}. +func (b *ResourceBuilder) WithCompositionRevisionSelector(apiGroup string, matchLabels map[string]string, matchExpressions []map[string]any) *ResourceBuilder { + selector := map[string]any{} + + if matchLabels != nil { + ml := make(map[string]any, len(matchLabels)) + for k, v := range matchLabels { + ml[k] = v + } + + selector["matchLabels"] = ml + } + + if matchExpressions != nil { + exprs := make([]any, len(matchExpressions)) + for i, e := range matchExpressions { + exprs[i] = e + } + + selector["matchExpressions"] = exprs + } + + // v2 XRs nest crossplane spec fields under spec.crossplane; v1 keeps them under spec. + path := []string{fieldSpec, "compositionRevisionSelector"} + if apiGroup != "apiextensions.crossplane.io/v1" { + path = []string{fieldSpec, "crossplane", "compositionRevisionSelector"} + } + + _ = un.SetNestedField(b.resource.Object, selector, path...) + + return b +} + +// WithFieldManagers sets the managed fields on the resource using the provided manager names. +func (b *ResourceBuilder) WithFieldManagers(managers ...string) *ResourceBuilder { + entries := make([]metav1.ManagedFieldsEntry, len(managers)) + for i, manager := range managers { + entries[i] = metav1.ManagedFieldsEntry{Manager: manager} + } + + b.resource.SetManagedFields(entries) + + return b +} + +// WithServerMetadata stamps the server-populated identity and versioning +// metadata fields (resourceVersion, uid, generation, creationTimestamp) that +// Kubernetes adds to a persisted object. Values are deterministic placeholders +// so tests remain stable. Use this to construct a resource that looks like it +// was fetched from the cluster (e.g. to verify diff cleanup strips these). +func (b *ResourceBuilder) WithServerMetadata() *ResourceBuilder { + b.resource.SetResourceVersion("12345") + b.resource.SetUID(k8stypes.UID("00000000-0000-0000-0000-000000000000")) + b.resource.SetGeneration(1) + b.resource.SetCreationTimestamp(metav1.NewTime(time.Unix(0, 0).UTC())) + + return b +} + +// Build returns the built unstructured resource. +func (b *ResourceBuilder) Build() *un.Unstructured { + return b.resource.DeepCopy() +} + +// BuildUComposite returns the built unstructured resource as a *cmp.Unstructured. +func (b *ResourceBuilder) BuildUComposite() *cmp.Unstructured { + built := &cmp.Unstructured{} + built.SetUnstructuredContent(b.Build().UnstructuredContent()) + + return built +} + +// BuildUComposed returns the built unstructured resource as a *cpd.Unstructured. +func (b *ResourceBuilder) BuildUComposed() *cpd.Unstructured { + built := &cpd.Unstructured{} + built.SetUnstructuredContent(b.Build().UnstructuredContent()) + + return built +} + +// BuildTyped converts the built unstructured resource into a typed Kubernetes object. +// The 'into' parameter should be a pointer to the target type (e.g., &corev1.Secret{}). +func (b *ResourceBuilder) BuildTyped(into runtime.Object) { + _ = runtime.DefaultUnstructuredConverter.FromUnstructured(b.Build().Object, into) +} + +// endregion + +// region CRD builders + +// ====================================================================================== +// CRD Building Helpers +// ====================================================================================== + +// CRDBuilder helps construct CRD resources for testing. +type CRDBuilder struct { + crd *extv1.CustomResourceDefinition +} + +// NewCRD creates a new CRDBuilder. +func NewCRD(name, group, kind string) *CRDBuilder { + return &CRDBuilder{ + crd: &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: "CustomResourceDefinition", + APIVersion: "apiextensions.k8s.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: group, + Names: extv1.CustomResourceDefinitionNames{ + Kind: kind, + }, + Scope: extv1.NamespaceScoped, + Versions: []extv1.CustomResourceDefinitionVersion{}, + }, + }, + } +} + +// WithPlural sets the plural name for the CRD. +func (b *CRDBuilder) WithPlural(plural string) *CRDBuilder { + b.crd.Spec.Names.Plural = plural + return b +} + +// WithSingular sets the singular name for the CRD. +func (b *CRDBuilder) WithSingular(singular string) *CRDBuilder { + b.crd.Spec.Names.Singular = singular + return b +} + +// WithScope sets the scope (Namespaced or Cluster) for the CRD. +func (b *CRDBuilder) WithScope(scope extv1.ResourceScope) *CRDBuilder { + b.crd.Spec.Scope = scope + return b +} + +// WithClusterScope sets the CRD to be cluster-scoped. +func (b *CRDBuilder) WithClusterScope() *CRDBuilder { + return b.WithScope(extv1.ClusterScoped) +} + +// WithNamespaceScope sets the CRD to be namespace-scoped. +func (b *CRDBuilder) WithNamespaceScope() *CRDBuilder { + return b.WithScope(extv1.NamespaceScoped) +} + +// WithDefaultVersion adds a default "v1" version to the CRD. +func (b *CRDBuilder) WithDefaultVersion() *CRDBuilder { + return b.WithVersion("v1", true, true) +} + +// WithVersion adds a version to the CRD. +func (b *CRDBuilder) WithVersion(name string, served, storage bool) *CRDBuilder { + version := extv1.CustomResourceDefinitionVersion{ + Name: name, + Served: served, + Storage: storage, + } + b.crd.Spec.Versions = append(b.crd.Spec.Versions, version) + + return b +} + +// WithSchema adds an OpenAPI v3 schema to the last added version. +func (b *CRDBuilder) WithSchema(schema *extv1.JSONSchemaProps) *CRDBuilder { + if len(b.crd.Spec.Versions) > 0 { + lastIdx := len(b.crd.Spec.Versions) - 1 + b.crd.Spec.Versions[lastIdx].Schema = &extv1.CustomResourceValidation{ + OpenAPIV3Schema: schema, + } + } + + return b +} + +// WithStringFieldSchema adds a simple string field schema to the CRD. +func (b *CRDBuilder) WithStringFieldSchema(fieldName string) *CRDBuilder { + schema := &extv1.JSONSchemaProps{ + Type: jsonTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: jsonTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + fieldName: { + Type: jsonTypeString, + }, + }, + }, + "status": { + Type: jsonTypeObject, + }, + }, + } + + return b.WithSchema(schema) +} + +// WithDefaultedStringFieldSchema adds a schema whose spec has a single string field carrying a +// `default:` value, for exercising CRD defaulting. +func (b *CRDBuilder) WithDefaultedStringFieldSchema(fieldName, defaultValue string) *CRDBuilder { + schema := &extv1.JSONSchemaProps{ + Type: jsonTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: jsonTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + fieldName: { + Type: jsonTypeString, + Default: &extv1.JSON{Raw: []byte(strconv.Quote(defaultValue))}, + }, + }, + }, + }, + } + + return b.WithSchema(schema) +} + +// WithStandardSchema adds a standard schema with common Kubernetes fields and a spec field. +func (b *CRDBuilder) WithStandardSchema(specFieldName string) *CRDBuilder { + schema := &extv1.JSONSchemaProps{ + Type: jsonTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": {Type: jsonTypeString}, + "kind": {Type: jsonTypeString}, + "metadata": {Type: jsonTypeObject}, + "spec": { + Type: jsonTypeObject, + Properties: map[string]extv1.JSONSchemaProps{ + specFieldName: {Type: jsonTypeString}, + }, + }, + "status": {Type: jsonTypeObject}, + }, + } + + return b.WithSchema(schema) +} + +// WithListKind sets the ListKind name for the CRD. +func (b *CRDBuilder) WithListKind(listKind string) *CRDBuilder { + b.crd.Spec.Names.ListKind = listKind + return b +} + +// Build returns the built CRD. +func (b *CRDBuilder) Build() *extv1.CustomResourceDefinition { + return b.crd.DeepCopy() +} + +// endregion + +// region XRD builders + +// ====================================================================================== +// XRD Building Helpers +// ====================================================================================== + +// XRDBuilder helps construct XRD resources for testing. +type XRDBuilder struct { + xrd *xpextv1.CompositeResourceDefinition +} + +// NewXRD creates a new XRDBuilder. +func NewXRD(name, group, kind string) *XRDBuilder { + return &XRDBuilder{ + xrd: &xpextv1.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: "CompositeResourceDefinition", + APIVersion: "apiextensions.crossplane.io/v1", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: xpextv1.CompositeResourceDefinitionSpec{ + Group: group, + Names: extv1.CustomResourceDefinitionNames{ + Kind: kind, + }, + Versions: []xpextv1.CompositeResourceDefinitionVersion{}, + }, + }, + } +} + +// WithPlural sets the plural name for the XRD. +func (b *XRDBuilder) WithPlural(plural string) *XRDBuilder { + b.xrd.Spec.Names.Plural = plural + return b +} + +// WithSingular sets the singular name for the XRD. +func (b *XRDBuilder) WithSingular(singular string) *XRDBuilder { + b.xrd.Spec.Names.Singular = singular + return b +} + +// WithClaimNames sets the claim names for the XRD. +func (b *XRDBuilder) WithClaimNames(kind, plural string) *XRDBuilder { + b.xrd.Spec.ClaimNames = &extv1.CustomResourceDefinitionNames{ + Kind: kind, + Plural: plural, + } + + return b +} + +// WithDefaultVersion adds a default "v1" version to the XRD. +func (b *XRDBuilder) WithDefaultVersion() *XRDBuilder { + return b.WithVersion("v1", true, true) +} + +// WithVersion adds a version to the XRD. +func (b *XRDBuilder) WithVersion(name string, served, referenceable bool) *XRDBuilder { + version := xpextv1.CompositeResourceDefinitionVersion{ + Name: name, + Served: served, + Referenceable: referenceable, + } + b.xrd.Spec.Versions = append(b.xrd.Spec.Versions, version) + + return b +} + +// WithSchema adds an OpenAPI v3 schema to the last added version. +func (b *XRDBuilder) WithSchema(schema *extv1.JSONSchemaProps) *XRDBuilder { + if len(b.xrd.Spec.Versions) > 0 { + // Convert JSONSchemaProps to RawExtension + rawBytes, err := json.Marshal(schema) + if err != nil { + // In tests, this should not happen, but if it does, we'll just skip the schema + return b + } + + lastIdx := len(b.xrd.Spec.Versions) - 1 + b.xrd.Spec.Versions[lastIdx].Schema = &xpextv1.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{ + Raw: rawBytes, + }, + } + } + + return b +} + +// WithRawSchema adds a raw JSON schema to the last added version. +func (b *XRDBuilder) WithRawSchema(rawJSON []byte) *XRDBuilder { + if len(b.xrd.Spec.Versions) > 0 { + lastIdx := len(b.xrd.Spec.Versions) - 1 + b.xrd.Spec.Versions[lastIdx].Schema = &xpextv1.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{ + Raw: rawJSON, + }, + } + } + + return b +} + +// Build returns the built XRD. +func (b *XRDBuilder) Build() *xpextv1.CompositeResourceDefinition { + return b.xrd.DeepCopy() +} + +// BuildAsUnstructured returns the built XRD as an unstructured object. +func (b *XRDBuilder) BuildAsUnstructured() *un.Unstructured { + xrd := b.Build() + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(xrd) + if err != nil { + // This should not happen in tests, but if it does, we'll return an empty unstructured + return &un.Unstructured{} + } + + return &un.Unstructured{Object: obj} +} + +// endregion + +// region Composition builders + +// ====================================================================================== +// Composition Building Helpers +// ====================================================================================== + +// CompositionBuilder helps construct Composition objects for testing. +type CompositionBuilder struct { + composition *xpextv1.Composition +} + +// NewComposition creates a new CompositionBuilder. +func NewComposition(name string) *CompositionBuilder { + return &CompositionBuilder{ + composition: &xpextv1.Composition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v1", + Kind: "Composition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: xpextv1.CompositionSpec{}, + }, + } +} + +// WithCompositeTypeRef sets the composite type reference. +func (b *CompositionBuilder) WithCompositeTypeRef(apiVersion, kind string) *CompositionBuilder { + b.composition.Spec.CompositeTypeRef = xpextv1.TypeReference{ + APIVersion: apiVersion, + Kind: kind, + } + + return b +} + +// WithLabels sets metadata.labels on the composition. CompositionRevisions inherit these labels, so +// they drive compositionRevisionSelector matching in comp-diff tests. +func (b *CompositionBuilder) WithLabels(labels map[string]string) *CompositionBuilder { + b.composition.SetLabels(labels) + return b +} + +// WithAnnotations sets metadata.annotations on the composition. Useful for modelling the +// tooling-applied annotations a cluster copy carries but a file copy does not (notably +// kubectl.kubernetes.io/last-applied-configuration). +func (b *CompositionBuilder) WithAnnotations(annotations map[string]string) *CompositionBuilder { + b.composition.SetAnnotations(annotations) + return b +} + +// WithPipelineMode sets the composition mode to pipeline. +func (b *CompositionBuilder) WithPipelineMode() *CompositionBuilder { + b.composition.Spec.Mode = xpextv1.CompositionModePipeline + return b +} + +// PipelineStepOption is a functional option for configuring a pipeline step. +type PipelineStepOption func(*xpextv1.PipelineStep) + +// WithCredentials adds credentials to a pipeline step. +func WithCredentials(name, namespace, secretName string) PipelineStepOption { + return func(step *xpextv1.PipelineStep) { + step.Credentials = append(step.Credentials, xpextv1.FunctionCredentials{ + Name: name, + Source: xpextv1.FunctionCredentialsSourceSecret, + SecretRef: &xpv2.SecretReference{ + Namespace: namespace, + Name: secretName, + }, + }) + } +} + +// WithPipelineStep adds a pipeline step to the composition. +func (b *CompositionBuilder) WithPipelineStep(step, functionName string, input map[string]any, opts ...PipelineStepOption) *CompositionBuilder { + var rawInput *runtime.RawExtension + + if input != nil { + // Properly serialize the map to JSON bytes + jsonBytes, err := json.Marshal(input) + if err == nil { + rawInput = &runtime.RawExtension{ + Raw: jsonBytes, + } + } + } + + pipelineStep := xpextv1.PipelineStep{ + Step: step, + FunctionRef: xpextv1.FunctionReference{Name: functionName}, + Input: rawInput, + } + + for _, opt := range opts { + opt(&pipelineStep) + } + + b.composition.Spec.Pipeline = append(b.composition.Spec.Pipeline, pipelineStep) + + return b +} + +// Build returns the built Composition. +func (b *CompositionBuilder) Build() *xpextv1.Composition { + return b.composition.DeepCopy() +} + +// BuildAsUnstructured returns the built Composition as an unstructured object. +func (b *CompositionBuilder) BuildAsUnstructured() *un.Unstructured { + comp := b.Build() + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(comp) + if err != nil { + // This should not happen in tests, but if it does, we'll return an empty unstructured + return &un.Unstructured{} + } + + return &un.Unstructured{Object: obj} +} + +// endregion diff --git a/internal/diff/testutils/mocks.go b/internal/diff/testutils/mocks.go new file mode 100644 index 00000000..468c1545 --- /dev/null +++ b/internal/diff/testutils/mocks.go @@ -0,0 +1,1169 @@ +package testutils + +import ( + "context" + "io" + "slices" + "strings" + + cerrdefs "github.com/containerd/errdefs" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/render" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + "github.com/crossplane/cli/v2/internal/diff/types" +) + +// duplicate these interfaces to avoid cyclical dependency: + +// DiffProcessor defines the interface for processing resources for diffing. +type DiffProcessor interface { + Initialize(ctx context.Context) error + ProcessAll(stdout io.Writer, ctx context.Context, resources []*un.Unstructured) error + ProcessResource(stdout io.Writer, ctx context.Context, res *un.Unstructured) error +} + +// region MockDynamicClient + +// MockDynamicClient mocks the dynamic.Interface. +type MockDynamicClient struct { + ResourceFn func(gvr schema.GroupVersionResource) dynamic.NamespaceableResourceInterface +} + +// Resource implements the dynamic.Interface method. +func (m *MockDynamicClient) Resource(gvr schema.GroupVersionResource) dynamic.NamespaceableResourceInterface { + return m.ResourceFn(gvr) +} + +// endregion + +// region MockNamespaceableResourceInterface + +// MockNamespaceableResourceInterface implements dynamic.NamespaceableResourceInterface. +type MockNamespaceableResourceInterface struct { + NamespaceFn func(namespace string) dynamic.ResourceInterface + GetFn func(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*un.Unstructured, error) + ListFn func(ctx context.Context, opts metav1.ListOptions) (*un.UnstructuredList, error) + CreateFn func(ctx context.Context, obj *un.Unstructured, options metav1.CreateOptions, subresources ...string) (*un.Unstructured, error) + UpdateFn func(ctx context.Context, obj *un.Unstructured, options metav1.UpdateOptions, subresources ...string) (*un.Unstructured, error) + DeleteFn func(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error + PatchFn func(ctx context.Context, name string, pt k8stypes.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*un.Unstructured, error) +} + +// Namespace implements dynamic.NamespaceableResourceInterface. +func (m *MockNamespaceableResourceInterface) Namespace(namespace string) dynamic.ResourceInterface { + if m.NamespaceFn != nil { + return m.NamespaceFn(namespace) + } + + return &MockResourceInterface{ + GetFn: m.GetFn, + ListFn: m.ListFn, + CreateFn: m.CreateFn, + UpdateFn: m.UpdateFn, + DeleteFn: m.DeleteFn, + PatchFn: m.PatchFn, + } +} + +// Create implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Create(ctx context.Context, obj *un.Unstructured, options metav1.CreateOptions, subresources ...string) (*un.Unstructured, error) { + if m.CreateFn != nil { + return m.CreateFn(ctx, obj, options, subresources...) + } + + return nil, nil +} + +// Update implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Update(ctx context.Context, obj *un.Unstructured, options metav1.UpdateOptions, subresources ...string) (*un.Unstructured, error) { + if m.UpdateFn != nil { + return m.UpdateFn(ctx, obj, options, subresources...) + } + + return nil, nil +} + +// UpdateStatus implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) UpdateStatus(_ context.Context, _ *un.Unstructured, _ metav1.UpdateOptions) (*un.Unstructured, error) { + return nil, nil +} + +// Delete implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + if m.DeleteFn != nil { + return m.DeleteFn(ctx, name, options, subresources...) + } + + return nil +} + +// DeleteCollection implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) DeleteCollection(_ context.Context, _ metav1.DeleteOptions, _ metav1.ListOptions) error { + return nil +} + +// Get implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*un.Unstructured, error) { + if m.GetFn != nil { + return m.GetFn(ctx, name, options, subresources...) + } + + return nil, nil +} + +// List implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*un.UnstructuredList, error) { + if m.ListFn != nil { + return m.ListFn(ctx, opts) + } + + return nil, nil +} + +// Watch implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Watch(_ context.Context, _ metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +// Patch implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Patch(ctx context.Context, name string, pt k8stypes.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*un.Unstructured, error) { + if m.PatchFn != nil { + return m.PatchFn(ctx, name, pt, data, options, subresources...) + } + + return nil, nil +} + +// Apply implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) Apply(_ context.Context, _ string, _ *un.Unstructured, _ metav1.ApplyOptions, _ ...string) (*un.Unstructured, error) { + return nil, nil +} + +// ApplyStatus implements dynamic.ResourceInterface. +func (m *MockNamespaceableResourceInterface) ApplyStatus(_ context.Context, _ string, _ *un.Unstructured, _ metav1.ApplyOptions) (*un.Unstructured, error) { + return nil, nil +} + +// endregion + +// region MockResourceInterface + +// MockResourceInterface mocks dynamic.ResourceInterface for namespaced resources. +type MockResourceInterface struct { + GetFn func(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*un.Unstructured, error) + ListFn func(ctx context.Context, opts metav1.ListOptions) (*un.UnstructuredList, error) + CreateFn func(ctx context.Context, obj *un.Unstructured, options metav1.CreateOptions, subresources ...string) (*un.Unstructured, error) + UpdateFn func(ctx context.Context, obj *un.Unstructured, options metav1.UpdateOptions, subresources ...string) (*un.Unstructured, error) + DeleteFn func(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error + PatchFn func(ctx context.Context, name string, pt k8stypes.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*un.Unstructured, error) +} + +// Create implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Create(ctx context.Context, obj *un.Unstructured, options metav1.CreateOptions, subresources ...string) (*un.Unstructured, error) { + if m.CreateFn != nil { + return m.CreateFn(ctx, obj, options, subresources...) + } + + return nil, nil +} + +// Update implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Update(ctx context.Context, obj *un.Unstructured, options metav1.UpdateOptions, subresources ...string) (*un.Unstructured, error) { + if m.UpdateFn != nil { + return m.UpdateFn(ctx, obj, options, subresources...) + } + + return nil, nil +} + +// UpdateStatus implements dynamic.ResourceInterface. +func (m *MockResourceInterface) UpdateStatus(_ context.Context, _ *un.Unstructured, _ metav1.UpdateOptions) (*un.Unstructured, error) { + return nil, nil +} + +// Delete implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + if m.DeleteFn != nil { + return m.DeleteFn(ctx, name, options, subresources...) + } + + return nil +} + +// DeleteCollection implements dynamic.ResourceInterface. +func (m *MockResourceInterface) DeleteCollection(_ context.Context, _ metav1.DeleteOptions, _ metav1.ListOptions) error { + return nil +} + +// Get implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*un.Unstructured, error) { + if m.GetFn != nil { + return m.GetFn(ctx, name, options, subresources...) + } + + return nil, nil +} + +// List implements dynamic.ResourceInterface. +func (m *MockResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*un.UnstructuredList, error) { + if m.ListFn != nil { + return m.ListFn(ctx, opts) + } + + return nil, nil +} + +// Watch implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Watch(_ context.Context, _ metav1.ListOptions) (watch.Interface, error) { + return nil, nil +} + +// Patch implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Patch(ctx context.Context, name string, pt k8stypes.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*un.Unstructured, error) { + if m.PatchFn != nil { + return m.PatchFn(ctx, name, pt, data, options, subresources...) + } + + return nil, nil +} + +// Apply implements dynamic.ResourceInterface. +func (m *MockResourceInterface) Apply(_ context.Context, _ string, _ *un.Unstructured, _ metav1.ApplyOptions, _ ...string) (*un.Unstructured, error) { + return nil, nil +} + +// ApplyStatus implements dynamic.ResourceInterface. +func (m *MockResourceInterface) ApplyStatus(_ context.Context, _ string, _ *un.Unstructured, _ metav1.ApplyOptions) (*un.Unstructured, error) { + return nil, nil +} + +// endregion + +// region MockDiffProcessor + +// MockDiffProcessor implements the DiffProcessor interface for testing. +type MockDiffProcessor struct { + // Function fields for mocking behavior + InitializeFn func(ctx context.Context) error + PerformDiffFn func(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) + DiffSingleResourceFn func(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) + CleanupFn func(ctx context.Context) error +} + +// Initialize implements the DiffProcessor interface. +func (m *MockDiffProcessor) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// PerformDiff implements the DiffProcessor.PerformDiff method. +func (m *MockDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { + if m.PerformDiffFn != nil { + return m.PerformDiffFn(ctx, resources, compositionProvider) + } + + return false, nil +} + +// DiffSingleResource implements the DiffProcessor.DiffSingleResource method. +func (m *MockDiffProcessor) DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + if m.DiffSingleResourceFn != nil { + return m.DiffSingleResourceFn(ctx, res, compositionProvider) + } + + return make(map[string]*dt.ResourceDiff), nil +} + +// Cleanup implements the DiffProcessor.Cleanup method. +func (m *MockDiffProcessor) Cleanup(ctx context.Context) error { + if m.CleanupFn != nil { + return m.CleanupFn(ctx) + } + + return nil +} + +// endregion + +// region MockSchemaValidator + +// MockSchemaValidator Mock schema validator. +type MockSchemaValidator struct { + ValidateResourcesFn func(ctx context.Context, xr *un.Unstructured, composed []cpd.Unstructured) error + ValidateScopeConstraintsFn func(ctx context.Context, resource *un.Unstructured, expectedNamespace string, isClaimRoot bool) error +} + +// ValidateResources validates a set of resources against schemas from the cluster. +func (m *MockSchemaValidator) ValidateResources(ctx context.Context, xr *un.Unstructured, composed []cpd.Unstructured) error { + if m.ValidateResourcesFn != nil { + return m.ValidateResourcesFn(ctx, xr, composed) + } + + return nil +} + +// EnsureComposedResourceCRDs Implement other required methods of the SchemaValidator interface. +func (m *MockSchemaValidator) EnsureComposedResourceCRDs(_ context.Context, _ []*un.Unstructured) error { + return nil +} + +// ValidateScopeConstraints validates that a resource has the appropriate namespace for its scope. +func (m *MockSchemaValidator) ValidateScopeConstraints(ctx context.Context, resource *un.Unstructured, expectedNamespace string, isClaimRoot bool) error { + if m.ValidateScopeConstraintsFn != nil { + return m.ValidateScopeConstraintsFn(ctx, resource, expectedNamespace, isClaimRoot) + } + + return nil +} + +// endregion + +// region MockDefaulter + +// MockDefaulter Mock defaulter. With no DefaultFn it applies no defaults, returning a copy of its +// input unchanged. +type MockDefaulter struct { + DefaultFn func(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, string, error) +} + +// Default returns a copy of obj with predicted CRD defaults applied, and the storage version of its +// CRD when that is not obj's own (none, without a DefaultFn). +func (m *MockDefaulter) Default(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, string, error) { + if m.DefaultFn != nil { + return m.DefaultFn(ctx, obj) + } + + return obj.DeepCopy(), "", nil +} + +// endregion + +// region MockInputValidator + +// MockInputValidator Mock input validator, for driving PerformDiff's wiring directly. +type MockInputValidator struct { + ToRenderFn func() []types.ValidatedInput + RecordRenderFn func(i int, rendered map[string]bool, err error) + VerdictsFn func(groups []dt.XRDiffGroup) []error + RenderOverlapsFn func(groups []dt.XRDiffGroup) []error +} + +// ToRender returns the inputs to render. +func (m *MockInputValidator) ToRender() []types.ValidatedInput { + if m.ToRenderFn != nil { + return m.ToRenderFn() + } + + return nil +} + +// RecordRender records one render. +func (m *MockInputValidator) RecordRender(i int, rendered map[string]bool, err error) { + if m.RecordRenderFn != nil { + m.RecordRenderFn(i, rendered, err) + } +} + +// Verdicts returns each group's final error; by default, none. +func (m *MockInputValidator) Verdicts(groups []dt.XRDiffGroup) []error { + if m.VerdictsFn != nil { + return m.VerdictsFn(groups) + } + + return make([]error, len(groups)) +} + +// RenderOverlaps returns the render-overlap errors; by default, none. +func (m *MockInputValidator) RenderOverlaps(groups []dt.XRDiffGroup) []error { + if m.RenderOverlapsFn != nil { + return m.RenderOverlapsFn(groups) + } + + return nil +} + +// endregion + +// region Kubernetes client mocks + +// ============================================================================== +// Kubernetes API Layer Mocks +// ============================================================================== + +// MockResourceClient implements the kubernetes.ResourceClient interface. +type MockResourceClient struct { + InitializeFn func(ctx context.Context) error + GetResourceFn func(ctx context.Context, gvk schema.GroupVersionKind, namespace, name string) (*un.Unstructured, error) + ListResourcesFn func(ctx context.Context, gvk schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) + GetResourcesByLabelFn func(ctx context.Context, gvk schema.GroupVersionKind, namespace string, sel metav1.LabelSelector) ([]*un.Unstructured, error) + GetAllResourcesByLabelsFn func(ctx context.Context, gvks []schema.GroupVersionKind, selectors []metav1.LabelSelector) ([]*un.Unstructured, error) + GetGVKsForGroupKindFn func(ctx context.Context, group, kind string) ([]schema.GroupVersionKind, error) + IsNamespacedResourceFn func(ctx context.Context, gvk schema.GroupVersionKind) (bool, error) +} + +// Initialize implements kubernetes.ResourceClient. +func (m *MockResourceClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// GetResource implements kubernetes.ResourceClient. +func (m *MockResourceClient) GetResource(ctx context.Context, gvk schema.GroupVersionKind, namespace, name string) (*un.Unstructured, error) { + if m.GetResourceFn != nil { + return m.GetResourceFn(ctx, gvk, namespace, name) + } + + return nil, errors.New("GetResource not implemented") +} + +// ListResources implements kubernetes.ResourceClient. +func (m *MockResourceClient) ListResources(ctx context.Context, gvk schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) { + if m.ListResourcesFn != nil { + return m.ListResourcesFn(ctx, gvk, namespace) + } + + return nil, errors.New("ListResources not implemented") +} + +// GetResourcesByLabel implements kubernetes.ResourceClient. +func (m *MockResourceClient) GetResourcesByLabel(ctx context.Context, gvk schema.GroupVersionKind, namespace string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + if m.GetResourcesByLabelFn != nil { + return m.GetResourcesByLabelFn(ctx, gvk, namespace, sel) + } + + return nil, errors.New("GetResourcesByLabel not implemented") +} + +// GetAllResourcesByLabels implements kubernetes.ResourceClient. +func (m *MockResourceClient) GetAllResourcesByLabels(ctx context.Context, gvks []schema.GroupVersionKind, selectors []metav1.LabelSelector) ([]*un.Unstructured, error) { + if m.GetAllResourcesByLabelsFn != nil { + return m.GetAllResourcesByLabelsFn(ctx, gvks, selectors) + } + + return nil, errors.New("GetAllResourcesByLabels not implemented") +} + +// GetGVKsForGroupKind implements kubernetes.ResourceClient. +func (m *MockResourceClient) GetGVKsForGroupKind(ctx context.Context, group, kind string) ([]schema.GroupVersionKind, error) { + if m.GetGVKsForGroupKindFn != nil { + return m.GetGVKsForGroupKindFn(ctx, group, kind) + } + + return nil, errors.New("GetGVKsForGroupKind not implemented") +} + +// IsNamespacedResource implements kubernetes.ResourceClient. +func (m *MockResourceClient) IsNamespacedResource(ctx context.Context, gvk schema.GroupVersionKind) (bool, error) { + if m.IsNamespacedResourceFn != nil { + return m.IsNamespacedResourceFn(ctx, gvk) + } + + return false, errors.Errorf("IsNamespacedResource not implemented for %s in mock", gvk.String()) +} + +// MockSchemaClient implements the kubernetes.SchemaClient interface. +type MockSchemaClient struct { + InitializeFn func(ctx context.Context) error + GetCRDFn func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) + GetCRDByNameFn func(name string) (*extv1.CustomResourceDefinition, error) + IsCRDRequiredFn func(ctx context.Context, gvk schema.GroupVersionKind) bool + ValidateResourceFn func(ctx context.Context, resource *un.Unstructured) error + LoadCRDsFromXRDsFn func(ctx context.Context, xrds []*un.Unstructured) error + GetAllCRDsFn func() []*extv1.CustomResourceDefinition +} + +// Initialize implements kubernetes.SchemaClient. +func (m *MockSchemaClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// GetCRD implements kubernetes.SchemaClient. +func (m *MockSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if m.GetCRDFn != nil { + return m.GetCRDFn(ctx, gvk) + } + + return nil, errors.New("GetCRD not implemented") +} + +// GetCRDByName implements kubernetes.SchemaClient. +func (m *MockSchemaClient) GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) { + if m.GetCRDByNameFn != nil { + return m.GetCRDByNameFn(name) + } + + return nil, errors.New("GetCRDByName not implemented") +} + +// IsCRDRequired implements kubernetes.SchemaClient. +func (m *MockSchemaClient) IsCRDRequired(ctx context.Context, gvk schema.GroupVersionKind) bool { + if m.IsCRDRequiredFn != nil { + return m.IsCRDRequiredFn(ctx, gvk) + } + + return true // Default to true +} + +// ValidateResource implements kubernetes.SchemaClient. +func (m *MockSchemaClient) ValidateResource(ctx context.Context, resource *un.Unstructured) error { + if m.ValidateResourceFn != nil { + return m.ValidateResourceFn(ctx, resource) + } + + return nil +} + +// LoadCRDsFromXRDs implements kubernetes.SchemaClient. +func (m *MockSchemaClient) LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error { + if m.LoadCRDsFromXRDsFn != nil { + return m.LoadCRDsFromXRDsFn(ctx, xrds) + } + + return nil +} + +// GetAllCRDs implements kubernetes.SchemaClient. +func (m *MockSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { + if m.GetAllCRDsFn != nil { + return m.GetAllCRDsFn() + } + + return []*extv1.CustomResourceDefinition{} +} + +// MockApplyClient implements the kubernetes.ApplyClient interface. +type MockApplyClient struct { + InitializeFn func(ctx context.Context) error + ApplyFn func(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, error) + DryRunApplyFn func(ctx context.Context, obj *un.Unstructured, fieldOwner string) (*un.Unstructured, error) + DryRunCreateFn func(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, error) +} + +// Initialize implements kubernetes.ApplyClient. +func (m *MockApplyClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// Apply implements kubernetes.ApplyClient. +func (m *MockApplyClient) Apply(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + if m.ApplyFn != nil { + return m.ApplyFn(ctx, obj) + } + + return nil, errors.New("Apply not implemented") +} + +// DryRunApply implements kubernetes.ApplyClient. +func (m *MockApplyClient) DryRunApply(ctx context.Context, obj *un.Unstructured, fieldOwner string) (*un.Unstructured, error) { + if m.DryRunApplyFn != nil { + return m.DryRunApplyFn(ctx, obj, fieldOwner) + } + + return nil, errors.New("DryRunApply not implemented") +} + +// DryRunCreate implements kubernetes.ApplyClient. +func (m *MockApplyClient) DryRunCreate(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + if m.DryRunCreateFn != nil { + return m.DryRunCreateFn(ctx, obj) + } + + return nil, errors.New("DryRunCreate not implemented") +} + +// MockAccessChecker implements the kubernetes.AccessChecker interface. +type MockAccessChecker struct { + CanFn func(ctx context.Context, gvk schema.GroupVersionKind, namespace string, verb types.Verb) (bool, string, error) +} + +// Can implements kubernetes.AccessChecker. +// +// The unset default is "allowed", not an error, and that is deliberate. The production code only +// consults the authorizer after a dry run has already come back Forbidden, and "allowed" is the +// answer that routes such a 403 to the cluster-rejection path. So a test that wires a Forbidden +// without saying anything about authorization gets the rejection behaviour rather than a confusing +// "Can not implemented" failure from a collaborator it never meant to exercise. +func (m *MockAccessChecker) Can(ctx context.Context, gvk schema.GroupVersionKind, namespace string, verb types.Verb) (bool, string, error) { + if m.CanFn != nil { + return m.CanFn(ctx, gvk, namespace, verb) + } + + return true, "", nil +} + +// MockTypeConverter implements the kubernetes.TypeConverter interface. +type MockTypeConverter struct { + GVKToGVRFn func(ctx context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) + GetResourceNameForGVKFn func(ctx context.Context, gvk schema.GroupVersionKind) (string, error) +} + +// GVKToGVR implements kubernetes.TypeConverter. +func (m *MockTypeConverter) GVKToGVR(ctx context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + if m.GVKToGVRFn != nil { + return m.GVKToGVRFn(ctx, gvk) + } + + return schema.GroupVersionResource{}, errors.New("GVKToGVR not implemented") +} + +// GetResourceNameForGVK implements kubernetes.TypeConverter. +func (m *MockTypeConverter) GetResourceNameForGVK(ctx context.Context, gvk schema.GroupVersionKind) (string, error) { + if m.GetResourceNameForGVKFn != nil { + return m.GetResourceNameForGVKFn(ctx, gvk) + } + + return "", errors.New("GetResourceNameForGVK not implemented") +} + +// endregion + +// region Crossplane client mocks + +// ============================================================================== +// Crossplane API Layer Mocks +// ============================================================================== + +// MockCompositionClient implements the crossplane.CompositionClient interface. +type MockCompositionClient struct { + InitializeFn func(ctx context.Context) error + FindMatchingCompositionFn func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) + ListCompositionsFn func(ctx context.Context) ([]*xpextv1.Composition, error) + GetCompositionFn func(ctx context.Context, name string) (*xpextv1.Composition, error) + FindCompositesFn func(ctx context.Context, comp *un.Unstructured, opts types.FindCompositesOptions) ([]*un.Unstructured, error) +} + +// Initialize implements crossplane.CompositionClient. +func (m *MockCompositionClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// FindMatchingComposition implements crossplane.CompositionClient. +func (m *MockCompositionClient) FindMatchingComposition(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + if m.FindMatchingCompositionFn != nil { + return m.FindMatchingCompositionFn(ctx, res) + } + + return types.ResolvedComposition{}, errors.New("FindMatchingComposition not implemented") +} + +// ListCompositions implements crossplane.CompositionClient. +func (m *MockCompositionClient) ListCompositions(ctx context.Context) ([]*xpextv1.Composition, error) { + if m.ListCompositionsFn != nil { + return m.ListCompositionsFn(ctx) + } + + return nil, errors.New("ListCompositions not implemented") +} + +// GetComposition implements crossplane.CompositionClient. +func (m *MockCompositionClient) GetComposition(ctx context.Context, name string) (*xpextv1.Composition, error) { + if m.GetCompositionFn != nil { + return m.GetCompositionFn(ctx, name) + } + + return nil, errors.New("GetComposition not implemented") +} + +// FindComposites implements crossplane.CompositionClient. +func (m *MockCompositionClient) FindComposites(ctx context.Context, comp *un.Unstructured, opts types.FindCompositesOptions) ([]*un.Unstructured, error) { + if m.FindCompositesFn != nil { + return m.FindCompositesFn(ctx, comp, opts) + } + + return nil, errors.New("FindComposites not implemented") +} + +// MockFunctionClient implements the crossplane.FunctionClient interface. +type MockFunctionClient struct { + InitializeFn func(ctx context.Context) error + GetFunctionsFromPipelineFn func(comp *xpextv1.Composition) ([]pkgv1.Function, error) + ListFunctionsFn func(ctx context.Context) ([]pkgv1.Function, error) +} + +// Initialize implements crossplane.FunctionClient. +func (m *MockFunctionClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// GetFunctionsFromPipeline implements crossplane.FunctionClient. +func (m *MockFunctionClient) GetFunctionsFromPipeline(comp *xpextv1.Composition) ([]pkgv1.Function, error) { + if m.GetFunctionsFromPipelineFn != nil { + return m.GetFunctionsFromPipelineFn(comp) + } + + return nil, errors.New("GetFunctionsFromPipeline not implemented") +} + +// ListFunctions implements crossplane.FunctionClient. +func (m *MockFunctionClient) ListFunctions(ctx context.Context) ([]pkgv1.Function, error) { + if m.ListFunctionsFn != nil { + return m.ListFunctionsFn(ctx) + } + + return nil, errors.New("ListFunctions not implemented") +} + +// MockEnvironmentClient implements the crossplane.EnvironmentClient interface. +type MockEnvironmentClient struct { + InitializeFn func(ctx context.Context) error + GetEnvironmentConfigsFn func(ctx context.Context) ([]*un.Unstructured, error) + GetEnvironmentConfigFn func(ctx context.Context, name string) (*un.Unstructured, error) +} + +// Initialize implements crossplane.EnvironmentClient. +func (m *MockEnvironmentClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// GetEnvironmentConfigs implements crossplane.EnvironmentClient. +func (m *MockEnvironmentClient) GetEnvironmentConfigs(ctx context.Context) ([]*un.Unstructured, error) { + if m.GetEnvironmentConfigsFn != nil { + return m.GetEnvironmentConfigsFn(ctx) + } + + return nil, errors.New("GetEnvironmentConfigs not implemented") +} + +// GetEnvironmentConfig implements crossplane.EnvironmentClient. +func (m *MockEnvironmentClient) GetEnvironmentConfig(ctx context.Context, name string) (*un.Unstructured, error) { + if m.GetEnvironmentConfigFn != nil { + return m.GetEnvironmentConfigFn(ctx, name) + } + + return nil, errors.New("GetEnvironmentConfig not implemented") +} + +// MockDefinitionClient implements the crossplane.DefinitionClient interface. +type MockDefinitionClient struct { + InitializeFn func(ctx context.Context) error + GetXRDsFn func(ctx context.Context) ([]*un.Unstructured, error) + GetXRDForClaimFn func(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) + GetXRDForXRFn func(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) + IsClaimResourceFn func(ctx context.Context, resource *un.Unstructured) bool + GetCompositeSchemaFn func(ctx context.Context, gvk schema.GroupVersionKind) (cmp.Schema, error) +} + +// Initialize implements crossplane.DefinitionClient. +func (m *MockDefinitionClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// GetXRDs implements crossplane.DefinitionClient. +func (m *MockDefinitionClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, error) { + if m.GetXRDsFn != nil { + return m.GetXRDsFn(ctx) + } + + return nil, errors.New("GetXRDs not implemented") +} + +// GetXRDForClaim implements crossplane.DefinitionClient. +func (m *MockDefinitionClient) GetXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if m.GetXRDForClaimFn != nil { + return m.GetXRDForClaimFn(ctx, gvk) + } + + return nil, errors.New("GetXRDForClaim not implemented") +} + +// GetXRDForXR implements crossplane.DefinitionClient. +func (m *MockDefinitionClient) GetXRDForXR(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if m.GetXRDForXRFn != nil { + return m.GetXRDForXRFn(ctx, gvk) + } + + return nil, errors.New("GetXRDForXR not implemented") +} + +// IsClaimResource implements crossplane.DefinitionClient. +func (m *MockDefinitionClient) IsClaimResource(ctx context.Context, resource *un.Unstructured) bool { + if m.IsClaimResourceFn != nil { + return m.IsClaimResourceFn(ctx, resource) + } + + return false +} + +// GetCompositeSchema implements crossplane.DefinitionClient. +func (m *MockDefinitionClient) GetCompositeSchema(ctx context.Context, gvk schema.GroupVersionKind) (cmp.Schema, error) { + if m.GetCompositeSchemaFn != nil { + return m.GetCompositeSchemaFn(ctx, gvk) + } + + return cmp.SchemaModern, errors.New("GetCompositeSchema not implemented") +} + +// MockResourceTreeClient implements the crossplane.ResourceTreeClient interface. +type MockResourceTreeClient struct { + InitializeFn func(ctx context.Context) error + GetResourceTreeFn func(ctx context.Context, root *un.Unstructured) (*resource.Resource, error) +} + +// Initialize implements crossplane.ResourceTreeClient. +func (m *MockResourceTreeClient) Initialize(ctx context.Context) error { + if m.InitializeFn != nil { + return m.InitializeFn(ctx) + } + + return nil +} + +// GetResourceTree implements crossplane.ResourceTreeClient. +func (m *MockResourceTreeClient) GetResourceTree(ctx context.Context, root *un.Unstructured) (*resource.Resource, error) { + if m.GetResourceTreeFn != nil { + return m.GetResourceTreeFn(ctx, root) + } + + return nil, errors.New("GetResourceTree not implemented") +} + +// MockCredentialClient implements the crossplane.CredentialClient interface. +type MockCredentialClient struct { + FetchCompositionCredentialsFn func(ctx context.Context, comp *xpextv1.Composition) (types.CredentialFetchResult, error) +} + +// FetchCompositionCredentials implements crossplane.CredentialClient. +func (m *MockCredentialClient) FetchCompositionCredentials(ctx context.Context, comp *xpextv1.Composition) (types.CredentialFetchResult, error) { + if m.FetchCompositionCredentialsFn != nil { + return m.FetchCompositionCredentialsFn(ctx, comp) + } + + return types.CredentialFetchResult{}, nil +} + +// endregion + +// MockDiffCalculator is a mock implementation of DiffCalculator for testing. +type MockDiffCalculator struct { + CalculateDiffFn func(context.Context, *un.Unstructured, *un.Unstructured) (*dt.ResourceDiff, error) + CalculateDiffsFn func(context.Context, *cmp.Unstructured, render.CompositionOutputs) (map[string]*dt.ResourceDiff, error) + CalculateNonRemovalDiffsFn func(context.Context, *cmp.Unstructured, render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) + CalculateRemovedResourceDiffsFn func(context.Context, *un.Unstructured, types.RenderedResources) (map[string]*dt.ResourceDiff, error) +} + +// CalculateDiff implements DiffCalculator. +func (m *MockDiffCalculator) CalculateDiff(ctx context.Context, composite *un.Unstructured, desired *un.Unstructured) (*dt.ResourceDiff, error) { + if m.CalculateDiffFn != nil { + return m.CalculateDiffFn(ctx, composite, desired) + } + + return nil, nil +} + +// CalculateDiffs implements DiffCalculator. +func (m *MockDiffCalculator) CalculateDiffs(ctx context.Context, xr *cmp.Unstructured, desired render.CompositionOutputs) (map[string]*dt.ResourceDiff, error) { + if m.CalculateDiffsFn != nil { + return m.CalculateDiffsFn(ctx, xr, desired) + } + + return nil, nil +} + +// CalculateNonRemovalDiffs implements DiffCalculator. +func (m *MockDiffCalculator) CalculateNonRemovalDiffs(ctx context.Context, xr *cmp.Unstructured, desired render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + if m.CalculateNonRemovalDiffsFn != nil { + return m.CalculateNonRemovalDiffsFn(ctx, xr, desired) + } + + return nil, types.RenderedResources{}, nil +} + +// CalculateRemovedResourceDiffs implements DiffCalculator. +func (m *MockDiffCalculator) CalculateRemovedResourceDiffs(ctx context.Context, xr *un.Unstructured, rendered types.RenderedResources) (map[string]*dt.ResourceDiff, error) { + if m.CalculateRemovedResourceDiffsFn != nil { + return m.CalculateRemovedResourceDiffsFn(ctx, xr, rendered) + } + + return nil, nil +} + +// MockDiffRenderer provides a mock implementation for DiffRenderer. +type MockDiffRenderer struct { + RenderDiffsFn func([]dt.XRDiffGroup, []dt.OutputError, []dt.OutputWarning) error +} + +// RenderDiffs implements RenderDiffs from the DiffRenderer interface. +func (m *MockDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.OutputError, warnings []dt.OutputWarning) error { + if m.RenderDiffsFn != nil { + return m.RenderDiffsFn(groups, errs, warnings) + } + + return nil +} + +// MockCompositionProvider provides a mock implementation for CompositionProvider. +type MockCompositionProvider struct { + GetCompositionFn func() (*xpextv1.Composition, error) +} + +// GetComposition implements GetComposition from the CompositionProvider interface. +func (m *MockCompositionProvider) GetComposition() (*xpextv1.Composition, error) { + if m.GetCompositionFn != nil { + return m.GetCompositionFn() + } + + return &xpextv1.Composition{}, nil +} + +// MockFunctionProvider provides a mock implementation for FunctionProvider. +type MockFunctionProvider struct { + GetFunctionsForCompositionFn func(comp *xpextv1.Composition) ([]pkgv1.Function, error) + CleanupFn func(ctx context.Context) error +} + +// GetFunctionsForComposition implements FunctionProvider. +func (m *MockFunctionProvider) GetFunctionsForComposition(comp *xpextv1.Composition) ([]pkgv1.Function, error) { + if m.GetFunctionsForCompositionFn != nil { + return m.GetFunctionsForCompositionFn(comp) + } + + return nil, nil +} + +// Cleanup implements FunctionProvider. +func (m *MockFunctionProvider) Cleanup(ctx context.Context) error { + if m.CleanupFn != nil { + return m.CleanupFn(ctx) + } + + return nil +} + +// region Docker daemon fake + +// FakeDocker is an in-memory Docker daemon: it holds containers and networks, and implements the subset +// of the Docker API client that dockerrun.Docker names. It behaves as the daemon does wherever the +// difference would change which resources are left: listing applies label filters (a resource matches +// only if it carries every one) and, without All, lists only running containers; a running container +// is only removed with Force; and a network is not removed while a container is attached to it. A test +// sets up the host, runs the code, and asserts what is left. +type FakeDocker struct { + Containers []container.Summary + Networks []network.Summary + + // ListErr and NetworkListErr fail every container and network list. + ListErr, NetworkListErr error + // RemoveErr fails removing the container or network with that ID, which stays. + RemoveErr map[string]error + // Vanished holds the IDs of containers and networks that are listed but gone by the time they are + // removed, as when a concurrent run removed them first. + Vanished map[string]bool +} + +// DockerSession returns the labels the upstream render engine puts on what it creates for session id. +func DockerSession(id string) map[string]string { + return map[string]string{render.LabelKeyManagedBy: render.LabelValueManagedByCrossplane, render.LabelKeySession: id} +} + +// DockerContainer returns a container in state, carrying labels and attached to the named networks. +func DockerContainer(id string, state container.ContainerState, labels map[string]string, networks ...string) container.Summary { + c := container.Summary{ID: id, State: state, Labels: labels, NetworkSettings: &container.NetworkSettingsSummary{}} + if len(networks) > 0 { + c.NetworkSettings.Networks = make(map[string]*network.EndpointSettings, len(networks)) + for _, n := range networks { + c.NetworkSettings.Networks[n] = &network.EndpointSettings{} + } + } + + return c +} + +// DockerNetwork returns a network carrying labels. Its ID is its name. +func DockerNetwork(name string, labels map[string]string) network.Summary { + return network.Summary{Network: network.Network{ID: name, Name: name, Labels: labels}} +} + +// ContainerIDs returns the IDs of the containers on the host, sorted. +func (f *FakeDocker) ContainerIDs() []string { + ids := make([]string, 0, len(f.Containers)) + for _, c := range f.Containers { + ids = append(ids, c.ID) + } + + slices.Sort(ids) + + return ids +} + +// NetworkNames returns the names of the networks on the host, sorted. +func (f *FakeDocker) NetworkNames() []string { + names := make([]string, 0, len(f.Networks)) + for _, n := range f.Networks { + names = append(names, n.Name) + } + + slices.Sort(names) + + return names +} + +// ContainerList lists the containers matching opts' filters; without opts.All, only running ones. +func (f *FakeDocker) ContainerList(_ context.Context, opts client.ContainerListOptions) (client.ContainerListResult, error) { + var res client.ContainerListResult + + if f.ListErr != nil { + return res, f.ListErr + } + + for _, c := range f.Containers { + ok, err := matchesDockerFilters(opts.Filters, c.Labels) + if err != nil { + return client.ContainerListResult{}, err + } + + if ok && (opts.All || c.State == container.StateRunning) { + res.Items = append(res.Items, c) + } + } + + return res, nil +} + +// ContainerRemove removes the container with ID id. +func (f *FakeDocker) ContainerRemove(_ context.Context, id string, opts client.ContainerRemoveOptions) (client.ContainerRemoveResult, error) { + i := slices.IndexFunc(f.Containers, func(c container.Summary) bool { return c.ID == id }) + + switch { + case i < 0: + return client.ContainerRemoveResult{}, errors.Wrapf(cerrdefs.ErrNotFound, "no such container: %s", id) + case f.Vanished[id]: + f.Containers = slices.Delete(f.Containers, i, i+1) + return client.ContainerRemoveResult{}, errors.Wrapf(cerrdefs.ErrNotFound, "no such container: %s", id) + case f.RemoveErr[id] != nil: + return client.ContainerRemoveResult{}, f.RemoveErr[id] + case f.Containers[i].State == container.StateRunning && !opts.Force: + return client.ContainerRemoveResult{}, errors.Wrapf(cerrdefs.ErrConflict, "container %s is running: stop it or force the removal", id) + } + + f.Containers = slices.Delete(f.Containers, i, i+1) + + return client.ContainerRemoveResult{}, nil +} + +// NetworkList lists the networks matching opts' filters. +func (f *FakeDocker) NetworkList(_ context.Context, opts client.NetworkListOptions) (client.NetworkListResult, error) { + var res client.NetworkListResult + + if f.NetworkListErr != nil { + return res, f.NetworkListErr + } + + for _, n := range f.Networks { + ok, err := matchesDockerFilters(opts.Filters, n.Labels) + if err != nil { + return client.NetworkListResult{}, err + } + + if ok { + res.Items = append(res.Items, n) + } + } + + return res, nil +} + +// NetworkRemove removes the network with ID or name id, unless a container is attached to it. +func (f *FakeDocker) NetworkRemove(_ context.Context, id string, _ client.NetworkRemoveOptions) (client.NetworkRemoveResult, error) { + i := slices.IndexFunc(f.Networks, func(n network.Summary) bool { return n.ID == id || n.Name == id }) + if i < 0 { + return client.NetworkRemoveResult{}, errors.Wrapf(cerrdefs.ErrNotFound, "network %s not found", id) + } + + n := f.Networks[i] + + switch { + case f.Vanished[n.ID]: + f.Networks = slices.Delete(f.Networks, i, i+1) + return client.NetworkRemoveResult{}, errors.Wrapf(cerrdefs.ErrNotFound, "network %s not found", id) + case f.RemoveErr[n.ID] != nil: + return client.NetworkRemoveResult{}, f.RemoveErr[n.ID] + case slices.ContainsFunc(f.Containers, func(c container.Summary) bool { + if c.NetworkSettings == nil { + return false + } + + _, attached := c.NetworkSettings.Networks[n.Name] + + return attached + }): + return client.NetworkRemoveResult{}, errors.Wrapf(cerrdefs.ErrConflict, "error while removing network: network %s has active endpoints", n.Name) + } + + f.Networks = slices.Delete(f.Networks, i, i+1) + + return client.NetworkRemoveResult{}, nil +} + +// Close does nothing: the fake holds no connection. +func (f *FakeDocker) Close() error { + return nil +} + +// matchesDockerFilters reports whether labels satisfy filters as the daemon's label filter does. The +// fake supports no other filter term, so it fails a list that uses one rather than ignore it. +func matchesDockerFilters(filters client.Filters, labels map[string]string) (bool, error) { + for term, values := range filters { + if term != "label" { + return false, errors.Errorf("fake Docker daemon does not support the %q list filter", term) + } + + for v := range values { + k, want, hasValue := strings.Cut(v, "=") + if got, ok := labels[k]; !ok || (hasValue && got != want) { + return false, nil + } + } + } + + return true, nil +} + +// endregion diff --git a/internal/diff/testutils/structured_assertions.go b/internal/diff/testutils/structured_assertions.go new file mode 100644 index 00000000..ca47434a --- /dev/null +++ b/internal/diff/testutils/structured_assertions.go @@ -0,0 +1,2307 @@ +// Package testutils provides test utilities for crossplane-diff. +package testutils + +import ( + "encoding/json" + "fmt" + "reflect" + "regexp" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/util/jsonpath" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// StructuredDiffOutput mirrors the renderer's xr document to avoid import cycles: v1's +// renderer.StructuredDiffOutput, and v2's structuredDiffOutputV2, which is the same document without +// changes (Changes is then nil). These types are used only for test assertions. +type StructuredDiffOutput struct { + OutputVersion string `json:"outputVersion"` + Summary Summary `json:"summary"` + Changes []ChangeDetail `json:"changes"` + Errors []OutputError `json:"errors,omitempty"` + Warnings []OutputWarning `json:"warnings,omitempty"` + Xrs []XRDiffWire `json:"xrs"` +} + +// OutputWarning mirrors renderer/types.OutputWarning: a non-fatal advisory carried in structured +// output alongside (but distinct from) errors. +type OutputWarning struct { + Message string `json:"message"` + Context map[string]string `json:"context,omitempty"` +} + +// XREntryWire mirrors the renderer's v2 per-XR entry (xrEntryWire), which both xr's xrs[] and comp's +// v2 impactAnalysis[] hold. XR is a corev1.ObjectReference nested under "xr", matching the renderer — +// asserting into the same platform type here also exercises that the "xr" object round-trips into an +// ObjectReference. +type XREntryWire struct { + XR corev1.ObjectReference `json:"xr"` + Status string `json:"status"` + FilterReason string `json:"filterReason,omitempty"` + FilterDetail string `json:"filterDetail,omitempty"` + Summary Summary `json:"summary"` + Changes []ChangeDetail `json:"changes"` + Errors []OutputError `json:"errors,omitempty"` +} + +// XRDiffWire mirrors the renderer's v1 xrs[] entry (xrDiffWire). Its fields are a subset of the v2 +// entry's, the rest of which xr never sets, so one mirror serves both versions. +type XRDiffWire = XREntryWire + +// Summary mirrors renderer.Summary. +type Summary struct { + Added int `json:"added"` + Modified int `json:"modified"` + Removed int `json:"removed"` +} + +// ChangeDetail mirrors renderer.ChangeDetail. +type ChangeDetail struct { + Type string `json:"type"` + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + Diff map[string]any `json:"diff"` + DryRun *DryRunInfo `json:"dryRun,omitempty"` +} + +// DryRunInfo mirrors dt.DryRunInfo. Absent (nil) means the resource's desired +// state was verified against the apiserver; see WithDryRunSkipped / +// WithDryRunPerformed. +type DryRunInfo struct { + Performed bool `json:"performed"` + SkipReason string `json:"skipReason,omitempty"` + Detail string `json:"detail,omitempty"` + StorageVersion string `json:"storageVersion,omitempty"` +} + +// DiffExpectation is an interface that allows AssertStructuredDiff to accept +// either *ExpectedDiff or *ResourceExpectation, eliminating the need for And(). +type DiffExpectation interface { + expectation() *ExpectedDiff +} + +// ExpectedDiff is a fluent builder for test expectations on structured diff output. +type ExpectedDiff struct { + summary *expectedSummary + resources []*ResourceExpectation + errors []*ErrorExpectation + warnings []*WarningExpectation + // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". + noWarnings bool + xrs []*XRExpectation + // outputVersion, when set, asserts the document's outputVersion and the shape that implies. + outputVersion string +} + +// warningMatch describes one expected entry in warnings[], shared by the xr and comp builders. Matched +// by message substring rather than exact equality, since warning prose is not a stable contract; +// context keys are asserted exactly, because those ARE what a machine consumer reads. +type warningMatch struct { + messageContains string + context map[string]string +} + +// WarningExpectation describes one expected entry in an xr diff's warnings[]. +type WarningExpectation struct { + warningMatch + + parent *ExpectedDiff +} + +func (w *WarningExpectation) expectation() *ExpectedDiff { return w.parent } + +func (e *ExpectedDiff) expectation() *ExpectedDiff { return e } + +type expectedSummary struct { + added int + modified int + removed int +} + +// ResourceExpectation defines expectations for a single resource change. +type ResourceExpectation struct { + parent *ExpectedDiff + xrParent *XRExpectation // set when this change is scoped to an xrs[] entry + changeType string // "added", "modified", "removed" + kind string + name string + namePattern *regexp.Regexp + namespace string + fieldValues map[string]any // For added/removed: field path -> expected value + fieldChanges map[string][2]any // For modified: field path -> [old, new] + fieldValuePatterns map[string]*regexp.Regexp // For pattern matching field values + specMatch map[string]any // For strict spec matching + anyNameAllowed bool // If true, any name is accepted + + dryRun dryRunExpectation +} + +// dryRunExpectation holds the dryRun assertions. It is shared by the flat (xr) +// and downstream (comp) change builders because dryRun lives on ChangeDetail, +// which is the same per-resource wire shape for both commands — so asserting it +// two different ways would let the two drift apart. +// +// skipReason, when non-empty, asserts the change carries a dryRun object with +// that skipReason. mustBePerformed asserts the opposite — that no dryRun object +// is present, i.e. the desired state went through the apiserver. The two are +// mutually exclusive; both unset asserts nothing, which keeps every pre-existing +// expectation valid. +type dryRunExpectation struct { + skipReason string + detailSubstring string + storageVersion string + mustBePerformed bool +} + +// expectation returns the root builder. Only meaningful for a flat top-level +// change (r.parent set); an XR-scoped change (r.xrParent set) is built as a +// standalone sub-expression passed to WithXRs and is never walked back to a +// root, so r.parent is nil there and that is fine. +func (r *ResourceExpectation) expectation() *ExpectedDiff { + return r.parent +} + +// ExpectDiff creates a new ExpectedDiff builder. +func ExpectDiff() *ExpectedDiff { + return &ExpectedDiff{ + resources: make([]*ResourceExpectation, 0), + } +} + +// WithSummary sets the expected summary counts. +func (e *ExpectedDiff) WithSummary(added, modified, removed int) *ExpectedDiff { + e.summary = &expectedSummary{ + added: added, + modified: modified, + removed: removed, + } + + return e +} + +// WithAddedResource adds an expectation for an added resource. +func (e *ExpectedDiff) WithAddedResource(kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: e, + changeType: dt.DiffTypeWordAdded, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + fieldValuePatterns: make(map[string]*regexp.Regexp), + } + e.resources = append(e.resources, r) + + return r +} + +// WithModifiedResource adds an expectation for a modified resource. +func (e *ExpectedDiff) WithModifiedResource(kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: e, + changeType: dt.DiffTypeWordModified, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + fieldValuePatterns: make(map[string]*regexp.Regexp), + } + e.resources = append(e.resources, r) + + return r +} + +// ErrorExpectation describes one expected entry in the structured +// errors[] payload. Match is by ResourceID; ValidationFailures, when +// non-nil, asserts the per-resource typed validation breakdown. +type ErrorExpectation struct { + parent *ExpectedDiff + resourceID string + messageContains string + validationFailures []*ValidationFailureExpectation +} + +func (e *ErrorExpectation) expectation() *ExpectedDiff { return e.parent } + +// ValidationFailureExpectation describes one expected entry in +// errors[].validationFailures[]. Match is by APIVersion/Kind/Name; +// Errors asserts on the per-field validation entries within. +type ValidationFailureExpectation struct { + parent *ErrorExpectation + apiVersion string + kind string + name string + namespace string + status string // optional; if "", no assertion + errors []*FieldErrorExpectation // optional; nil = no assertion on per-field errors +} + +func (v *ValidationFailureExpectation) expectation() *ExpectedDiff { return v.parent.parent } + +// FieldErrorExpectation describes one expected entry in +// errors[].validationFailures[].errors[]. Match is by Type+Field +// (Field may be empty for non-localized errors); messageContains and +// value are optional refinements. +type FieldErrorExpectation struct { + parent *ValidationFailureExpectation + errType string + field string + messageContains string + hasValue bool + value any +} + +func (f *FieldErrorExpectation) expectation() *ExpectedDiff { return f.parent.parent.parent } + +// WithWarning asserts that structured output carries a warning whose message contains the supplied +// substring. Chain WithWarningContext to pin the machine-readable context pairs. +func (e *ExpectedDiff) WithWarning(messageContains string) *WarningExpectation { + w := &WarningExpectation{ + parent: e, + warningMatch: warningMatch{messageContains: messageContains}, + } + e.warnings = append(e.warnings, w) + + return w +} + +// WithNoWarnings asserts that structured output carries no advisories at all. Distinct from simply +// declaring no WithWarning expectations, which asserts nothing: this is how a test pins that a +// condition the tool used to warn about is correctly silent. +func (e *ExpectedDiff) WithNoWarnings() *ExpectedDiff { + e.noWarnings = true + return e +} + +// WithWarningContext pins the expected context key/value pairs on the warning under construction. +func (w *WarningExpectation) WithWarningContext(context map[string]string) *WarningExpectation { + w.context = context + return w +} + +// And returns to the parent builder. +func (w *WarningExpectation) And() *ExpectedDiff { return w.parent } + +// WithError attaches an expectation for an entry in the structured +// errors[] payload, matched by resourceID. Use the returned +// ErrorExpectation to add validationFailures expectations. +func (e *ExpectedDiff) WithError(resourceID string) *ErrorExpectation { + er := &ErrorExpectation{ + parent: e, + resourceID: resourceID, + } + e.errors = append(e.errors, er) + + return er +} + +// WithMessageContaining adds a substring assertion on the error's +// Message field. Optional — useful when machine consumers care about +// the human-readable message too. +func (e *ErrorExpectation) WithMessageContaining(s string) *ErrorExpectation { + e.messageContains = s + return e +} + +// WithValidationFailure adds an expectation for one entry in the +// error's validationFailures[] slice, matched by APIVersion/Kind/Name. +// namespace is optional (empty string skips the namespace assertion); +// pass the actual namespace to lock down namespaced resources. +func (e *ErrorExpectation) WithValidationFailure(apiVersion, kind, name, namespace string) *ValidationFailureExpectation { + v := &ValidationFailureExpectation{ + parent: e, + apiVersion: apiVersion, + kind: kind, + name: name, + namespace: namespace, + } + e.validationFailures = append(e.validationFailures, v) + + return v +} + +// AndError returns to the parent ExpectedDiff to chain another +// WithError call, mirroring the AndXR / AndComp helpers elsewhere in +// this file. +func (e *ErrorExpectation) AndError() *ExpectedDiff { + return e.parent +} + +// WithStatus pins the validation status on this resource entry +// ("invalid", "missingSchema"). Optional; empty status skips the +// assertion. +func (v *ValidationFailureExpectation) WithStatus(status string) *ValidationFailureExpectation { + v.status = status + return v +} + +// WithFieldError adds an expectation for one entry in the failure's +// errors[] slice, matched by errType ("schema", "cel", +// "unknownField", "defaulting") and field (empty matches +// non-localized errors like defaulting). +func (v *ValidationFailureExpectation) WithFieldError(errType, field string) *FieldErrorExpectation { + f := &FieldErrorExpectation{ + parent: v, + errType: errType, + field: field, + } + v.errors = append(v.errors, f) + + return f +} + +// AndValidationFailure returns to the parent ErrorExpectation to chain +// another WithValidationFailure. +func (v *ValidationFailureExpectation) AndValidationFailure() *ErrorExpectation { + return v.parent +} + +// WithMessageContaining adds a substring assertion on this field +// error's Message. Optional. +func (f *FieldErrorExpectation) WithMessageContaining(s string) *FieldErrorExpectation { + f.messageContains = s + return f +} + +// WithValue pins the bad value on this field error. Pass the value as +// it would arrive after JSON decoding (e.g. float64 for numbers, +// string for strings); for "any number is fine" omit this method. +func (f *FieldErrorExpectation) WithValue(v any) *FieldErrorExpectation { + f.hasValue = true + f.value = v + + return f +} + +// AndFieldError returns to the parent ValidationFailureExpectation to +// chain another WithFieldError. +func (f *FieldErrorExpectation) AndFieldError() *ValidationFailureExpectation { + return f.parent +} + +// WithRemovedResource adds an expectation for a removed resource. +func (e *ExpectedDiff) WithRemovedResource(kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: e, + changeType: dt.DiffTypeWordRemoved, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + fieldValuePatterns: make(map[string]*regexp.Regexp), + } + e.resources = append(e.resources, r) + + return r +} + +// WithField asserts a specific field exists with exact value (for added/removed resources). +func (r *ResourceExpectation) WithField(path string, value any) *ResourceExpectation { + r.fieldValues[path] = value + return r +} + +// WithFieldAbsent asserts the path carries no value at all (for added/removed resources). +// +// Named rather than spelled WithField(path, nil) at call sites, because "this field must not be +// here" is a different claim from "this field equals nothing" and reads as a typo otherwise. +func (r *ResourceExpectation) WithFieldAbsent(path string) *ResourceExpectation { + r.fieldValues[path] = nil + return r +} + +// WithFieldChange asserts a field changed from old to new value (for modified resources). +func (r *ResourceExpectation) WithFieldChange(path string, oldValue, newValue any) *ResourceExpectation { + r.fieldChanges[path] = [2]any{oldValue, newValue} + return r +} + +// WithDryRunSkipped asserts this change reports that its desired state was NOT +// verified against the apiserver, for the given reason (one of the +// dt.DryRunSkipReason values: "disabled", "forbidden", "webhookUnavailable"). +// +// Pass a detailSubstring to additionally require that the cluster's own +// explanation was propagated rather than swallowed; pass "" to skip that check. +func (r *ResourceExpectation) WithDryRunSkipped(reason, detailSubstring string) *ResourceExpectation { + r.dryRun.skipReason = reason + r.dryRun.detailSubstring = detailSubstring + + return r +} + +// WithDryRunStorageVersion asserts this change, reported as not verified (see +// WithDryRunSkipped), records version as the version its CRD stores it at: +// one other than the version it was requested at, whose defaults and +// conversion its locally predicted diff does not model. +func (r *ResourceExpectation) WithDryRunStorageVersion(version string) *ResourceExpectation { + r.dryRun.storageVersion = version + return r +} + +// WithDryRunPerformed asserts this change carries NO dryRun object, i.e. its +// desired state did go through the apiserver. Worth asserting explicitly on the +// happy path: without it, a regression that silently stopped dry-running +// additions would only be caught by whichever field assertions happened to +// depend on server-side defaulting. +func (r *ResourceExpectation) WithDryRunPerformed() *ResourceExpectation { + r.dryRun.mustBePerformed = true + return r +} + +// WithFieldAdded asserts a field was added (for modified resources). +// Equivalent to WithFieldChange(path, nil, value). +func (r *ResourceExpectation) WithFieldAdded(path string, value any) *ResourceExpectation { + return r.WithFieldChange(path, nil, value) +} + +// WithFieldRemoved asserts a field was removed (for modified resources). +// Equivalent to WithFieldChange(path, value, nil). +func (r *ResourceExpectation) WithFieldRemoved(path string, value any) *ResourceExpectation { + return r.WithFieldChange(path, value, nil) +} + +// WithFieldValuePattern asserts a field value matches a regex pattern. +// Use this for fields with generated/dynamic values like resource names with random suffixes. +func (r *ResourceExpectation) WithFieldValuePattern(path, pattern string) *ResourceExpectation { + r.fieldValuePatterns[path] = regexp.MustCompile(pattern) + return r +} + +// WithSpec asserts the entire spec matches (for strict equality). +func (r *ResourceExpectation) WithSpec(spec map[string]any) *ResourceExpectation { + r.specMatch = spec + return r +} + +// WithNamePattern matches resource name against a regex pattern instead of exact name. +func (r *ResourceExpectation) WithNamePattern(pattern string) *ResourceExpectation { + r.namePattern = regexp.MustCompile(pattern) + r.anyNameAllowed = false + + return r +} + +// WithAnyName allows any resource name (useful for generated names). +func (r *ResourceExpectation) WithAnyName() *ResourceExpectation { + r.anyNameAllowed = true + return r +} + +// And returns to the root ExpectedDiff to chain more expectations. +// Not needed at the end of a chain - AssertStructuredDiff accepts a +// ResourceExpectation directly. +func (r *ResourceExpectation) And() *ExpectedDiff { + return r.expectation() +} + +// Change opens a sibling change on the same enclosing XR entry (only valid on a +// change opened via XRExpectation.Change). Lets an XR's changes chain without a +// scope-closing call: xr.Change(...).FieldChange(...).Change(...). Panics if +// called on a flat top-level change (which has no enclosing XR). +func (r *ResourceExpectation) Change(changeType, kind, name, namespace string) *ResourceExpectation { + if r.xrParent == nil { + panic("Change chained on a flat (non-XR) change; open the change via XR(...).Change(...)") + } + + return r.xrParent.Change(changeType, kind, name, namespace) +} + +// XRExpectation defines expectations for a single entry in the xrs[] grouped +// view: the input XR's identity, its status, its per-XR summary, and its +// per-XR changes. Build one with XR(...), configure it with the methods below, +// then pass it (with any siblings) to ExpectedDiff.WithXRs. +type XRExpectation struct { + kind string + name string + namePattern *regexp.Regexp + namespace string + anyName bool + status string + summary *expectedSummary + changes []*ResourceExpectation + errorIDs []string +} + +// XR begins a standalone expectation for one xrs[] entry, keyed by the input +// XR's kind/name/namespace. Pass the result(s) to ExpectedDiff.WithXRs. +func XR(kind, name, namespace string) *XRExpectation { + return &XRExpectation{kind: kind, name: name, namespace: namespace} +} + +// xrSpec is satisfied by the two things a WithXRs argument can end on: an +// XRExpectation itself, or a ResourceExpectation returned by an XR's Change(…) +// sub-chain (which resolves back to its enclosing XR). This lets a per-XR +// sub-expression terminate on either a status/summary call or a field call +// without an explicit scope-closing step. +type xrSpec interface { + xrExpectation() *XRExpectation +} + +func (x *XRExpectation) xrExpectation() *XRExpectation { return x } + +func (r *ResourceExpectation) xrExpectation() *XRExpectation { return r.xrParent } + +// WithOutputVersion asserts the document's outputVersion ("v1" or "v2") and its shape: v1 carries the +// flat changes[], v2 does not. Under v2, assert changes per XR with WithXRs; the top-level resource +// expectations (WithAddedResource and friends) read the flat view and so are rejected. +func (e *ExpectedDiff) WithOutputVersion(version string) *ExpectedDiff { + e.outputVersion = version + return e +} + +// WithXRs attaches per-input-XR expectations (built via XR(...)) to assert +// against the xrs[] grouped view, in order. +func (e *ExpectedDiff) WithXRs(xrs ...xrSpec) *ExpectedDiff { + for _, x := range xrs { + e.xrs = append(e.xrs, x.xrExpectation()) + } + + return e +} + +// Build terminates the chain and returns the root expectation. Optional — +// AssertStructuredDiff accepts an *ExpectedDiff directly — but it reads as an +// explicit "done building" marker. +func (e *ExpectedDiff) Build() *ExpectedDiff { return e } + +// Status asserts the entry's status (changed/unchanged/error). +func (x *XRExpectation) Status(status string) *XRExpectation { + x.status = status + return x +} + +// Summary asserts the entry's per-XR summary counts. +func (x *XRExpectation) Summary(added, modified, removed int) *XRExpectation { + x.summary = &expectedSummary{added: added, modified: modified, removed: removed} + return x +} + +// AnyName matches the XR by kind/namespace only, accepting any name (useful for +// generated XR names). +func (x *XRExpectation) AnyName() *XRExpectation { + x.anyName = true + return x +} + +// NamePattern matches the XR name against a regex instead of an exact name. +func (x *XRExpectation) NamePattern(pattern string) *XRExpectation { + x.namePattern = regexp.MustCompile(pattern) + return x +} + +// Err asserts the entry carries an error with the given resourceID. +func (x *XRExpectation) Err(resourceID string) *XRExpectation { + x.errorIDs = append(x.errorIDs, resourceID) + return x +} + +// Change opens a change expectation scoped to this XR entry's changes[]. +// changeType is "added", "modified", or "removed". Returns a +// ResourceExpectation whose field methods (WithField/WithFieldChange/…) and +// chained Change(...) let a change and its siblings be expressed inline. +func (x *XRExpectation) Change(changeType, kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + xrParent: x, + changeType: changeType, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + fieldValuePatterns: make(map[string]*regexp.Regexp), + } + x.changes = append(x.changes, r) + + return r +} + +// ParseStructuredOutput parses JSON output into StructuredDiffOutput. +func ParseStructuredOutput(jsonOutput string) (StructuredDiffOutput, error) { + var output StructuredDiffOutput + if err := json.Unmarshal([]byte(jsonOutput), &output); err != nil { + return output, fmt.Errorf("failed to parse JSON output: %w", err) + } + + return output, nil +} + +// AssertStructuredDiff compares actual JSON output against expected. +// Accepts DiffExpectation interface so callers don't need to call And() to return to root. +func AssertStructuredDiff(t *testing.T, jsonOutput string, e DiffExpectation) { + t.Helper() + + expected := e.expectation() + + output, err := ParseStructuredOutput(jsonOutput) + if err != nil { + t.Fatalf("Failed to parse structured output: %v\nOutput was:\n%s", err, jsonOutput) + } + + // Check summary if specified + if expected.summary != nil { + if output.Summary.Added != expected.summary.added { + t.Errorf("Summary.Added: expected %d, got %d", expected.summary.added, output.Summary.Added) + } + + if output.Summary.Modified != expected.summary.modified { + t.Errorf("Summary.Modified: expected %d, got %d", expected.summary.modified, output.Summary.Modified) + } + + if output.Summary.Removed != expected.summary.removed { + t.Errorf("Summary.Removed: expected %d, got %d", expected.summary.removed, output.Summary.Removed) + } + } + + assertXROutputVersion(t, output, expected) + + // Check each resource expectation against the flat changes[] view. + assertResourceExpectations(t, "", output.Changes, expected.resources) + + // Check for unexpected resources + if len(expected.resources) > 0 && len(output.Changes) != len(expected.resources) { + t.Errorf("Expected %d changes, got %d", len(expected.resources), len(output.Changes)) + } + + // Check each error expectation against output.Errors. + for _, want := range expected.errors { + got := findMatchingError(output.Errors, want.resourceID) + if got == nil { + actualIDs := make([]string, 0, len(output.Errors)) + for _, e := range output.Errors { + actualIDs = append(actualIDs, e.ResourceID) + } + + t.Errorf("Expected error for resourceID %q not found in output. Actual error IDs: %v", + want.resourceID, actualIDs) + + continue + } + + assertErrorMatch(t, *got, want) + } + + // If the test pinned a specific number of error expectations, verify + // no extras were emitted. Zero expectations = no opinion on errors. + if len(expected.errors) > 0 && len(output.Errors) != len(expected.errors) { + t.Errorf("Expected %d errors in structured output, got %d", len(expected.errors), len(output.Errors)) + } + + wantWarnings := make([]warningMatch, 0, len(expected.warnings)) + for _, w := range expected.warnings { + wantWarnings = append(wantWarnings, w.warningMatch) + } + + assertWarnings(t, output.Warnings, wantWarnings, expected.noWarnings) + + // Check each xrs[] entry expectation. + assertXRExpectations(t, output.Xrs, expected.xrs) +} + +// assertXROutputVersion checks the xr document's outputVersion and the presence of the flat changes[] +// it implies, when the test pinned a version. +func assertXROutputVersion(t *testing.T, output StructuredDiffOutput, expected *ExpectedDiff) { + t.Helper() + + if expected.outputVersion == "" { + return + } + + if output.OutputVersion != expected.outputVersion { + t.Errorf("outputVersion: expected %q, got %q", expected.outputVersion, output.OutputVersion) + } + + switch expected.outputVersion { + case "v2": + if output.Changes != nil { + t.Errorf("v2 output carries the v1 flat changes[]: %d entries", len(output.Changes)) + } + + if len(expected.resources) > 0 { + t.Errorf("v2 output has no flat changes[]: assert changes per XR with WithXRs instead") + } + default: + if output.Changes == nil { + t.Errorf("v1 output lacks the flat changes[]") + } + } +} + +// assertWarnings checks each expectation against got, then — if the test pinned any expectations, or +// asserted there are none — verifies no extras were emitted, mirroring the errors[] guard. Without the +// count check, warning duplication is untestable: a regression that emits the same advisory once per +// XR still satisfies every find-first-match expectation. No expectations and no noWarnings = no opinion. +func assertWarnings(t *testing.T, got []OutputWarning, want []warningMatch, noWarnings bool) { + t.Helper() + + for _, w := range want { + assertWarningExpectation(t, got, w) + } + + if (len(want) > 0 || noWarnings) && len(got) != len(want) { + messages := make([]string, 0, len(got)) + for _, w := range got { + messages = append(messages, w.Message) + } + + t.Errorf("Expected %d warnings in structured output, got %d: %v", len(want), len(got), messages) + } +} + +// assertWarningExpectation finds a warning whose message contains want's substring and, when the +// expectation pins one, whose context map is exactly want's. +// +// Both conditions select the warning; context is not merely checked against the first message match. +// Several warnings legitimately share a message and differ only in context (one degraded-dry-run +// warning per GVK + namespace + cause, say), and an expectation for any one of them has to be able to +// find it rather than being compared against whichever happened to be raised first. +func assertWarningExpectation(t *testing.T, got []OutputWarning, want warningMatch) { + t.Helper() + + var contextsSeen []map[string]string + + for _, w := range got { + if !strings.Contains(w.Message, want.messageContains) { + continue + } + + if want.context == nil || reflect.DeepEqual(want.context, w.Context) { + return + } + + contextsSeen = append(contextsSeen, w.Context) + } + + if len(contextsSeen) > 0 { + t.Errorf("warning %q context mismatch: expected %v, got only %v", + want.messageContains, want.context, contextsSeen) + + return + } + + messages := make([]string, 0, len(got)) + for _, w := range got { + messages = append(messages, w.Message) + } + + t.Errorf("expected a warning containing %q in structured output; got %v", want.messageContains, messages) +} + +// assertResourceExpectations validates a set of ResourceExpectations against a +// changes[] slice. scope is a label ("" for the flat view, or an XR name) used +// only in failure messages. Extracted so both the flat changes[] and each +// xrs[] entry's changes[] can be validated with identical logic. +func assertResourceExpectations(t *testing.T, scope string, changes []ChangeDetail, resources []*ResourceExpectation) { + t.Helper() + + prefix := "" + if scope != "" { + prefix = "xr " + scope + ": " + } + + for _, expectRes := range resources { + found := findMatchingChange(changes, expectRes) + if found == nil { + actualResources := make([]string, 0, len(changes)) + for _, c := range changes { + actualResources = append(actualResources, fmt.Sprintf("%s %s/%s (ns=%s)", c.Type, c.Kind, c.Name, c.Namespace)) + } + + t.Errorf("%sExpected %s resource %s/%s (ns=%s) not found. Actual resources: %v", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, expectRes.namespace, actualResources) + + continue + } + + assertChangeFields(t, prefix, found, expectRes) + } +} + +// assertDryRun validates a change's dryRun expectations: either that the +// desired state was reported as NOT verified against the apiserver (with a +// specific reason, and optionally that the cluster's own explanation survived), +// or that no such report is present at all. +// id identifies the change under assertion in failure messages. +func assertDryRun(t *testing.T, id string, found *ChangeDetail, expect dryRunExpectation) { + t.Helper() + + if expect.mustBePerformed { + if found.DryRun != nil { + t.Errorf("%s: expected the desired state to have been dry-run against the apiserver, but it reports skipReason %q (detail: %q)", + id, found.DryRun.SkipReason, found.DryRun.Detail) + } + + return + } + + if expect.skipReason == "" { + return + } + + if found.DryRun == nil { + t.Errorf("%s: expected dryRun.skipReason %q, but no dryRun was reported (absence means the desired state went through the apiserver)", + id, expect.skipReason) + + return + } + + if found.DryRun.SkipReason != expect.skipReason { + t.Errorf("%s: dryRun.skipReason: expected %q, got %q", + id, expect.skipReason, found.DryRun.SkipReason) + } + + // Performed must be false whenever the object is present; see dt.DryRunInfo. + if found.DryRun.Performed { + t.Errorf("%s: dryRun.performed was true on a reported skip, which is contradictory", id) + } + + if expect.detailSubstring != "" && !strings.Contains(found.DryRun.Detail, expect.detailSubstring) { + t.Errorf("%s: dryRun.detail %q does not contain %q — the cluster's own explanation must be propagated, not swallowed", + id, found.DryRun.Detail, expect.detailSubstring) + } + + if expect.storageVersion != "" && found.DryRun.StorageVersion != expect.storageVersion { + t.Errorf("%s: dryRun.storageVersion: expected %q, got %q", + id, expect.storageVersion, found.DryRun.StorageVersion) + } +} + +// assertChangeFields validates the field-level expectations (exact values, +// old/new changes, value patterns, and strict spec match) of a single matched +// change. Split out of assertResourceExpectations to keep that function's +// cognitive complexity in check. +func assertChangeFields(t *testing.T, prefix string, found *ChangeDetail, expectRes *ResourceExpectation) { + t.Helper() + + assertDryRun(t, + fmt.Sprintf("%s%s %s/%s", prefix, expectRes.changeType, expectRes.kind, expectRes.name), + found, expectRes.dryRun) + + // Validate field values for added/removed resources + for path, expectedValue := range expectRes.fieldValues { + actualValue := getFieldFromDiff(found.Diff, expectRes.changeType, path) + if !reflect.DeepEqual(actualValue, expectedValue) { + t.Errorf("%s%s %s/%s: field %s: expected %v, got %v", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, expectedValue, actualValue) + } + } + + // Validate field changes for modified resources + for path, change := range expectRes.fieldChanges { + oldVal := getFieldFromDiff(found.Diff, dt.DiffKeyOld, path) + newVal := getFieldFromDiff(found.Diff, dt.DiffKeyNew, path) + + if !reflect.DeepEqual(oldVal, change[0]) { + t.Errorf("%s%s %s/%s: field %s old value: expected %v, got %v", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, change[0], oldVal) + } + + if !reflect.DeepEqual(newVal, change[1]) { + t.Errorf("%s%s %s/%s: field %s new value: expected %v, got %v", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, change[1], newVal) + } + } + + // Validate field value patterns + for path, pattern := range expectRes.fieldValuePatterns { + actualValue := getNewFieldValue(found.Diff, expectRes.changeType, path) + + actualStr := fmt.Sprintf("%v", actualValue) + if !pattern.MatchString(actualStr) { + t.Errorf("%s%s %s/%s: field %s value %q does not match pattern %q", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, actualStr, pattern.String()) + } + } + + // Validate spec match if specified + if expectRes.specMatch != nil { + spec := getFieldFromDiff(found.Diff, expectRes.changeType, "spec") + if specMap, ok := spec.(map[string]any); ok { + if !reflect.DeepEqual(specMap, expectRes.specMatch) { + t.Errorf("%s%s %s/%s: spec mismatch: expected %v, got %v", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, expectRes.specMatch, specMap) + } + } else { + t.Errorf("%s%s %s/%s: spec is not a map: %v", + prefix, expectRes.changeType, expectRes.kind, expectRes.name, spec) + } + } +} + +// assertXRExpectations validates xrs[] entry expectations against the actual +// grouped output. +// +// Each expectation consumes the entry it matches, so two expectations can never +// both resolve to the same entry, and the entry count is pinned once the test +// has an opinion on xrs[] at all. Without those guards a duplicated identity — +// exactly the symptom of issue #477 — satisfied every expectation that matched +// it and went unnoticed (issue #488). +func assertXRExpectations(t *testing.T, actual []XRDiffWire, expected []*XRExpectation) { + t.Helper() + + if len(expected) == 0 { + return + } + + if len(actual) != len(expected) { + actualXRs := make([]string, 0, len(actual)) + for _, x := range actual { + actualXRs = append(actualXRs, fmt.Sprintf("%s/%s (ns=%s)", x.XR.Kind, x.XR.Name, x.XR.Namespace)) + } + + t.Errorf("Expected %d xrs[] entries, got %d: %v", len(expected), len(actual), actualXRs) + } + + used := make([]bool, len(actual)) + + for _, want := range expected { + got := findMatchingXR(actual, used, want) + if got == nil { + actualXRs := make([]string, 0, len(actual)) + for _, x := range actual { + actualXRs = append(actualXRs, fmt.Sprintf("%s/%s (ns=%s, status=%s)", x.XR.Kind, x.XR.Name, x.XR.Namespace, x.Status)) + } + + t.Errorf("Expected xrs[] entry %s/%s (ns=%s) not found. Actual: %v", + want.kind, want.name, want.namespace, actualXRs) + + continue + } + + label := fmt.Sprintf("%s/%s", got.XR.Kind, got.XR.Name) + + if want.status != "" && got.Status != want.status { + t.Errorf("xr %s: status: expected %q, got %q", label, want.status, got.Status) + } + + if want.summary != nil { + if got.Summary.Added != want.summary.added || got.Summary.Modified != want.summary.modified || got.Summary.Removed != want.summary.removed { + t.Errorf("xr %s: summary: expected {added:%d modified:%d removed:%d}, got {added:%d modified:%d removed:%d}", + label, want.summary.added, want.summary.modified, want.summary.removed, + got.Summary.Added, got.Summary.Modified, got.Summary.Removed) + } + } + + assertResourceExpectations(t, label, got.Changes, want.changes) + + // Pin the entry's change count too, so a change the test did not ask + // for cannot hide inside a matched entry. + if len(want.changes) > 0 && len(got.Changes) != len(want.changes) { + t.Errorf("xr %s: expected %d changes, got %d", label, len(want.changes), len(got.Changes)) + } + + for _, id := range want.errorIDs { + if findMatchingError(got.Errors, id) == nil { + t.Errorf("xr %s: expected error with resourceID %q not found in entry errors", label, id) + } + } + } +} + +// findMatchingXR locates the first not-yet-consumed xrs[] entry matching an +// XRExpectation by kind/namespace and (unless anyName / a name pattern is used) +// exact name, marking it consumed in used. Consuming matters because identities +// need not be unique: without it, N expectations could all be satisfied by one +// entry (or by N byte-identical ones). +func findMatchingXR(actual []XRDiffWire, used []bool, want *XRExpectation) *XRDiffWire { + for i := range actual { + if used[i] { + continue + } + + x := &actual[i] + + if x.XR.Kind != want.kind || x.XR.Namespace != want.namespace { + continue + } + + switch { + case want.anyName: + case want.namePattern != nil: + if !want.namePattern.MatchString(x.XR.Name) { + continue + } + case x.XR.Name != want.name: + continue + } + + used[i] = true + + return x + } + + return nil +} + +// assertErrorMatch validates a single OutputError against its +// expectation. Split out from AssertStructuredDiff so the per-error +// branching doesn't push that function further over the gocognit +// threshold its existing exemption already silences. +func assertErrorMatch(t *testing.T, got OutputError, want *ErrorExpectation) { + t.Helper() + + if want.messageContains != "" && !strings.Contains(got.Message, want.messageContains) { + t.Errorf("error %q: message %q does not contain %q", + want.resourceID, got.Message, want.messageContains) + } + + for _, wantVF := range want.validationFailures { + gotVF := findMatchingValidationFailure(got.ValidationFailures, wantVF) + if gotVF == nil { + actual := make([]string, 0, len(got.ValidationFailures)) + for _, vf := range got.ValidationFailures { + actual = append(actual, fmt.Sprintf("%s/%s %s/%s", vf.APIVersion, vf.Kind, vf.Namespace, vf.Name)) + } + + t.Errorf("error %q: expected validationFailure %s/%s %s/%s not found. Actual: %v", + want.resourceID, wantVF.apiVersion, wantVF.kind, wantVF.namespace, wantVF.name, actual) + + continue + } + + assertValidationFailureMatch(t, *gotVF, wantVF) + } + + if len(want.validationFailures) > 0 && len(got.ValidationFailures) != len(want.validationFailures) { + t.Errorf("error %q: expected %d validationFailures, got %d", + want.resourceID, len(want.validationFailures), len(got.ValidationFailures)) + } +} + +// assertValidationFailureMatch validates a single +// ResourceValidationFailure against its expectation. +func assertValidationFailureMatch(t *testing.T, got ResourceValidationFailure, want *ValidationFailureExpectation) { + t.Helper() + + if want.status != "" && got.Status != want.status { + t.Errorf("validationFailure %s/%s %s: status: expected %q, got %q", + want.apiVersion, want.kind, want.name, want.status, got.Status) + } + + for _, wantFE := range want.errors { + gotFE := findMatchingFieldError(got.Errors, wantFE) + if gotFE == nil { + actual := make([]string, 0, len(got.Errors)) + for _, fe := range got.Errors { + actual = append(actual, fmt.Sprintf("[%s] %s", fe.Type, fe.Field)) + } + + t.Errorf("validationFailure %s/%s %s: expected fieldError [%s] %s not found. Actual: %v", + want.apiVersion, want.kind, want.name, wantFE.errType, wantFE.field, actual) + + continue + } + + if wantFE.messageContains != "" && !strings.Contains(gotFE.Message, wantFE.messageContains) { + t.Errorf("validationFailure %s/%s %s: fieldError [%s] %s: message %q does not contain %q", + want.apiVersion, want.kind, want.name, wantFE.errType, wantFE.field, + gotFE.Message, wantFE.messageContains) + } + + if wantFE.hasValue && !reflect.DeepEqual(gotFE.Value, wantFE.value) { + t.Errorf("validationFailure %s/%s %s: fieldError [%s] %s: value: expected %v (%T), got %v (%T)", + want.apiVersion, want.kind, want.name, wantFE.errType, wantFE.field, + wantFE.value, wantFE.value, gotFE.Value, gotFE.Value) + } + } + + if len(want.errors) > 0 && len(got.Errors) != len(want.errors) { + t.Errorf("validationFailure %s/%s %s: expected %d field errors, got %d", + want.apiVersion, want.kind, want.name, len(want.errors), len(got.Errors)) + } +} + +func findMatchingError(errs []OutputError, resourceID string) *OutputError { + for i := range errs { + if errs[i].ResourceID == resourceID { + return &errs[i] + } + } + + return nil +} + +func findMatchingValidationFailure(vfs []ResourceValidationFailure, want *ValidationFailureExpectation) *ResourceValidationFailure { + for i := range vfs { + v := &vfs[i] + if v.APIVersion != want.apiVersion || v.Kind != want.kind || v.Name != want.name { + continue + } + + if want.namespace != "" && v.Namespace != want.namespace { + continue + } + + return v + } + + return nil +} + +func findMatchingFieldError(fes []FieldValidationError, want *FieldErrorExpectation) *FieldValidationError { + for i := range fes { + fe := &fes[i] + if fe.Type == want.errType && fe.Field == want.field { + return fe + } + } + + return nil +} + +// findMatchingChange finds a change that matches the resource expectation. +func findMatchingChange(changes []ChangeDetail, expect *ResourceExpectation) *ChangeDetail { + for i := range changes { + change := &changes[i] + if change.Type != expect.changeType { + continue + } + + if change.Kind != expect.kind { + continue + } + + if expect.namespace != "" && change.Namespace != expect.namespace { + continue + } + + // Check name matching + if expect.anyNameAllowed { + return change + } + + if expect.namePattern != nil { + if expect.namePattern.MatchString(change.Name) { + return change + } + + continue + } + + if change.Name == expect.name { + return change + } + } + + return nil +} + +// getFieldFromDiff extracts a field value from the diff structure. +// For added/removed, key is "spec"; for modified, key is "old" or "new". +func getFieldFromDiff(diff map[string]any, key, path string) any { + // Handle special keys for modified resources + var root any + + switch key { + case dt.DiffKeyOld, dt.DiffKeyNew: + root = diff[key] + case dt.DiffTypeWordAdded, dt.DiffTypeWordRemoved: + root = diff[dt.DiffKeySpec] + default: + root = diff[key] + } + + if root == nil { + return nil + } + + return getNestedField(root, path) +} + +// getNewFieldValue returns the "new" field value for assertions. +// For modified resources, it returns the value from the "new" state. +// For added/removed resources, it returns the value from the "spec". +func getNewFieldValue(diff map[string]any, changeType, path string) any { + if changeType == dt.DiffTypeWordModified { + return getFieldFromDiff(diff, dt.DiffKeyNew, path) + } + + return getFieldFromDiff(diff, changeType, path) +} + +// getNestedField extracts a nested field using k8s jsonpath. +// Examples: +// - "spec.forProvider.configData" - simple path +// - "spec.pipeline[0].functionRef.name" - array index +// - "spec.pipeline[1].input.resources[0].patches[0].transforms" - multiple array indices +func getNestedField(obj any, path string) any { + if path == "" { + return obj + } + + // Convert bracket notation to k8s jsonpath escaped format + // "metadata.annotations['key.with.dots']" -> "metadata.annotations.key\.with\.dots" + path = convertBracketNotation(path) + + // Wrap path in k8s jsonpath template syntax: "spec.foo[0]" -> "{.spec.foo[0]}" + jp := jsonpath.New("field") + jp.AllowMissingKeys(true) + + if err := jp.Parse("{." + path + "}"); err != nil { + return nil + } + + results, err := jp.FindResults(obj) + if err != nil || len(results) == 0 || len(results[0]) == 0 { + return nil + } + + return results[0][0].Interface() +} + +// convertBracketNotation converts bracket notation for map keys to k8s jsonpath escaped format. +// Examples: +// - "metadata.annotations['key.with.dots']" -> "metadata.annotations.key\.with\.dots" +// - "spec.data['my-key']" -> "spec.data.my-key" +// - "spec.pipeline[0].name" -> "spec.pipeline[0].name" (array indices unchanged) +func convertBracketNotation(path string) string { + // Pattern matches: ['key'] or ["key"] where key can contain dots + bracketPattern := regexp.MustCompile(`\['([^']+)'\]|\["([^"]+)"\]`) + + return bracketPattern.ReplaceAllStringFunc(path, func(match string) string { + // Extract the key from ['key'] or ["key"] + key := match[2 : len(match)-2] // Remove [' and '] or [" and "] + + // Escape dots in the key for k8s jsonpath + escapedKey := strings.ReplaceAll(key, ".", `\.`) + + // Return as dot notation with escaped dots + return "." + escapedKey + }) +} + +// --- Composition Diff Types and Assertions --- +// +// Note: We use reflect.DeepEqual for value comparisons. This works because all +// field values in our test expectations are strings. If we ever need to compare +// numeric values (where JSON decodes everything as float64), we can switch to +// YAML output format which preserves int vs float64 distinction. + +// StructuredCompDiffOutput mirrors the JSON schema for composition diffs. +type StructuredCompDiffOutput struct { + OutputVersion string `json:"outputVersion"` + Compositions []CompositionDiffWire `json:"compositions"` + Errors []OutputError `json:"errors,omitempty"` + Warnings []OutputWarning `json:"warnings,omitempty"` +} + +// OutputError mirrors dt.OutputError. +type OutputError struct { + ResourceID string `json:"resourceID,omitempty"` + Message string `json:"message"` + ValidationFailures []ResourceValidationFailure `json:"validationFailures,omitempty"` +} + +// ResourceValidationFailure mirrors dt.ResourceValidationFailure. +type ResourceValidationFailure struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + Status string `json:"status"` + Errors []FieldValidationError `json:"errors,omitempty"` +} + +// FieldValidationError mirrors dt.FieldValidationError. +type FieldValidationError struct { + Type string `json:"type"` + Field string `json:"field,omitempty"` + Message string `json:"message"` + Value any `json:"value,omitempty"` +} + +// CompositionDiffWire mirrors compositionDiffWire from the renderer. +type CompositionDiffWire struct { + Name string `json:"name"` + Error string `json:"error,omitempty"` // v1 only + Errors []OutputError `json:"errors,omitempty"` // v2 only + CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` + AffectedResources AffectedResourcesSummary `json:"affectedResources"` + ImpactAnalysis []XRImpactWire `json:"impactAnalysis"` + // ImpactAnalysisSkipped distinguishes "not evaluated" from "no affected composites found". + ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` + // MaskedChangesOnly records that an absent compositionChanges does not mean unchanged. + MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` + // RevisionImpact mirrors renderer.RevisionImpact. + RevisionImpact RevisionImpactWire `json:"revisionImpact"` +} + +// RevisionImpactWire mirrors renderer.RevisionImpact. +type RevisionImpactWire struct { + ChangeScope string `json:"changeScope"` + CreatesRevision bool `json:"createsRevision"` + RepointedComposites int `json:"repointedComposites"` + PredictedRevisionName string `json:"predictedRevisionName,omitempty"` +} + +// AffectedResourcesSummary mirrors renderer.AffectedResourcesSummary. +type AffectedResourcesSummary struct { + Total int `json:"total"` + WithChanges int `json:"withChanges"` + Unchanged int `json:"unchanged"` + WithErrors int `json:"withErrors"` + FilteredByPolicy int `json:"filteredByPolicy,omitempty"` + FilteredBySelector int `json:"filteredBySelector,omitempty"` + FilteredByDeletion int `json:"filteredByDeletion,omitempty"` +} + +// XRImpactWire mirrors an impactAnalysis[] entry of either version: v1's xrImpactWire (identity +// inlined, error string, downstreamChanges wrapper) or v2's xrEntryWire (the fields of XREntryWire). +// The two share only status, filterReason and filterDetail, so a v2 entry leaves the v1-only fields +// zero and vice versa; assertXRImpactShape checks that. Read it through ref, downstream and +// errorText, which work for both. +type XRImpactWire struct { + // v1 only. + APIVersion string `json:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + UID string `json:"uid,omitempty"` + Error string `json:"error,omitempty"` + DownstreamChanges *DownstreamChanges `json:"downstreamChanges,omitempty"` + + // Both versions. + Status string `json:"status"` + FilterReason string `json:"filterReason,omitempty"` + FilterDetail string `json:"filterDetail,omitempty"` + + // v2 only. Pointers and nil slices so their absence from a v1 entry is detectable. + XR *corev1.ObjectReference `json:"xr,omitempty"` + Summary *Summary `json:"summary,omitempty"` + Changes []ChangeDetail `json:"changes,omitempty"` + Errors []OutputError `json:"errors,omitempty"` +} + +// ref returns the entry's XR identity. +func (x *XRImpactWire) ref() corev1.ObjectReference { + if x.XR != nil { + return *x.XR + } + + return corev1.ObjectReference{APIVersion: x.APIVersion, Kind: x.Kind, Name: x.Name, Namespace: x.Namespace} +} + +// downstream returns the entry's changes, or nil when it lists none, matching v1's absent +// downstreamChanges. +func (x *XRImpactWire) downstream() *DownstreamChanges { + switch { + case x.DownstreamChanges != nil: + return x.DownstreamChanges + case len(x.Changes) > 0 && x.Summary != nil: + return &DownstreamChanges{Summary: *x.Summary, Changes: x.Changes} + default: + return nil + } +} + +// errorText returns the entry's error message: v1's error string, or v2's errors[] messages joined. +func (x *XRImpactWire) errorText() string { + if x.Error != "" { + return x.Error + } + + msgs := make([]string, 0, len(x.Errors)) + for _, e := range x.Errors { + msgs = append(msgs, e.Message) + } + + return strings.Join(msgs, "; ") +} + +// assertXRImpactShape checks an impactAnalysis[] entry carries exactly its version's fields. +func assertXRImpactShape(t *testing.T, label, version string, x *XRImpactWire) { + t.Helper() + + v1Fields := x.Kind != "" || x.APIVersion != "" || x.Name != "" || x.Error != "" || x.DownstreamChanges != nil + v2Fields := x.XR != nil || x.Summary != nil || x.Changes != nil || x.Errors != nil + + switch version { + case "v2": + if v1Fields { + t.Errorf("%s: v2 entry carries v1 fields: %+v", label, *x) + } + + // summary and changes are always present in v2; changes is [] when there are none. + if x.XR == nil || x.Summary == nil || x.Changes == nil { + t.Errorf("%s: v2 entry lacks xr, summary or changes: %+v", label, *x) + } + default: + if v2Fields { + t.Errorf("%s: v1 entry carries v2 fields: %+v", label, *x) + } + } +} + +// DownstreamChanges mirrors renderer.DownstreamChanges. +type DownstreamChanges struct { + Summary Summary `json:"summary"` + Changes []ChangeDetail `json:"changes"` +} + +// ParseStructuredCompOutput parses JSON output into StructuredCompDiffOutput. +func ParseStructuredCompOutput(jsonOutput string) (StructuredCompDiffOutput, error) { + var output StructuredCompDiffOutput + if err := json.Unmarshal([]byte(jsonOutput), &output); err != nil { + return output, fmt.Errorf("failed to parse comp diff JSON output: %w", err) + } + + return output, nil +} + +// CompDiffExpectation is an interface that allows AssertStructuredCompDiff to accept +// any level of the builder hierarchy, eliminating the need for And()/AndXR()/AndComp(). +type CompDiffExpectation interface { + compExpectation() *ExpectedCompDiff +} + +// ExpectedCompDiff is a fluent builder for test expectations on composition diff output. +type ExpectedCompDiff struct { + compositions []*CompositionExpectation + warnings []*CompWarningExpectation + // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". + noWarnings bool + // outputVersion, when set, asserts the document's outputVersion and that every composition and + // impactAnalysis[] entry has that version's shape. + outputVersion string + // errorIDs are resourceIDs the top-level errors[] must contain. + errorIDs []string +} + +func (e *ExpectedCompDiff) compExpectation() *ExpectedCompDiff { return e } + +// CompWarningExpectation describes one expected entry in a comp diff's warnings[]. +type CompWarningExpectation struct { + warningMatch + + parent *ExpectedCompDiff +} + +func (w *CompWarningExpectation) compExpectation() *ExpectedCompDiff { return w.parent } + +// WithWarning asserts that structured output carries a warning whose message contains the supplied +// substring. Chain WithWarningContext to pin the machine-readable context pairs. +func (e *ExpectedCompDiff) WithWarning(messageContains string) *CompWarningExpectation { + w := &CompWarningExpectation{ + parent: e, + warningMatch: warningMatch{messageContains: messageContains}, + } + e.warnings = append(e.warnings, w) + + return w +} + +// WithOutputVersion asserts the document's outputVersion ("v1" or "v2"), and that each composition and +// impactAnalysis[] entry carries exactly that version's fields. +func (e *ExpectedCompDiff) WithOutputVersion(version string) *ExpectedCompDiff { + e.outputVersion = version + return e +} + +// WithError asserts the top-level errors[] contains an error with the given resourceID. +func (e *ExpectedCompDiff) WithError(resourceID string) *ExpectedCompDiff { + e.errorIDs = append(e.errorIDs, resourceID) + return e +} + +// WithNoWarnings asserts that structured output carries no advisories at all. Distinct from simply +// declaring no WithWarning expectations, which asserts nothing: this is how a test pins that a +// condition the tool warns about elsewhere is correctly silent here. +func (e *ExpectedCompDiff) WithNoWarnings() *ExpectedCompDiff { + e.noWarnings = true + return e +} + +// WithWarningContext pins the expected context key/value pairs on the warning under construction. +func (w *CompWarningExpectation) WithWarningContext(context map[string]string) *CompWarningExpectation { + w.context = context + return w +} + +// And returns to the parent builder. +func (w *CompWarningExpectation) And() *ExpectedCompDiff { return w.parent } + +// CompositionExpectation defines expectations for a single composition in the diff. +type CompositionExpectation struct { + parent *ExpectedCompDiff + name string + affectedTotal *int + affectedWithChanges *int + affectedUnchanged *int + affectedWithErrors *int + affectedFilteredByDeletion *int + xrImpacts []*XRImpactExpectation + // Composition changes expectations + compositionChangeType string // "modified" - composition changes are always modifications + compositionFieldChanges map[string][2]any // For modified: field path -> [old, new] + // impactAnalysisSkipped, when set, asserts the composites were deliberately not evaluated. + impactAnalysisSkipped *bool + // revisionImpact, when set, asserts what applying the composition does to CompositionRevisions. + revisionImpact *expectedRevisionImpact + // predictedRevisionName, when set, asserts revisionImpact.predictedRevisionName. Set independently + // of revisionImpact so a case can assert the name alone, and because the name is usually matched by + // pattern (its suffix is a content hash) while the rest are exact. + predictedRevisionName *expectedPredictedRevisionName + // errorContains, when set, is a substring the composition's error must contain: v1's error string, + // or a message in v2's errors[]. + errorContains string +} + +// expectedRevisionImpact is the expected revisionImpact object for a composition. +type expectedRevisionImpact struct { + changeScope string + createsRevision bool + repointedComposites int +} + +// expectedPredictedRevisionName is the expected revisionImpact.predictedRevisionName. A pattern rather +// than a literal because the name's suffix is the first 7 hex digits of the composition's hash, so +// pinning it exactly would make every fixture edit a test failure. The derivation itself is pinned +// exactly by the revisionIdentity unit test, which is where that belongs. +// +// An empty pattern asserts the field is absent, which is a meaningful outcome in its own right: the +// name was not predictable. +type expectedPredictedRevisionName struct { + pattern string +} + +func (c *CompositionExpectation) compExpectation() *ExpectedCompDiff { return c.parent } + +// XRImpactExpectation defines expectations for a single XR impact. +type XRImpactExpectation struct { + parent *CompositionExpectation + kind string + name string + namespace string + anyNameAllowed bool + status string // "changed", "unchanged", "error", "filtered" + filterReason string // "manual_policy", "revision_selector_mismatch", "deleting"; only checked when set + errorContains string // substring of an "error" impact's Error; only checked when set + errorIDs []string // resourceIDs the entry's v2 errors[] must contain + downstreamSummary *expectedSummary + downstreamResources []*DownstreamResourceExpectation +} + +func (x *XRImpactExpectation) compExpectation() *ExpectedCompDiff { return x.parent.parent } + +// ExpectCompDiff creates a new ExpectedCompDiff builder. +func ExpectCompDiff() *ExpectedCompDiff { + return &ExpectedCompDiff{ + compositions: make([]*CompositionExpectation, 0), + } +} + +// WithComposition adds an expectation for a composition. +func (e *ExpectedCompDiff) WithComposition(name string) *CompositionExpectation { + c := &CompositionExpectation{ + parent: e, + name: name, + xrImpacts: make([]*XRImpactExpectation, 0), + } + e.compositions = append(e.compositions, c) + + return c +} + +// WithAffectedResources sets the expected affected resources counts. +func (c *CompositionExpectation) WithAffectedResources(total, withChanges, unchanged, withErrors int) *CompositionExpectation { + c.affectedTotal = &total + c.affectedWithChanges = &withChanges + c.affectedUnchanged = &unchanged + c.affectedWithErrors = &withErrors + + return c +} + +// WithFilteredByDeletion asserts the number of XRs excluded from impact analysis because they are +// being deleted (AffectedResourcesSummary.FilteredByDeletion). A single-purpose setter rather than a +// positional counterpart to WithAffectedResources, so a caller cannot transpose filter counters. +func (c *CompositionExpectation) WithFilteredByDeletion(count int) *CompositionExpectation { + c.affectedFilteredByDeletion = &count + + return c +} + +// WithImpactAnalysisSkipped asserts that the composites were deliberately not evaluated, which is +// what distinguishes "we did not look" from "we looked and found no affected composites" — both of +// which leave impactAnalysis empty. +func (c *CompositionExpectation) WithImpactAnalysisSkipped() *CompositionExpectation { + skipped := true + c.impactAnalysisSkipped = &skipped + + return c +} + +// WithRevisionImpact asserts what applying the composition does to CompositionRevisions, independent +// of whether anything renders differently. changeScope is "none", "metadata" or "spec". +func (c *CompositionExpectation) WithRevisionImpact(changeScope string, createsRevision bool, repointedComposites int) *CompositionExpectation { + c.revisionImpact = &expectedRevisionImpact{ + changeScope: changeScope, + createsRevision: createsRevision, + repointedComposites: repointedComposites, + } + + return c +} + +// WithPredictedRevisionNamePattern asserts that revisionImpact.predictedRevisionName matches pattern +// (an unanchored regexp unless the pattern anchors itself). Use for the usual case, where the name's +// hash suffix is not worth pinning in an end-to-end test. +func (c *CompositionExpectation) WithPredictedRevisionNamePattern(pattern string) *CompositionExpectation { + c.predictedRevisionName = &expectedPredictedRevisionName{pattern: pattern} + + return c +} + +// WithoutPredictedRevisionName asserts that revisionImpact carries no predictedRevisionName — i.e. the +// revision's identity could not be predicted. Distinct from simply not asserting on the field. +func (c *CompositionExpectation) WithoutPredictedRevisionName() *CompositionExpectation { + c.predictedRevisionName = &expectedPredictedRevisionName{pattern: ""} + + return c +} + +// WithCompositionModified asserts that the composition itself is modified. +func (c *CompositionExpectation) WithCompositionModified() *CompositionExpectation { + c.compositionChangeType = dt.DiffTypeWordModified + if c.compositionFieldChanges == nil { + c.compositionFieldChanges = make(map[string][2]any) + } + + return c +} + +// WithCompositionFieldChange asserts a specific field in the composition changed from old to new value. +func (c *CompositionExpectation) WithCompositionFieldChange(path string, oldValue, newValue any) *CompositionExpectation { + if c.compositionFieldChanges == nil { + c.compositionFieldChanges = make(map[string][2]any) + } + + c.compositionFieldChanges[path] = [2]any{oldValue, newValue} + + return c +} + +// WithCompositionErrorContaining asserts the composition failed with an error containing s: v1's +// error string, or a message in v2's errors[]. +func (c *CompositionExpectation) WithCompositionErrorContaining(s string) *CompositionExpectation { + c.errorContains = s + return c +} + +// WithXRImpact adds an expectation for an XR impact. +func (c *CompositionExpectation) WithXRImpact(kind, name, namespace, status string) *XRImpactExpectation { + x := &XRImpactExpectation{ + parent: c, + kind: kind, + name: name, + namespace: namespace, + status: status, + } + c.xrImpacts = append(c.xrImpacts, x) + + return x +} + +// WithAnyName allows any XR name (useful for generated names). +func (x *XRImpactExpectation) WithAnyName() *XRImpactExpectation { + x.anyNameAllowed = true + return x +} + +// WithFilterReason pins the expected FilterReason on a filtered XR impact (e.g. "manual_policy", +// "revision_selector_mismatch", or "deleting"). Only asserted when set. +func (x *XRImpactExpectation) WithFilterReason(reason string) *XRImpactExpectation { + x.filterReason = reason + return x +} + +// WithErrorContaining pins a substring of an "error" XR impact's Error, so a test can assert why +// the composite failed rather than only that it did. Only asserted when set. +func (x *XRImpactExpectation) WithErrorContaining(s string) *XRImpactExpectation { + x.errorContains = s + return x +} + +// WithErrorResourceID asserts the entry's errors[] (v2 only) holds an error with the given resourceID. +func (x *XRImpactExpectation) WithErrorResourceID(resourceID string) *XRImpactExpectation { + x.errorIDs = append(x.errorIDs, resourceID) + return x +} + +// WithDownstreamSummary sets the expected downstream changes summary. +func (x *XRImpactExpectation) WithDownstreamSummary(added, modified, removed int) *XRImpactExpectation { + x.downstreamSummary = &expectedSummary{ + added: added, + modified: modified, + removed: removed, + } + + return x +} + +// WithDownstreamResource adds an expectation for a downstream resource change. +// Use this to assert on specific field-level changes in downstream resources. +func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, namespace string) *DownstreamResourceExpectation { + r := &DownstreamResourceExpectation{ + parent: x, + changeType: changeType, + kind: kind, + name: name, + namespace: namespace, + fieldChanges: make(map[string][2]any), + fieldValues: make(map[string]any), + fieldValuePatterns: make(map[string]*regexp.Regexp), + } + x.downstreamResources = append(x.downstreamResources, r) + + return r +} + +// DownstreamResourceExpectation defines expectations for a downstream resource change. +type DownstreamResourceExpectation struct { + parent *XRImpactExpectation + changeType string // "added", "modified", "removed" + kind string + name string + namePattern *regexp.Regexp + namespace string + anyNameAllowed bool + fieldChanges map[string][2]any // For modified: field path -> [old, new] + fieldValues map[string]any // For added/removed: field path -> expected value + fieldValuePatterns map[string]*regexp.Regexp // For pattern matching field values + dryRun dryRunExpectation +} + +func (d *DownstreamResourceExpectation) compExpectation() *ExpectedCompDiff { + return d.parent.parent.parent +} + +// WithFieldChange asserts a field changed from old to new value (for modified resources). +func (d *DownstreamResourceExpectation) WithFieldChange(path string, oldValue, newValue any) *DownstreamResourceExpectation { + d.fieldChanges[path] = [2]any{oldValue, newValue} + return d +} + +// WithFieldAdded asserts a field was added (for modified resources). +// Equivalent to WithFieldChange(path, nil, value). +func (d *DownstreamResourceExpectation) WithFieldAdded(path string, value any) *DownstreamResourceExpectation { + return d.WithFieldChange(path, nil, value) +} + +// WithFieldRemoved asserts a field was removed (for modified resources). +// Equivalent to WithFieldChange(path, value, nil). +func (d *DownstreamResourceExpectation) WithFieldRemoved(path string, value any) *DownstreamResourceExpectation { + return d.WithFieldChange(path, value, nil) +} + +// WithField asserts a specific field exists with exact value (for added/removed resources). +func (d *DownstreamResourceExpectation) WithField(path string, value any) *DownstreamResourceExpectation { + d.fieldValues[path] = value + return d +} + +// WithFieldValuePattern asserts a field value matches a regex pattern. +// Use this for fields with generated/dynamic values like resource names with random suffixes. +func (d *DownstreamResourceExpectation) WithFieldValuePattern(path, pattern string) *DownstreamResourceExpectation { + d.fieldValuePatterns[path] = regexp.MustCompile(pattern) + return d +} + +// WithDryRunSkipped asserts this downstream change reports that its desired state +// was NOT verified against the apiserver, for the given reason (one of the +// dt.DryRunSkipReason values: "disabled", "forbidden", "webhookUnavailable"). +// +// Pass a detailSubstring to additionally require that the cluster's own +// explanation was propagated rather than swallowed; pass "" to skip that check. +func (d *DownstreamResourceExpectation) WithDryRunSkipped(reason, detailSubstring string) *DownstreamResourceExpectation { + d.dryRun.skipReason = reason + d.dryRun.detailSubstring = detailSubstring + + return d +} + +// WithDryRunPerformed asserts this downstream change carries NO dryRun object, +// i.e. its desired state did go through the apiserver. See the ResourceExpectation +// counterpart for why the happy path is worth asserting explicitly. +func (d *DownstreamResourceExpectation) WithDryRunPerformed() *DownstreamResourceExpectation { + d.dryRun.mustBePerformed = true + return d +} + +// WithAnyName allows any resource name (useful for generated names). +func (d *DownstreamResourceExpectation) WithAnyName() *DownstreamResourceExpectation { + d.anyNameAllowed = true + return d +} + +// WithNamePattern matches resource name against a regex pattern. +func (d *DownstreamResourceExpectation) WithNamePattern(pattern string) *DownstreamResourceExpectation { + d.namePattern = regexp.MustCompile(pattern) + return d +} + +// AndXR returns to parent to chain more downstream resource expectations. +// Not needed at the end of a chain - AssertStructuredCompDiff accepts DownstreamResourceExpectation directly. +func (d *DownstreamResourceExpectation) AndXR() *XRImpactExpectation { + return d.parent +} + +// AndComp returns to parent to chain more XR impact expectations. +// Not needed at the end of a chain - AssertStructuredCompDiff accepts XRImpactExpectation directly. +func (x *XRImpactExpectation) AndComp() *CompositionExpectation { + return x.parent +} + +// And returns to parent to chain more composition expectations. +// Not needed at the end of a chain - AssertStructuredCompDiff accepts CompositionExpectation directly. +func (c *CompositionExpectation) And() *ExpectedCompDiff { + return c.parent +} + +// AndComposition starts an expectation for another composition, from anywhere in the current +// composition's chain. It exists because the only climb a caller genuinely needs is the one back to +// the root to add a sibling — every other And()/AndXR()/AndComp() is avoidable, since the assert +// functions accept any builder level. Spelling the sibling case as one intention-named call keeps a +// multi-composition expectation readable instead of ending each composition with a run of climbs +// whose purpose is not obvious from reading them. +func (d *DownstreamResourceExpectation) AndComposition(name string) *CompositionExpectation { + return d.compExpectation().WithComposition(name) +} + +// AndComposition starts an expectation for another composition. See +// DownstreamResourceExpectation.AndComposition. +func (x *XRImpactExpectation) AndComposition(name string) *CompositionExpectation { + return x.compExpectation().WithComposition(name) +} + +// AndComposition starts an expectation for another composition. See +// DownstreamResourceExpectation.AndComposition. +func (c *CompositionExpectation) AndComposition(name string) *CompositionExpectation { + return c.compExpectation().WithComposition(name) +} + +// AssertStructuredCompDiff compares actual JSON output against expected. +// Accepts CompDiffExpectation interface so callers don't need to call And()/AndXR()/AndComp(). +// +//nolint:gocognit // Test assertion function with necessary branching for comprehensive validation +func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e CompDiffExpectation) { + t.Helper() + + expected := e.compExpectation() + + output, err := ParseStructuredCompOutput(jsonOutput) + if err != nil { + t.Fatalf("Failed to parse structured comp output: %v\nOutput was:\n%s", err, jsonOutput) + } + + wantWarnings := make([]warningMatch, 0, len(expected.warnings)) + for _, w := range expected.warnings { + wantWarnings = append(wantWarnings, w.warningMatch) + } + + assertWarnings(t, output.Warnings, wantWarnings, expected.noWarnings) + + assertCompOutputVersion(t, output, expected) + + for _, id := range expected.errorIDs { + if findMatchingError(output.Errors, id) == nil { + t.Errorf("Expected top-level error with resourceID %q not found", id) + } + } + + // Check each composition expectation + for _, expectComp := range expected.compositions { + found := findMatchingComposition(output.Compositions, expectComp.name) + if found == nil { + actualComps := make([]string, 0, len(output.Compositions)) + for _, c := range output.Compositions { + actualComps = append(actualComps, c.Name) + } + + t.Errorf("Expected composition %s not found in output. Actual compositions: %v", + expectComp.name, actualComps) + + continue + } + + if expectComp.errorContains != "" { + var text strings.Builder + text.WriteString(found.Error) + for _, e := range found.Errors { + text.WriteString(e.Message) + } + + if !strings.Contains(text.String(), expectComp.errorContains) { + t.Errorf("Composition %s: expected error containing %q, got %q", expectComp.name, expectComp.errorContains, text.String()) + } + } + + // Check affected resources counts + if expectComp.affectedTotal != nil && found.AffectedResources.Total != *expectComp.affectedTotal { + t.Errorf("Composition %s: AffectedResources.Total: expected %d, got %d", + expectComp.name, *expectComp.affectedTotal, found.AffectedResources.Total) + } + + if expectComp.affectedWithChanges != nil && found.AffectedResources.WithChanges != *expectComp.affectedWithChanges { + t.Errorf("Composition %s: AffectedResources.WithChanges: expected %d, got %d", + expectComp.name, *expectComp.affectedWithChanges, found.AffectedResources.WithChanges) + } + + if expectComp.affectedUnchanged != nil && found.AffectedResources.Unchanged != *expectComp.affectedUnchanged { + t.Errorf("Composition %s: AffectedResources.Unchanged: expected %d, got %d", + expectComp.name, *expectComp.affectedUnchanged, found.AffectedResources.Unchanged) + } + + if expectComp.affectedWithErrors != nil && found.AffectedResources.WithErrors != *expectComp.affectedWithErrors { + t.Errorf("Composition %s: AffectedResources.WithErrors: expected %d, got %d", + expectComp.name, *expectComp.affectedWithErrors, found.AffectedResources.WithErrors) + } + + if expectComp.affectedFilteredByDeletion != nil && found.AffectedResources.FilteredByDeletion != *expectComp.affectedFilteredByDeletion { + t.Errorf("Composition %s: AffectedResources.FilteredByDeletion: expected %d, got %d", + expectComp.name, *expectComp.affectedFilteredByDeletion, found.AffectedResources.FilteredByDeletion) + } + + if expectComp.impactAnalysisSkipped != nil && found.ImpactAnalysisSkipped != *expectComp.impactAnalysisSkipped { + t.Errorf("Composition %s: ImpactAnalysisSkipped: expected %t, got %t", + expectComp.name, *expectComp.impactAnalysisSkipped, found.ImpactAnalysisSkipped) + } + + if want := expectComp.revisionImpact; want != nil { + got := found.RevisionImpact + + if got.ChangeScope != want.changeScope { + t.Errorf("Composition %s: RevisionImpact.ChangeScope: expected %q, got %q", + expectComp.name, want.changeScope, got.ChangeScope) + } + + if got.CreatesRevision != want.createsRevision { + t.Errorf("Composition %s: RevisionImpact.CreatesRevision: expected %t, got %t", + expectComp.name, want.createsRevision, got.CreatesRevision) + } + + if got.RepointedComposites != want.repointedComposites { + t.Errorf("Composition %s: RevisionImpact.RepointedComposites: expected %d, got %d", + expectComp.name, want.repointedComposites, got.RepointedComposites) + } + } + + if want := expectComp.predictedRevisionName; want != nil { + got := found.RevisionImpact.PredictedRevisionName + + switch want.pattern { + case "": + if got != "" { + t.Errorf("Composition %s: RevisionImpact.PredictedRevisionName: expected absent, got %q", + expectComp.name, got) + } + default: + matched, err := regexp.MatchString(want.pattern, got) + + switch { + case err != nil: + t.Errorf("Composition %s: RevisionImpact.PredictedRevisionName: invalid pattern %q: %v", + expectComp.name, want.pattern, err) + case !matched: + t.Errorf("Composition %s: RevisionImpact.PredictedRevisionName: %q does not match pattern %q", + expectComp.name, got, want.pattern) + } + } + } + + // Check composition changes + if expectComp.compositionChangeType != "" { + if found.CompositionChanges == nil { + t.Errorf("Composition %s: expected composition changes but none found", expectComp.name) + } else { + if found.CompositionChanges.Type != expectComp.compositionChangeType { + t.Errorf("Composition %s: expected change type %s, got %s", + expectComp.name, expectComp.compositionChangeType, found.CompositionChanges.Type) + } + + // Check composition field changes + for path, expected := range expectComp.compositionFieldChanges { + oldVal := getFieldFromDiff(found.CompositionChanges.Diff, dt.DiffKeyOld, path) + newVal := getFieldFromDiff(found.CompositionChanges.Diff, dt.DiffKeyNew, path) + + if !reflect.DeepEqual(oldVal, expected[0]) { + t.Errorf("Composition %s: field %s old value: expected %v, got %v", + expectComp.name, path, expected[0], oldVal) + } + + if !reflect.DeepEqual(newVal, expected[1]) { + t.Errorf("Composition %s: field %s new value: expected %v, got %v", + expectComp.name, path, expected[1], newVal) + } + } + } + } + + // Check XR impacts + for _, expectXR := range expectComp.xrImpacts { + foundXR := findMatchingXRImpact(found.ImpactAnalysis, expectXR) + if foundXR == nil { + actualXRs := make([]string, 0, len(found.ImpactAnalysis)) + for _, x := range found.ImpactAnalysis { + ref := x.ref() + actualXRs = append(actualXRs, fmt.Sprintf("%s/%s (ns=%s, status=%s)", ref.Kind, ref.Name, ref.Namespace, x.Status)) + } + + t.Errorf("Composition %s: Expected XR impact %s/%s (ns=%s) not found. Actual XRs: %v", + expectComp.name, expectXR.kind, expectXR.name, expectXR.namespace, actualXRs) + + continue + } + + // Check status + if foundXR.Status != expectXR.status { + t.Errorf("Composition %s: XR %s/%s: expected status %s, got %s", + expectComp.name, expectXR.kind, expectXR.name, expectXR.status, foundXR.Status) + } + + // Check filter reason if specified + if expectXR.filterReason != "" && foundXR.FilterReason != expectXR.filterReason { + t.Errorf("Composition %s: XR %s/%s: expected filterReason %s, got %s", + expectComp.name, expectXR.kind, expectXR.name, expectXR.filterReason, foundXR.FilterReason) + } + + // Check error message if specified + if expectXR.errorContains != "" && !strings.Contains(foundXR.errorText(), expectXR.errorContains) { + t.Errorf("Composition %s: XR %s/%s: expected error containing %q, got %q", + expectComp.name, expectXR.kind, expectXR.name, expectXR.errorContains, foundXR.errorText()) + } + + for _, id := range expectXR.errorIDs { + if findMatchingError(foundXR.Errors, id) == nil { + t.Errorf("Composition %s: XR %s/%s: expected entry error with resourceID %q not found", + expectComp.name, expectXR.kind, expectXR.name, id) + } + } + + downstream := foundXR.downstream() + + // Check downstream summary if specified + if expectXR.downstreamSummary != nil && downstream != nil { + if downstream.Summary.Added != expectXR.downstreamSummary.added { + t.Errorf("Composition %s: XR %s/%s: DownstreamChanges.Summary.Added: expected %d, got %d", + expectComp.name, expectXR.kind, expectXR.name, expectXR.downstreamSummary.added, downstream.Summary.Added) + } + + if downstream.Summary.Modified != expectXR.downstreamSummary.modified { + t.Errorf("Composition %s: XR %s/%s: DownstreamChanges.Summary.Modified: expected %d, got %d", + expectComp.name, expectXR.kind, expectXR.name, expectXR.downstreamSummary.modified, downstream.Summary.Modified) + } + + if downstream.Summary.Removed != expectXR.downstreamSummary.removed { + t.Errorf("Composition %s: XR %s/%s: DownstreamChanges.Summary.Removed: expected %d, got %d", + expectComp.name, expectXR.kind, expectXR.name, expectXR.downstreamSummary.removed, downstream.Summary.Removed) + } + } + + // Check downstream resources with field-level assertions + for _, expectRes := range expectXR.downstreamResources { + if downstream == nil { + t.Errorf("Composition %s: XR %s/%s: expected downstream resource %s/%s but no downstream changes present", + expectComp.name, expectXR.kind, expectXR.name, expectRes.kind, expectRes.name) + + continue + } + + foundRes := findMatchingDownstreamChange(downstream.Changes, expectRes) + if foundRes == nil { + actualRes := make([]string, 0, len(downstream.Changes)) + for _, c := range downstream.Changes { + actualRes = append(actualRes, fmt.Sprintf("%s/%s (type=%s)", c.Kind, c.Name, c.Type)) + } + + t.Errorf("Composition %s: XR %s/%s: expected downstream resource %s/%s not found. Actual: %v", + expectComp.name, expectXR.kind, expectXR.name, expectRes.kind, expectRes.name, actualRes) + + continue + } + + // Check change type + if foundRes.Type != expectRes.changeType { + t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: expected type %s, got %s", + expectComp.name, expectXR.kind, expectXR.name, expectRes.kind, expectRes.name, expectRes.changeType, foundRes.Type) + } + + assertDryRun(t, + fmt.Sprintf("Composition %s: XR %s/%s: downstream %s %s/%s", + expectComp.name, expectXR.kind, expectXR.name, expectRes.changeType, expectRes.kind, expectRes.name), + foundRes, expectRes.dryRun) + + // Check field changes (for modified resources) + for path, expected := range expectRes.fieldChanges { + oldVal, newVal := expected[0], expected[1] + assertDownstreamFieldChange(t, expectComp.name, expectXR, expectRes, foundRes, path, oldVal, newVal) + } + + // Check field values (for added/removed resources) + for path, expectedVal := range expectRes.fieldValues { + assertDownstreamFieldValue(t, expectComp.name, expectXR, expectRes, foundRes, path, expectedVal) + } + + // Check field value patterns + for path, pattern := range expectRes.fieldValuePatterns { + assertDownstreamFieldValuePattern(t, expectComp.name, expectXR, expectRes, foundRes, path, pattern) + } + } + } + } +} + +// assertCompOutputVersion checks the comp document's outputVersion, and that each composition and +// impactAnalysis[] entry carries exactly that version's fields, when the test pinned a version. +func assertCompOutputVersion(t *testing.T, output StructuredCompDiffOutput, expected *ExpectedCompDiff) { + t.Helper() + + if expected.outputVersion == "" { + return + } + + if output.OutputVersion != expected.outputVersion { + t.Errorf("outputVersion: expected %q, got %q", expected.outputVersion, output.OutputVersion) + } + + for _, comp := range output.Compositions { + switch { + case expected.outputVersion == "v2" && comp.Error != "": + t.Errorf("Composition %s: v2 output carries the v1 error string", comp.Name) + case expected.outputVersion != "v2" && comp.Errors != nil: + t.Errorf("Composition %s: v1 output carries the v2 errors[]", comp.Name) + } + + for i := range comp.ImpactAnalysis { + assertXRImpactShape(t, fmt.Sprintf("Composition %s: impactAnalysis[%d]", comp.Name, i), expected.outputVersion, &comp.ImpactAnalysis[i]) + } + } +} + +// findMatchingComposition finds a composition by name. +func findMatchingComposition(comps []CompositionDiffWire, name string) *CompositionDiffWire { + for i := range comps { + if comps[i].Name == name { + return &comps[i] + } + } + + return nil +} + +// findMatchingXRImpact finds an XR impact that matches the expectation. +func findMatchingXRImpact(impacts []XRImpactWire, expect *XRImpactExpectation) *XRImpactWire { + for i := range impacts { + impact := &impacts[i] + + ref := impact.ref() + if ref.Kind != expect.kind { + continue + } + + if expect.namespace != "" && ref.Namespace != expect.namespace { + continue + } + + // Check name matching + if expect.anyNameAllowed { + return impact + } + + if ref.Name == expect.name { + return impact + } + } + + return nil +} + +// findMatchingDownstreamChange finds a downstream change that matches the expectation. +func findMatchingDownstreamChange(changes []ChangeDetail, expect *DownstreamResourceExpectation) *ChangeDetail { + for i := range changes { + change := &changes[i] + if change.Type != expect.changeType { + continue + } + + if change.Kind != expect.kind { + continue + } + + if expect.namespace != "" && change.Namespace != expect.namespace { + continue + } + + // Check name matching + if expect.anyNameAllowed { + return change + } + + if expect.namePattern != nil { + if expect.namePattern.MatchString(change.Name) { + return change + } + + continue + } + + if change.Name == expect.name { + return change + } + } + + return nil +} + +// assertDownstreamFieldChange asserts that a field changed from old to new value in a modified downstream resource. +func assertDownstreamFieldChange(t *testing.T, compName string, expectXR *XRImpactExpectation, expectRes *DownstreamResourceExpectation, foundRes *ChangeDetail, path string, expectedOld, expectedNew any) { + t.Helper() + + // For modified resources, the diff structure has "old" and "new" keys + actualOld := getFieldFromDiff(foundRes.Diff, dt.DiffKeyOld, path) + actualNew := getFieldFromDiff(foundRes.Diff, dt.DiffKeyNew, path) + + if !reflect.DeepEqual(actualOld, expectedOld) { + t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s old value: expected %v, got %v", + compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, expectedOld, actualOld) + } + + if !reflect.DeepEqual(actualNew, expectedNew) { + t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s new value: expected %v, got %v", + compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, expectedNew, actualNew) + } +} + +// assertDownstreamFieldValue asserts that a field has the expected value in an added/removed downstream resource. +func assertDownstreamFieldValue(t *testing.T, compName string, expectXR *XRImpactExpectation, expectRes *DownstreamResourceExpectation, foundRes *ChangeDetail, path string, expectedValue any) { + t.Helper() + + // For added/removed resources, the value is in "spec" key + actualValue := getFieldFromDiff(foundRes.Diff, expectRes.changeType, path) + + if !reflect.DeepEqual(actualValue, expectedValue) { + t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s: expected %v, got %v", + compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, expectedValue, actualValue) + } +} + +// assertDownstreamFieldValuePattern asserts that a field value matches a regex pattern. +func assertDownstreamFieldValuePattern(t *testing.T, compName string, expectXR *XRImpactExpectation, expectRes *DownstreamResourceExpectation, foundRes *ChangeDetail, path string, pattern *regexp.Regexp) { + t.Helper() + + actualValue := getNewFieldValue(foundRes.Diff, expectRes.changeType, path) + + actualStr := fmt.Sprintf("%v", actualValue) + if !pattern.MatchString(actualStr) { + t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s value %q does not match pattern %q", + compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, actualStr, pattern.String()) + } +} diff --git a/internal/diff/testutils/structured_assertions_test.go b/internal/diff/testutils/structured_assertions_test.go new file mode 100644 index 00000000..e5357f3b --- /dev/null +++ b/internal/diff/testutils/structured_assertions_test.go @@ -0,0 +1,764 @@ +package testutils + +import ( + "reflect" + "testing" +) + +func TestParseStructuredOutput(t *testing.T) { + tests := []struct { + name string + input string + wantErr bool + wantAdded int + }{ + { + name: "valid JSON with summary", + input: `{ + "summary": {"added": 1, "modified": 2, "removed": 0}, + "changes": [] + }`, + wantErr: false, + wantAdded: 1, + }, + { + name: "invalid JSON", + input: `{invalid}`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output, err := ParseStructuredOutput(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("ParseStructuredOutput() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && output.Summary.Added != tt.wantAdded { + t.Errorf("Summary.Added = %d, want %d", output.Summary.Added, tt.wantAdded) + } + }) + } +} + +func TestAssertStructuredDiff_Summary(t *testing.T) { + jsonOutput := `{ + "summary": {"added": 1, "modified": 2, "removed": 3}, + "changes": [] + }` + + // This should pass - matching summary + AssertStructuredDiff(t, jsonOutput, ExpectDiff().WithSummary(1, 2, 3)) +} + +func TestAssertStructuredDiff_AddedResource(t *testing.T) { + jsonOutput := `{ + "summary": {"added": 1, "modified": 0, "removed": 0}, + "changes": [{ + "type": "added", + "apiVersion": "v1alpha1", + "kind": "XNopResource", + "name": "test-resource", + "namespace": "default", + "diff": { + "spec": { + "spec": { + "forProvider": { + "configData": "new-value" + } + } + } + } + }] + }` + + AssertStructuredDiff(t, jsonOutput, + ExpectDiff(). + WithSummary(1, 0, 0). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And()) +} + +func TestAssertStructuredDiff_ModifiedResource(t *testing.T) { + jsonOutput := `{ + "summary": {"added": 0, "modified": 1, "removed": 0}, + "changes": [{ + "type": "modified", + "apiVersion": "v1alpha1", + "kind": "XNopResource", + "name": "test-resource", + "namespace": "default", + "diff": { + "old": {"spec": {"forProvider": {"configData": "old-value"}}}, + "new": {"spec": {"forProvider": {"configData": "new-value"}}} + } + }] + }` + + AssertStructuredDiff(t, jsonOutput, + ExpectDiff(). + WithSummary(0, 1, 0). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "old-value", "new-value"). + And()) +} + +func TestAssertStructuredDiff_RemovedResource(t *testing.T) { + jsonOutput := `{ + "summary": {"added": 0, "modified": 0, "removed": 1}, + "changes": [{ + "type": "removed", + "apiVersion": "v1alpha1", + "kind": "XNopResource", + "name": "removed-resource", + "namespace": "default", + "diff": { + "spec": { + "spec": { + "forProvider": { + "configData": "deleted-value" + } + } + } + } + }] + }` + + AssertStructuredDiff(t, jsonOutput, + ExpectDiff(). + WithSummary(0, 0, 1). + WithRemovedResource("XNopResource", "removed-resource", "default"). + WithField("spec.forProvider.configData", "deleted-value"). + And()) +} + +func TestAssertStructuredDiff_NamePattern(t *testing.T) { + jsonOutput := `{ + "summary": {"added": 1, "modified": 0, "removed": 0}, + "changes": [{ + "type": "added", + "apiVersion": "v1alpha1", + "kind": "XNopResource", + "name": "test-resource-abc123", + "namespace": "default", + "diff": {"spec": {}} + }] + }` + + AssertStructuredDiff(t, jsonOutput, + ExpectDiff(). + WithSummary(1, 0, 0). + WithAddedResource("XNopResource", "", "default"). + WithNamePattern(`test-resource-[a-z0-9]+`). + And()) +} + +func TestGetNestedField(t *testing.T) { + obj := map[string]any{ + "spec": map[string]any{ + "forProvider": map[string]any{ + "configData": "test-value", + }, + }, + } + + tests := []struct { + path string + want any + }{ + {"spec.forProvider.configData", "test-value"}, + {"spec.forProvider", map[string]any{"configData": "test-value"}}, + {"spec.nonexistent", nil}, + {"", obj}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + got := getNestedField(obj, tt.path) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getNestedField(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestGetNestedFieldWithArrayIndex(t *testing.T) { + // Simulate a composition structure with pipeline steps + obj := map[string]any{ + "spec": map[string]any{ + "pipeline": []any{ + map[string]any{ + "functionRef": map[string]any{ + "name": "function-patch-and-transform", + }, + "input": map[string]any{ + "resources": []any{ + map[string]any{ + "name": "nop-resource", + "patches": []any{ + map[string]any{ + "fromFieldPath": "spec.coolField", + "transforms": []any{ + map[string]any{ + "string": map[string]any{ + "fmt": "updated-%s", + }, + }, + }, + }, + map[string]any{ + "fromFieldPath": "spec.otherField", + "transforms": []any{ + map[string]any{ + "string": map[string]any{ + "fmt": "basic-%s", + }, + }, + }, + }, + }, + }, + }, + }, + "step": "generate-resources", + }, + map[string]any{ + "functionRef": map[string]any{ + "name": "function-auto-ready", + }, + "step": "detect-ready-resources", + }, + }, + }, + } + + tests := []struct { + name string + path string + want any + }{ + { + name: "first pipeline step function name", + path: "spec.pipeline[0].functionRef.name", + want: "function-patch-and-transform", + }, + { + name: "second pipeline step function name", + path: "spec.pipeline[1].functionRef.name", + want: "function-auto-ready", + }, + { + name: "first resource name", + path: "spec.pipeline[0].input.resources[0].name", + want: "nop-resource", + }, + { + name: "first patch transform fmt", + path: "spec.pipeline[0].input.resources[0].patches[0].transforms[0].string.fmt", + want: "updated-%s", + }, + { + name: "second patch transform fmt", + path: "spec.pipeline[0].input.resources[0].patches[1].transforms[0].string.fmt", + want: "basic-%s", + }, + { + name: "out of bounds index", + path: "spec.pipeline[5].functionRef.name", + want: nil, + }, + { + name: "negative index (last element)", + path: "spec.pipeline[-1].functionRef.name", + want: "function-auto-ready", // k8s jsonpath supports Python-style negative indices + }, + { + name: "non-array with index", + path: "spec.pipeline[0].functionRef[0]", + want: nil, + }, + { + name: "step field", + path: "spec.pipeline[0].step", + want: "generate-resources", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getNestedField(obj, tt.path) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getNestedField(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestGetNestedFieldWithBracketNotation(t *testing.T) { + // Test data with keys containing dots (like Kubernetes annotations) + obj := map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "getcomposed.example.org/source-bucket": "bucket-name-abc123", + "crossplane.io/composition-resource-name": "test-resource", + "simple-key": "simple-value", + }, + }, + } + + tests := []struct { + name string + path string + want any + }{ + { + name: "bracket notation with single quotes", + path: "metadata.annotations['getcomposed.example.org/source-bucket']", + want: "bucket-name-abc123", + }, + { + name: "bracket notation with double quotes", + path: `metadata.annotations["crossplane.io/composition-resource-name"]`, + want: "test-resource", + }, + { + name: "simple key without dots", + path: "metadata.annotations['simple-key']", + want: "simple-value", + }, + { + name: "simple dot notation still works", + path: "metadata.annotations.simple-key", + want: "simple-value", + }, + { + name: "nonexistent key with brackets", + path: "metadata.annotations['nonexistent.key']", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := getNestedField(obj, tt.path) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("getNestedField(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} + +func TestAssertStructuredCompDiff_DownstreamFieldChanges(t *testing.T) { + jsonOutput := `{ + "compositions": [{ + "name": "test-composition", + "affectedResources": { + "total": 1, + "withChanges": 1, + "unchanged": 0, + "withErrors": 0 + }, + "impactAnalysis": [{ + "apiVersion": "test.example.org/v1alpha1", + "kind": "XTestResource", + "name": "test-xr", + "status": "changed", + "downstreamChanges": { + "summary": {"added": 0, "modified": 1, "removed": 0}, + "changes": [{ + "type": "modified", + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "ClusterNopResource", + "name": "test-xr-abc123", + "diff": { + "old": { + "metadata": { + "annotations": { + "config-data": "old-value", + "resource-tier": "basic" + } + } + }, + "new": { + "metadata": { + "annotations": { + "config-data": "new-value", + "resource-tier": "premium" + } + } + } + } + }] + } + }] + }] + }` + + AssertStructuredCompDiff(t, jsonOutput, + ExpectCompDiff(). + WithComposition("test-composition"). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XTestResource", "test-xr", "", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "ClusterNopResource", "", ""). + WithAnyName(). + WithFieldChange("metadata.annotations.config-data", "old-value", "new-value"). + WithFieldChange("metadata.annotations.resource-tier", "basic", "premium"). + AndXR(). + AndComp(). + And()) +} + +func TestFindMatchingDownstreamChange(t *testing.T) { + changes := []ChangeDetail{ + { + Type: "modified", + Kind: "ClusterNopResource", + Name: "test-resource-abc123", + Namespace: "", + }, + { + Type: "added", + Kind: "ConfigMap", + Name: "test-config", + Namespace: "default", + }, + } + + tests := []struct { + name string + expect *DownstreamResourceExpectation + want bool + }{ + { + name: "exact name match", + expect: &DownstreamResourceExpectation{ + changeType: "modified", + kind: "ClusterNopResource", + name: "test-resource-abc123", + }, + want: true, + }, + { + name: "any name match", + expect: &DownstreamResourceExpectation{ + changeType: "modified", + kind: "ClusterNopResource", + anyNameAllowed: true, + }, + want: true, + }, + { + name: "name pattern match", + expect: func() *DownstreamResourceExpectation { + d := &DownstreamResourceExpectation{ + changeType: "modified", + kind: "ClusterNopResource", + } + d.WithNamePattern(`test-resource-[a-z0-9]+`) + + return d + }(), + want: true, + }, + { + name: "kind mismatch", + expect: &DownstreamResourceExpectation{ + changeType: "modified", + kind: "WrongKind", + name: "test-resource-abc123", + }, + want: false, + }, + { + name: "change type mismatch", + expect: &DownstreamResourceExpectation{ + changeType: "added", + kind: "ClusterNopResource", + name: "test-resource-abc123", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + found := findMatchingDownstreamChange(changes, tt.expect) + if (found != nil) != tt.want { + t.Errorf("findMatchingDownstreamChange() found = %v, want found = %v", found != nil, tt.want) + } + }) + } +} + +func TestAssertStructuredCompDiff_FieldValuePattern(t *testing.T) { + jsonOutput := `{ + "compositions": [{ + "name": "test-composition", + "affectedResources": { + "total": 1, + "withChanges": 1, + "unchanged": 0, + "withErrors": 0 + }, + "impactAnalysis": [{ + "apiVersion": "test.example.org/v1alpha1", + "kind": "XTestResource", + "name": "test-xr", + "status": "changed", + "downstreamChanges": { + "summary": {"added": 0, "modified": 1, "removed": 0}, + "changes": [{ + "type": "modified", + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "ClusterNopResource", + "name": "test-xr-abc123", + "diff": { + "old": { + "metadata": { + "annotations": { + "existing-annotation": "value" + } + } + }, + "new": { + "metadata": { + "annotations": { + "existing-annotation": "value", + "generated-ref": "test-xr-xyz789" + } + } + } + } + }] + } + }] + }] + }` + + AssertStructuredCompDiff(t, jsonOutput, + ExpectCompDiff(). + WithComposition("test-composition"). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XTestResource", "test-xr", "", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "ClusterNopResource", "", ""). + WithAnyName(). + WithFieldValuePattern("metadata.annotations.generated-ref", `test-xr-[a-z0-9]+`). + AndXR(). + AndComp(). + And()) +} + +func TestAssertStructuredDiff_GroupedByXR(t *testing.T) { + // A multi-XR xr structured payload: one changed XR (with a modified + // composed resource), one unchanged XR, and one errored XR. The flat + // changes[]/summary reflect the merge; xrs[] carries the per-XR grouping. + jsonOutput := `{ + "summary": {"added": 0, "modified": 1, "removed": 0}, + "changes": [{ + "type": "modified", "apiVersion": "example.org/v1", "kind": "Bucket", + "name": "bucket-a", "namespace": "default", + "diff": {"old": {"spec": {"region": "us-east-1"}}, "new": {"spec": {"region": "us-west-2"}}} + }], + "errors": [{"resourceID": "XBucket/broken-xr", "message": "cannot get composition"}], + "xrs": [ + { + "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "changed-xr", "namespace": "default"}, + "status": "changed", + "summary": {"added": 0, "modified": 1, "removed": 0}, + "changes": [{ + "type": "modified", "apiVersion": "example.org/v1", "kind": "Bucket", + "name": "bucket-a", "namespace": "default", + "diff": {"old": {"spec": {"region": "us-east-1"}}, "new": {"spec": {"region": "us-west-2"}}} + }] + }, + { + "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "unchanged-xr", "namespace": "default"}, + "status": "unchanged", + "summary": {"added": 0, "modified": 0, "removed": 0}, + "changes": [] + }, + { + "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "broken-xr", "namespace": "default"}, + "status": "error", + "summary": {"added": 0, "modified": 0, "removed": 0}, + "changes": [], + "errors": [{"resourceID": "XBucket/broken-xr", "message": "cannot get composition"}] + } + ] + }` + + matchT := &testing.T{} + AssertStructuredDiff(matchT, jsonOutput, + ExpectDiff(). + WithSummary(0, 1, 0). + WithXRs( + XR("XBucket", "changed-xr", "default"). + Status("changed"). + Summary(0, 1, 0). + Change("modified", "Bucket", "bucket-a", "default"). + WithFieldChange("spec.region", "us-east-1", "us-west-2"), + XR("XBucket", "unchanged-xr", "default"). + Status("unchanged"), + XR("XBucket", "broken-xr", "default"). + Status("error"). + Err("XBucket/broken-xr"), + ). + Build()) + + if matchT.Failed() { + t.Errorf("grouped assertions unexpectedly failed on a matching payload") + } + + // Negative path: a wrong status must be caught (guards against a no-op + // matcher). All three entries are listed so the only discrepancy is the + // status, not the entry count. + badT := &testing.T{} + AssertStructuredDiff(badT, jsonOutput, + ExpectDiff(). + WithXRs( + XR("XBucket", "changed-xr", "default"). + Status("unchanged"), // actual is "changed" + XR("XBucket", "unchanged-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build()) + + if !badT.Failed() { + t.Errorf("grouped assertions should have failed on a mismatched status") + } + + // Negative path: an xrs[] entry the test did not ask for must be caught. + extraT := &testing.T{} + AssertStructuredDiff(extraT, jsonOutput, + ExpectDiff(). + WithXRs(XR("XBucket", "changed-xr", "default")). + Build()) + + if !extraT.Failed() { + t.Errorf("grouped assertions should have failed on unexpected extra xrs[] entries") + } + + // Negative path (issue #488): two expectations must not both be satisfied by + // the same entry, leaving another entry unchecked. The counts match here + // (2 expectations, 2 entries), so only consuming an entry on match catches + // it. Before that, a duplicated XR identity — the symptom of issue #477 — + // passed unnoticed. + twoEntries := `{ + "summary": {"added": 0, "modified": 0, "removed": 0}, + "changes": [], + "xrs": [ + { + "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "first-xr", "namespace": "default"}, + "status": "unchanged", + "summary": {"added": 0, "modified": 0, "removed": 0}, + "changes": [] + }, + { + "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "second-xr", "namespace": "default"}, + "status": "unchanged", + "summary": {"added": 0, "modified": 0, "removed": 0}, + "changes": [] + } + ] + }` + + dupeT := &testing.T{} + AssertStructuredDiff(dupeT, twoEntries, + ExpectDiff(). + WithXRs( + XR("XBucket", "first-xr", "default").Status("unchanged"), + XR("XBucket", "first-xr", "default").Status("unchanged"), + ). + Build()) + + if !dupeT.Failed() { + t.Errorf("grouped assertions should have failed when two expectations matched one xrs[] entry") + } +} + +// TestAssertStructuredCompDiff_V2 asserts a v2 document through the same builders as v1, with the +// real t so a mismatch fails the test. +func TestAssertStructuredCompDiff_V2(t *testing.T) { + jsonOutput := `{ + "outputVersion": "v2", + "compositions": [{ + "name": "test-composition", + "affectedResources": {"total": 2, "withChanges": 1, "unchanged": 0, "withErrors": 1}, + "impactAnalysis": [ + { + "xr": {"apiVersion": "test.example.org/v1alpha1", "kind": "XTestResource", "name": "test-xr"}, + "status": "changed", + "summary": {"added": 0, "modified": 1, "removed": 0}, + "changes": [{ + "type": "modified", + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "ClusterNopResource", + "name": "test-xr-abc123", + "diff": {"old": {"spec": {"v": "old"}}, "new": {"spec": {"v": "new"}}} + }] + }, + { + "xr": {"apiVersion": "test.example.org/v1alpha1", "kind": "XTestResource", "name": "broken-xr"}, + "status": "error", + "summary": {"added": 0, "modified": 0, "removed": 0}, + "changes": [], + "errors": [{"resourceID": "XTestResource/broken-xr", "message": "render failed"}] + } + ] + }, { + "name": "broken-composition", + "errors": [{"resourceID": "Composition/broken-composition", "message": "cannot fetch"}], + "affectedResources": {"total": 0, "withChanges": 0, "unchanged": 0, "withErrors": 0}, + "impactAnalysis": [] + }], + "errors": [ + {"resourceID": "XTestResource/broken-xr", "message": "render failed"}, + {"resourceID": "Composition/broken-composition", "message": "cannot fetch"} + ] + }` + + AssertStructuredCompDiff(t, jsonOutput, + ExpectCompDiff(). + WithOutputVersion("v2"). + WithError("XTestResource/broken-xr"). + WithError("Composition/broken-composition"). + WithComposition("test-composition"). + WithAffectedResources(2, 1, 0, 1). + WithXRImpact("XTestResource", "test-xr", "", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "ClusterNopResource", "test-xr-abc123", ""). + WithFieldChange("spec.v", "old", "new"). + AndXR(). + AndComp(). + WithXRImpact("XTestResource", "broken-xr", "", "error"). + WithErrorContaining("render failed"). + WithErrorResourceID("XTestResource/broken-xr"). + AndComposition("broken-composition"). + WithCompositionErrorContaining("cannot fetch")) +} + +// TestAssertStructuredDiff_V2 asserts a v2 xr document, which has no flat changes[], with the real t. +func TestAssertStructuredDiff_V2(t *testing.T) { + jsonOutput := `{ + "outputVersion": "v2", + "summary": {"added": 1, "modified": 0, "removed": 0}, + "xrs": [{ + "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "b", "namespace": "default"}, + "status": "changed", + "summary": {"added": 1, "modified": 0, "removed": 0}, + "changes": [{"type": "added", "apiVersion": "v1", "kind": "ConfigMap", "name": "cm", "namespace": "default", "diff": {"spec": {"data": {"k": "v"}}}}] + }] + }` + + AssertStructuredDiff(t, jsonOutput, + ExpectDiff(). + WithOutputVersion("v2"). + WithSummary(1, 0, 0). + WithXRs(XR("XBucket", "b", "default").Status("changed").Summary(1, 0, 0). + Change("added", "ConfigMap", "cm", "default").WithField("data.k", "v"))) +} diff --git a/internal/diff/testutils/utils.go b/internal/diff/testutils/utils.go new file mode 100644 index 00000000..35eb096a --- /dev/null +++ b/internal/diff/testutils/utils.go @@ -0,0 +1,203 @@ +package testutils + +import ( + stdlog "log" + "regexp" + "strings" + "sync" + "testing" + + "github.com/go-logr/logr/testr" + "github.com/go-logr/stdr" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/discovery" + fakediscovery "k8s.io/client-go/discovery/fake" + kt "k8s.io/client-go/testing" + "sigs.k8s.io/controller-runtime/pkg/log" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +// Colors for terminal output. +const ( + ColorRed = "\x1b[31m" + ColorGreen = "\x1b[32m" + ColorYellow = "\x1b[33m" + ColorReset = "\x1b[0m" +) + +// color takes a multiline string, splits it by line, and adds the specified coloring to each line. +// It returns a single string with all lines joined back together. +func color(colorCode, input string) string { + lines := strings.Split(input, "\n") + coloredLines := make([]string, 0, len(lines)) + + for _, line := range lines { + // Handle the case of the last empty line after a newline + if line == "" && len(coloredLines) == len(lines)-1 { + coloredLines = append(coloredLines, "") + continue + } + + coloredLines = append(coloredLines, colorCode+line+ColorReset) + } + + return strings.Join(coloredLines, "\n") +} + +// Green takes a multiline string, splits it by line, and adds green coloring to each line. +// It returns a single string with all lines joined back together. +func Green(input string) string { + return color(ColorGreen, input) +} + +// Red takes a multiline string, splits it by line, and adds red coloring to each line. +// It returns a single string with all lines joined back together. +func Red(input string) string { + return color(ColorRed, input) +} + +// Yellow takes a multiline string, splits it by line, and adds yellow coloring to each line. +// It returns a single string with all lines joined back together. +func Yellow(input string) string { + return color(ColorYellow, input) +} + +// CompareIgnoringAnsi compares two strings after stripping ANSI special characters. +func CompareIgnoringAnsi(expected, actual string) bool { + // Strip ANSI codes from both strings + ansiPattern := regexp.MustCompile("\x1b\\[[0-9;]*m") + expectedStripped := ansiPattern.ReplaceAllString(expected, "") + actualStripped := ansiPattern.ReplaceAllString(actual, "") + + // Compare the stripped strings + return expectedStripped == actualStripped +} + +// SetupKubeTestLogger sets the global logger for use of the Kube environment to the T.Log of this test. +func SetupKubeTestLogger(t *testing.T) { + t.Helper() + + // Create a logr.Logger that writes to testing.T.Log + testLogger := stdr.NewWithOptions(stdlog.New(testWriter{t}, "", 0), stdr.Options{LogCaller: stdr.All}) + + // Set the logger for controller-runtime + log.SetLogger(testLogger) +} + +// testWriter adapts testing.T.Log to io.Writer. +type testWriter struct { + t *testing.T +} + +// Write logs the provided argument as a string to the T.Log. +func (tw testWriter) Write(p []byte) (int, error) { + tw.t.Log(string(p)) + return len(p), nil +} + +// TestLogger coerces the T.Log into the shape of a Logr logger. +func TestLogger(t *testing.T, verbose bool) logging.Logger { + verbosity := 0 + if verbose { + verbosity = 1 + } + + return logging.NewLogrLogger(testr.NewWithOptions(t, testr.Options{Verbosity: verbosity})) +} + +// AdvisoryCapturingLogger wraps a logging.Logger and records the messages passed to Info, so a test +// can assert that a call site raises a user-facing advisory rather than mere tracing. +// +// Info is the codebase's advisory level: the CLI wraps the injected logger so every Info becomes a +// stderr WARNING line and a structured warnings[] entry. Emitters deliberately know nothing about +// that wrapper, so tests for them assert the contract they actually have — "this calls Info, not +// Debug" — instead of coupling to the wrapper. That also lets packages the wrapper's own package +// imports (the clients) assert their advisories without an import cycle. +type AdvisoryCapturingLogger struct { + wrapped logging.Logger + + mu sync.Mutex + advisory []string +} + +// NewAdvisoryCapturingLogger returns a logger that forwards to a standard test logger while recording +// Info messages for assertion. +func NewAdvisoryCapturingLogger(t *testing.T) *AdvisoryCapturingLogger { + t.Helper() + + return &AdvisoryCapturingLogger{wrapped: TestLogger(t, false)} +} + +// Info records msg as an advisory and forwards to the wrapped logger. +func (l *AdvisoryCapturingLogger) Info(msg string, keysAndValues ...any) { + l.mu.Lock() + l.advisory = append(l.advisory, msg) + l.mu.Unlock() + + l.wrapped.Info(msg, keysAndValues...) +} + +// Debug forwards to the wrapped logger without recording: tracing is not an advisory. +func (l *AdvisoryCapturingLogger) Debug(msg string, keysAndValues ...any) { + l.wrapped.Debug(msg, keysAndValues...) +} + +// WithValues returns a derived logger that records into the same place, so an advisory raised through +// a derived logger is still captured. +func (l *AdvisoryCapturingLogger) WithValues(keysAndValues ...any) logging.Logger { + return &advisoryChild{parent: l, wrapped: l.wrapped.WithValues(keysAndValues...)} +} + +// Advisories returns the Info messages recorded so far, in call order. +func (l *AdvisoryCapturingLogger) Advisories() []string { + l.mu.Lock() + defer l.mu.Unlock() + + return append([]string{}, l.advisory...) +} + +// advisoryChild is a WithValues-derived logger that records into its parent. +type advisoryChild struct { + parent *AdvisoryCapturingLogger + wrapped logging.Logger +} + +func (l *advisoryChild) Info(msg string, keysAndValues ...any) { + l.parent.mu.Lock() + l.parent.advisory = append(l.parent.advisory, msg) + l.parent.mu.Unlock() + + l.wrapped.Info(msg, keysAndValues...) +} + +func (l *advisoryChild) Debug(msg string, keysAndValues ...any) { + l.wrapped.Debug(msg, keysAndValues...) +} + +func (l *advisoryChild) WithValues(keysAndValues ...any) logging.Logger { + return &advisoryChild{parent: l.parent, wrapped: l.wrapped.WithValues(keysAndValues...)} +} + +// CreateFakeDiscoveryClient is a helper function to create a fake discovery client for testing. +// The fake discovery client automatically derives API groups from the provided resources, +// enabling both ServerResourcesForGroupVersion() and ServerGroups() to work correctly. +func CreateFakeDiscoveryClient(resources map[string][]metav1.APIResource) discovery.DiscoveryInterface { + fakeDiscovery := &fakediscovery.FakeDiscovery{ + Fake: &kt.Fake{}, + } + + apiResourceLists := make([]*metav1.APIResourceList, 0, len(resources)) + + for gv, apiResources := range resources { + apiResourceLists = append(apiResourceLists, &metav1.APIResourceList{ + GroupVersion: gv, + APIResources: apiResources, + }) + } + + fakeDiscovery.Resources = apiResourceLists + fakeDiscovery.Resources = apiResourceLists + + return fakeDiscovery +} diff --git a/internal/diff/testutils/xrd_crds.go b/internal/diff/testutils/xrd_crds.go new file mode 100644 index 00000000..f4a39dd8 --- /dev/null +++ b/internal/diff/testutils/xrd_crds.go @@ -0,0 +1,91 @@ +package testutils + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + clicrd "github.com/crossplane/cli/v2/cmd/crossplane/common/crd" + clixr "github.com/crossplane/cli/v2/pkg/xr" +) + +// CRDsForXRD returns the CRDs Crossplane generates from an XRD manifest: the composite resource's CRD and, +// when the XRD sets spec.claimNames, the claim's. Generation is upstream's (crossplane-runtime pkg/xcrd, via +// the crossplane CLI's ConvertToCRDs), so a test cluster gets the same CRDs a real one would. +// +// xcrd consumes the XRD as Crossplane's controller reads it: at apiextensions.crossplane.io/v1, with the +// apiserver's defaults applied. A manifest has neither, and the defaults decide what is generated (the +// policy defaults the CRDs declare, and the scope of an XRD that omits it, which is Namespaced at v2 but +// LegacyCluster at v1). So the manifest is first defaulted exactly as the apiserver would: with the schema +// of its own version, then relabelled to v1 (the XRD CRD converts with strategy None, so only apiVersion +// changes) and defaulted with v1's. Both schemas come from xrdCRD, the CompositeResourceDefinition CRD. +// +// The generated CRDs carry no owner reference: xcrd points one at the XRD, but the XRD has not been +// created yet when a test installs its CRDs, so it has no UID and an apiserver would reject the reference. +// +// The input is not modified. +func CRDsForXRD(xrd *un.Unstructured, xrdCRD *extv1.CustomResourceDefinition) ([]*extv1.CustomResourceDefinition, error) { + if gk := xrd.GroupVersionKind().GroupKind(); gk != xpextv1.CompositeResourceDefinitionGroupVersionKind.GroupKind() { + return nil, errors.Errorf("%s %q is not a CompositeResourceDefinition", gk, xrd.GetName()) + } + + defaulted := xrd.DeepCopy() + + if err := clixr.ApplyCRDDefaults(defaulted.Object, defaulted.GetAPIVersion(), *xrdCRD); err != nil { + return nil, errors.Wrapf(err, "cannot default XRD %q at %s", xrd.GetName(), xrd.GetAPIVersion()) + } + + read := xpextv1.CompositeResourceDefinitionGroupVersionKind.GroupVersion().String() + if defaulted.GetAPIVersion() != read { + defaulted.SetAPIVersion(read) + + if err := clixr.ApplyCRDDefaults(defaulted.Object, read, *xrdCRD); err != nil { + return nil, errors.Wrapf(err, "cannot default XRD %q at %s", xrd.GetName(), read) + } + } + + crds, err := clicrd.ConvertToCRDs([]*un.Unstructured{defaulted}) + if err != nil { + return nil, errors.Wrapf(err, "cannot generate CRDs for XRD %q", xrd.GetName()) + } + + for _, crd := range crds { + crd.SetOwnerReferences(nil) + } + + return crds, nil +} + +// pinnedCrossplaneCRDsTarget is the Earthly target that writes the directory PinnedCrossplaneCRDsDir returns. +const pinnedCrossplaneCRDsTarget = "earthly +fetch-crossplane-crds-gomod" + +// PinnedCrossplaneCRDsDir returns the directory of Crossplane's own CRDs (Compositions, XRDs, Functions, …) at the +// version of github.com/crossplane/crossplane/v2 that go.mod pins, so they match the xcrd and the rest of the +// Crossplane code under test. `earthly +fetch-crossplane-crds-gomod` writes them to cluster/gomod/crds at the +// repository root. They are located from this source file rather than the working directory, and the test fails +// with the target to run if they are missing. +func PinnedCrossplaneCRDsDir(tb testing.TB) string { + tb.Helper() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + tb.Fatal("cannot locate Crossplane's CRDs: runtime.Caller gave no file for testutils") + } + + // This file is cmd/diff/testutils/xrd_crds.go, three levels below the repository root. + dir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "cluster", "gomod", "crds") + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + tb.Fatalf("Crossplane's CRDs are not at %s; run `%s` to fetch the version go.mod pins", + dir, pinnedCrossplaneCRDsTarget) + } + + return dir +} diff --git a/internal/diff/testutils/xrd_crds_test.go b/internal/diff/testutils/xrd_crds_test.go new file mode 100644 index 00000000..0d1ce473 --- /dev/null +++ b/internal/diff/testutils/xrd_crds_test.go @@ -0,0 +1,315 @@ +package testutils + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +// The XRD CRD's file in PinnedCrossplaneCRDsDir, the same file the integration suite installs. +const xrdCRDFile = "apiextensions.crossplane.io_compositeresourcedefinitions.yaml" + +// policyField is where a generated CRD declares one of Crossplane's policy fields, and the default it +// declares there. A zero value means the CRD does not declare the field at all. +type policyField struct { + Path string + Default string +} + +// crdShape is the part of a generated CRD these tests pin down: identity, scope, and the two policy +// fields whose defaults differ between an XR's CRD and its claim's. +type crdShape struct { + Name string + Scope extv1.ResourceScope + CompositionUpdatePolicy policyField + CompositeDeletePolicy policyField +} + +func TestCRDsForXRD(t *testing.T) { + xrdCRD := loadXRDCRD(t) + + tests := map[string]struct { + reason string + xrd string + want []crdShape + wantErr bool + }{ + "V1XRDWithClaimNames": { + reason: "A v1 XRD yields a legacy XR CRD that defaults compositionUpdatePolicy and has no compositeDeletePolicy, " + + "and a claim CRD that does not default compositionUpdatePolicy but defaults compositeDeletePolicy, " + + "both from the defaults the apiserver applies to the XRD itself", + xrd: ` +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xthings.example.org +spec: + group: example.org + names: + kind: XThing + plural: xthings + claimNames: + kind: Thing + plural: things + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + field: + type: string +`, + want: []crdShape{ + { + Name: "xthings.example.org", + Scope: extv1.ClusterScoped, + CompositionUpdatePolicy: policyField{Path: "spec.compositionUpdatePolicy", Default: `"Automatic"`}, + }, + { + Name: "things.example.org", + Scope: extv1.NamespaceScoped, + CompositionUpdatePolicy: policyField{Path: "spec.compositionUpdatePolicy"}, + CompositeDeletePolicy: policyField{Path: "spec.compositeDeletePolicy", Default: `"Background"`}, + }, + }, + }, + "V1XRDWithExplicitPolicies": { + reason: "Policies the XRD sets explicitly become the generated CRDs' defaults", + xrd: ` +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xthings.example.org +spec: + group: example.org + names: + kind: XThing + plural: xthings + claimNames: + kind: Thing + plural: things + defaultCompositionUpdatePolicy: Manual + defaultCompositeDeletePolicy: Foreground + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object +`, + want: []crdShape{ + { + Name: "xthings.example.org", + Scope: extv1.ClusterScoped, + CompositionUpdatePolicy: policyField{Path: "spec.compositionUpdatePolicy", Default: `"Manual"`}, + }, + { + Name: "things.example.org", + Scope: extv1.NamespaceScoped, + CompositionUpdatePolicy: policyField{Path: "spec.compositionUpdatePolicy"}, + CompositeDeletePolicy: policyField{Path: "spec.compositeDeletePolicy", Default: `"Foreground"`}, + }, + }, + }, + "V2XRDWithoutScope": { + reason: "A v2 XRD that omits scope is Namespaced (its own version's default, not v1's LegacyCluster), " + + "nests the machinery under spec.crossplane, defaults compositionUpdatePolicy, and has no claim CRD", + xrd: ` +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xthings.example.org +spec: + group: example.org + names: + kind: XThing + plural: xthings + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object +`, + want: []crdShape{ + { + Name: "xthings.example.org", + Scope: extv1.NamespaceScoped, + CompositionUpdatePolicy: policyField{Path: "spec.crossplane.compositionUpdatePolicy", Default: `"Automatic"`}, + }, + }, + }, + "V2ClusterXRD": { + reason: "A v2 Cluster XRD yields a cluster-scoped XR CRD that still nests the machinery under spec.crossplane", + xrd: ` +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xthings.example.org +spec: + group: example.org + scope: Cluster + names: + kind: XThing + plural: xthings + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object +`, + want: []crdShape{ + { + Name: "xthings.example.org", + Scope: extv1.ClusterScoped, + CompositionUpdatePolicy: policyField{Path: "spec.crossplane.compositionUpdatePolicy", Default: `"Automatic"`}, + }, + }, + }, + "NotAnXRD": { + reason: "Anything but an XRD is a caller error, never silently ignored", + xrd: ` +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xthings.example.org +`, + wantErr: true, + }, + "UnknownXRDVersion": { + reason: "An XRD at a version the XRD CRD does not serve cannot be defaulted, so it is an error", + xrd: ` +apiVersion: apiextensions.crossplane.io/v9 +kind: CompositeResourceDefinition +metadata: + name: xthings.example.org +spec: + group: example.org + names: + kind: XThing + plural: xthings + versions: [] +`, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + xrd := &un.Unstructured{} + if err := yaml.Unmarshal([]byte(tt.xrd), &xrd.Object); err != nil { + t.Fatalf("cannot parse test XRD: %v", err) + } + + original := xrd.DeepCopy() + + crds, err := CRDsForXRD(xrd, xrdCRD) + + if tt.wantErr { + if err == nil { + t.Fatalf("\n%s\nCRDsForXRD(...): want error, got nil", tt.reason) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nCRDsForXRD(...): unexpected error: %v", tt.reason, err) + } + + got := make([]crdShape, 0, len(crds)) + for _, crd := range crds { + got = append(got, shapeOf(t, crd)) + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("\n%s\nCRDsForXRD(...): -want, +got:\n%s", tt.reason, diff) + } + + for _, crd := range crds { + if refs := crd.GetOwnerReferences(); len(refs) != 0 { + t.Errorf("\n%s\nCRDsForXRD(...): CRD %q has owner references %v; the XRD has no UID yet, so "+ + "an apiserver would reject them", tt.reason, crd.GetName(), refs) + } + } + + if diff := cmp.Diff(original.Object, xrd.Object); diff != "" { + t.Errorf("\n%s\nCRDsForXRD(...) mutated its input: -before, +after:\n%s", tt.reason, diff) + } + }) + } +} + +func loadXRDCRD(t *testing.T) *extv1.CustomResourceDefinition { + t.Helper() + + data, err := os.ReadFile(filepath.Join(PinnedCrossplaneCRDsDir(t), xrdCRDFile)) + if err != nil { + t.Fatalf("cannot read the XRD CRD: %v", err) + } + + crd := &extv1.CustomResourceDefinition{} + if err := yaml.Unmarshal(data, crd); err != nil { + t.Fatalf("cannot parse the XRD CRD: %v", err) + } + + return crd +} + +func shapeOf(t *testing.T, crd *extv1.CustomResourceDefinition) crdShape { + t.Helper() + + if len(crd.Spec.Versions) != 1 { + t.Fatalf("CRD %q: want exactly one version, got %d", crd.GetName(), len(crd.Spec.Versions)) + } + + spec := crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + + return crdShape{ + Name: crd.GetName(), + Scope: crd.Spec.Scope, + CompositionUpdatePolicy: findPolicyField(spec, "compositionUpdatePolicy"), + CompositeDeletePolicy: findPolicyField(spec, "compositeDeletePolicy"), + } +} + +// findPolicyField looks for a policy field where Crossplane puts it: directly under spec for a legacy XR +// or a claim, or under spec.crossplane for a modern XR. +func findPolicyField(spec extv1.JSONSchemaProps, name string) policyField { + for _, parent := range []struct { + path string + props extv1.JSONSchemaProps + }{ + {path: "spec", props: spec}, + {path: "spec.crossplane", props: spec.Properties["crossplane"]}, + } { + field, ok := parent.props.Properties[name] + if !ok { + continue + } + + f := policyField{Path: parent.path + "." + name} + if field.Default != nil { + f.Default = string(field.Default.Raw) + } + + return f + } + + return policyField{} +} diff --git a/internal/diff/types/types.go b/internal/diff/types/types.go new file mode 100644 index 00000000..b4ba7482 --- /dev/null +++ b/internal/diff/types/types.go @@ -0,0 +1,191 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package types defines shared type definitions and interfaces used across the crossplane-diff application. +package types + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8stypes "k8s.io/apimachinery/pkg/types" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +// Verb is a Kubernetes API verb, as it appears in a SelfSubjectAccessReview's +// resourceAttributes. See kubernetes.AccessChecker. +// +// It is a named string rather than a validated enum on purpose, and there is +// nothing upstream to adopt: authorizationv1.ResourceAttributes.Verb is a plain +// string, and the only verb constant in k8s.io/api is rbac's VerbAll ("*"). +// That is because the verb space is genuinely open — subresources, plus +// escalate, bind, impersonate, use, and whatever an aggregated API server +// defines — so this type deliberately does not restrict the value. +// +// What it buys is narrow but real. AccessChecker.Can takes a namespace and a +// verb adjacently; while both were plain strings, transposing them compiled +// cleanly and asked the apiserver "may I 'create' in namespace 'create'?", +// which comes back denied and would then be reported as a permission +// limitation — the exact false degradation that authorization check exists to +// prevent. Distinct types make that transposition a compile error. Note it does +// NOT prevent an empty verb, since an untyped "" converts implicitly; that is +// left to tests rather than a runtime guard, there being two call sites and both +// passing a constant below. +// +// It lives in this leaf package rather than beside AccessChecker because +// testutils must name it to implement the mock, and testutils cannot import +// client/kubernetes — that package's tests are internal (package kubernetes) +// and import testutils, so the edge would close a cycle. Same reason +// renderer/types exists. Do not "tidy" this back next to the interface. +type Verb string + +const ( + // VerbCreate authorizes a dry-run create, for resources that do not yet exist. + VerbCreate Verb = "create" + + // VerbPatch authorizes a dry-run server-side apply against a resource that + // already exists. + VerbPatch Verb = "patch" +) + +// CompositionProvider resolves the composition a composite is rendered against, and the +// CompositionRevision that composition comes from. +type CompositionProvider func(ctx context.Context, res *un.Unstructured) (ResolvedComposition, error) + +// ResolvedComposition is what a CompositionProvider resolves for one composite. +type ResolvedComposition struct { + // Composition is the composition to render the composite against. + Composition *apiextensionsv1.Composition + + // RevisionName is the name of the CompositionRevision Composition comes from. Crossplane's composite + // reconciler writes the revision it selects to the composite's compositionRevisionRef before it + // composes, so the composite is rendered with its ref set to this name, and a template reading the + // revision name sees the revision that rendered it (#536). Empty means unknown, for example a + // composition with no revisions yet. The composite then keeps the ref it has; a name is never made up. + RevisionName string +} + +// ValidatedInput is one input an input validator hands back for rendering: the resource, and the error +// that rejected it before rendering, if any. Lives here (not in diffprocessor, beside the InputValidator +// interface that returns it) so the validator's mock in cmd/diff/testutils can name it without an import +// cycle. +type ValidatedInput struct { + Resource *un.Unstructured + Err error // non-nil if rejected before rendering +} + +// FindCompositesOptions narrows what CompositionClient.FindComposites returns. +// Lives here (not in the crossplane client package) so test mocks in cmd/diff/testutils +// can implement the interface without creating an import cycle with cmd/diff/client/crossplane. +type FindCompositesOptions struct { + // Namespace scopes default discovery to a single namespace. Empty = all namespaces. + // Ignored when Refs is non-empty (refs carry their own namespace). + Namespace string + // Refs limits the result to specific user-named composites. When non-empty, a ref is included + // in the result only if (a) the named resource exists at the ref's [namespace/]name and (b) it + // references the supplied composition. Refs that don't satisfy both are silently omitted; the + // caller derives "unmatched" from the diff between input refs and returned objects. + Refs []k8stypes.NamespacedName +} + +// CredentialFetchResult reports the outcome of fetching a composition's function-credential secrets +// from the cluster. Lives here for the same reason as FindCompositesOptions: so test mocks in +// cmd/diff/testutils can implement CredentialClient without an import cycle. +// +// Absent secrets are reported rather than warned about at the point of discovery, because whether an +// absent secret is a problem depends on something the client cannot see: the caller may have supplied +// it via --function-credentials. Only the caller, after merging its own credentials in, can tell which +// shortfalls actually remain. +type CredentialFetchResult struct { + // Secrets are the credential secrets successfully read from the cluster, in pipeline order. + Secrets []corev1.Secret + + // Absent identifies the credential secrets a pipeline step references that do not exist on the + // cluster, in pipeline order and deduplicated. A NotFound is the only fetch failure recorded here; + // every other failure is returned as an error, because it leaves the credential state unknown + // rather than known-empty. + Absent []k8stypes.NamespacedName +} + +// RenderedResources records what the render of a composite, and the renders of the XRs nested beneath +// it, produced: what removal detection and input validation need to know about them. The zero value is +// empty and ready to use. Lives here (not in diffprocessor, beside the DiffCalculator interface that +// returns it) so the calculator's mock in cmd/diff/testutils can name it without an import cycle. +// +// It holds the rendered resources under two identities, because the two consumers mean different things +// by "the same resource": +// +// - Kubernetes identity, as a diff key: the object a rendering would be applied to. Input validation +// compares it across inputs, since two inputs reaching one object conflict whatever composed them. +// - Crossplane identity: the composition resource name (the crossplane.io/composition-resource-name +// annotation), which is unique only within the composite that desired it. Crossplane's garbage +// collector deletes a resource a composite composed exactly when the composite no longer desires a +// resource of that name, whatever API version or object the desired one would be applied as, so +// removal detection asks this one. +type RenderedResources struct { + keys map[string]bool + desired map[string]map[string]bool // composite identity -> composition resource names it desired +} + +// AddKey records the diff key of a rendered composed resource, changed or not. +func (r *RenderedResources) AddKey(key string) { + if r.keys == nil { + r.keys = make(map[string]bool) + } + + r.keys[key] = true +} + +// AddDesired records that the render of the composite with the given identity desired a composed resource +// under compositionResourceName. The identity is the composite's version-independent one (group, kind, +// namespace and name), so that what a removal check derives from the composite's cluster copy matches it. +func (r *RenderedResources) AddDesired(composite, compositionResourceName string) { + if r.desired == nil { + r.desired = make(map[string]map[string]bool) + } + + if r.desired[composite] == nil { + r.desired[composite] = make(map[string]bool) + } + + r.desired[composite][compositionResourceName] = true +} + +// Merge adds everything other records to r. +func (r *RenderedResources) Merge(other RenderedResources) { + for key := range other.keys { + r.AddKey(key) + } + + for composite, names := range other.desired { + for name := range names { + r.AddDesired(composite, name) + } + } +} + +// Keys returns the diff keys of every rendered composed resource. The caller must not modify it. +func (r *RenderedResources) Keys() map[string]bool { + return r.keys +} + +// Desires reports whether the render of the composite with the given identity desired a composed resource +// under compositionResourceName. A composite that was not rendered desires nothing. +func (r *RenderedResources) Desires(composite, compositionResourceName string) bool { + return r.desired[composite][compositionResourceName] +} From 8cd5e3f3c02a1a188ce5183308144bac7d4e9d10 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:04:33 -0400 Subject: [PATCH 003/126] build(nix): fetch Crossplane CRDs for diff tests 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/testutils/xrd_crds.go | 28 ++++++++++++---------------- nix/apps.nix | 1 + nix/checks.nix | 2 ++ nix/crossplane-crds.nix | 13 +++++++++++++ 4 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 nix/crossplane-crds.nix diff --git a/internal/diff/testutils/xrd_crds.go b/internal/diff/testutils/xrd_crds.go index f4a39dd8..8b79d7e8 100644 --- a/internal/diff/testutils/xrd_crds.go +++ b/internal/diff/testutils/xrd_crds.go @@ -2,8 +2,6 @@ package testutils import ( "os" - "path/filepath" - "runtime" "testing" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -64,27 +62,25 @@ func CRDsForXRD(xrd *un.Unstructured, xrdCRD *extv1.CustomResourceDefinition) ([ return crds, nil } -// pinnedCrossplaneCRDsTarget is the Earthly target that writes the directory PinnedCrossplaneCRDsDir returns. -const pinnedCrossplaneCRDsTarget = "earthly +fetch-crossplane-crds-gomod" +// crossplaneCRDsDirEnv names the environment variable that holds the directory PinnedCrossplaneCRDsDir returns. +const crossplaneCRDsDirEnv = "CROSSPLANE_CRDS_DIR" // PinnedCrossplaneCRDsDir returns the directory of Crossplane's own CRDs (Compositions, XRDs, Functions, …) at the -// version of github.com/crossplane/crossplane/v2 that go.mod pins, so they match the xcrd and the rest of the -// Crossplane code under test. `earthly +fetch-crossplane-crds-gomod` writes them to cluster/gomod/crds at the -// repository root. They are located from this source file rather than the working directory, and the test fails -// with the target to run if they are missing. +// version of github.com/crossplane/crossplane/apis/v2 that go.mod pins, so they match the xcrd and the rest of the +// Crossplane code under test. nix/crossplane-crds.nix fetches them, and both `nix run .#test` and the sandboxed +// `test` check export their location as CROSSPLANE_CRDS_DIR. The test fails with how to provide them if missing. func PinnedCrossplaneCRDsDir(tb testing.TB) string { tb.Helper() - _, thisFile, _, ok := runtime.Caller(0) - if !ok { - tb.Fatal("cannot locate Crossplane's CRDs: runtime.Caller gave no file for testutils") + dir := os.Getenv(crossplaneCRDsDirEnv) + if dir == "" { + tb.Fatalf("%s is not set; run the tests with `nix run .#test` (or `./nix.sh run .#test`), which fetches "+ + "Crossplane's CRDs at the version go.mod pins", crossplaneCRDsDirEnv) } - // This file is cmd/diff/testutils/xrd_crds.go, three levels below the repository root. - dir := filepath.Join(filepath.Dir(thisFile), "..", "..", "..", "cluster", "gomod", "crds") - if info, err := os.Stat(dir); err != nil || !info.IsDir() { - tb.Fatalf("Crossplane's CRDs are not at %s; run `%s` to fetch the version go.mod pins", - dir, pinnedCrossplaneCRDsTarget) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { //nolint:gosec // Test-only path set by the test runner. + tb.Fatalf("%s=%s is not a directory of Crossplane's CRDs; run the tests with `nix run .#test`", + crossplaneCRDsDirEnv, dir) } return dir diff --git a/nix/apps.nix b/nix/apps.nix index b4b25a60..f0c49a63 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -25,6 +25,7 @@ inheritPath = false; text = '' export CGO_ENABLED=0 + export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${import ./crossplane-crds.nix { inherit pkgs; }}}" go test -covermode=count ./... "$@" ''; } diff --git a/nix/checks.nix b/nix/checks.nix index 26b9ad27..d6e8e4f2 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -16,6 +16,7 @@ let # Go builders backed by a single shared per-module vendor cache. # See nix/go-builders.nix. inherit (import ./go-builders.nix { inherit pkgs self; }) buildRoot; + crossplaneCRDs = import ./crossplane-crds.nix { inherit pkgs; }; in { # Run Go unit tests with coverage @@ -27,6 +28,7 @@ in src = self; env.CGO_ENABLED = "0"; + env.CROSSPLANE_CRDS_DIR = crossplaneCRDs; dontBuild = true; diff --git a/nix/crossplane-crds.nix b/nix/crossplane-crds.nix new file mode 100644 index 00000000..f9a54db6 --- /dev/null +++ b/nix/crossplane-crds.nix @@ -0,0 +1,13 @@ +# Crossplane's own CRDs (Compositions, XRDs, Functions, ...) for the diff +# tests. Pinned to the github.com/crossplane/crossplane/apis/v2 version in +# go.mod; bump rev and hash together when that changes. Tests find them through +# CROSSPLANE_CRDS_DIR. +{ pkgs }: +pkgs.fetchFromGitHub { + owner = "crossplane"; + repo = "crossplane"; + rev = "v2.4.1"; + sparseCheckout = [ "cluster/crds" ]; + hash = "sha256-+jJSqzsVmhw4eU93tFJi0Hcn/iVXc5EZ4Ktp/TYO+s0="; +} ++ "/cluster/crds" From 1ab035bcda438284ba3dc5e40617f381ed0c61bf Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:32:07 -0400 Subject: [PATCH 004/126] build(nix): format apps.nix and refresh vendor hash Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- nix/apps.nix | 4 +++- nix/vendor-hashes.nix | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/nix/apps.nix b/nix/apps.nix index f0c49a63..afcec82f 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -25,7 +25,9 @@ inheritPath = false; text = '' export CGO_ENABLED=0 - export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${import ./crossplane-crds.nix { inherit pkgs; }}}" + export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ + import ./crossplane-crds.nix { inherit pkgs; } + }}" go test -covermode=count ./... "$@" ''; } diff --git a/nix/vendor-hashes.nix b/nix/vendor-hashes.nix index 2e92b6f5..003a46b6 100644 --- a/nix/vendor-hashes.nix +++ b/nix/vendor-hashes.nix @@ -12,5 +12,5 @@ # hand.) { # Root module: github.com/crossplane/cli/v2 - root = "sha256-MO9qTpEGofkSmktxC+ubi9m46TvDh570taHwKIQHTGA="; + root = "sha256-xs2qUPloORBdohdEAVJaoIlT6qMUnlblTR48awImEqI="; } From a777efbd5e9eb0c3c77a385e06f812cd5e36ed42 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:33:39 -0400 Subject: [PATCH 005/126] feat(diff): port ref, dockerrun and core client from crossplane-diff 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/client/core/core.go | 134 +++++++ internal/diff/dockerrun/lock_unix.go | 32 ++ internal/diff/dockerrun/lock_windows.go | 32 ++ internal/diff/dockerrun/run.go | 346 ++++++++++++++++ internal/diff/dockerrun/run_test.go | 499 ++++++++++++++++++++++++ internal/diff/ref/ref.go | 105 +++++ internal/diff/ref/ref_test.go | 199 ++++++++++ 7 files changed, 1347 insertions(+) create mode 100644 internal/diff/client/core/core.go create mode 100644 internal/diff/dockerrun/lock_unix.go create mode 100644 internal/diff/dockerrun/lock_windows.go create mode 100644 internal/diff/dockerrun/run.go create mode 100644 internal/diff/dockerrun/run_test.go create mode 100644 internal/diff/ref/ref.go create mode 100644 internal/diff/ref/ref_test.go diff --git a/internal/diff/client/core/core.go b/internal/diff/client/core/core.go new file mode 100644 index 00000000..21df1369 --- /dev/null +++ b/internal/diff/client/core/core.go @@ -0,0 +1,134 @@ +// Package core contains structs and functions to aggregate built-in kube clients for use elsewhere +package core + +import ( + "context" + "fmt" + "reflect" + + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + kubeclient "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/crossplane/apis/v2/pkg" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xrm" +) + +//nolint:gochecknoinits // Registering types with the global client-go scheme in init avoids race conditions. +func init() { + // Register Crossplane package types with the global scheme at init time. + // This follows the standard Kubernetes pattern of building the scheme during + // initialization and treating it as read-only thereafter, which is safe for + // concurrent access. + _ = pkg.AddToScheme(scheme.Scheme) +} + +// Initializable is an interface for any client that can be initialized. +type Initializable interface { + Initialize(ctx context.Context) error +} + +// Clients aggregates the root level of built-in kube clients that we use to initialize our wrapper interfaces. +type Clients struct { + Dynamic dynamic.Interface + Discovery discovery.DiscoveryInterface + Tree *xrm.Client + + // Kube is the generated, typed clientset. Most of this tool works in + // Unstructured because it deals in arbitrary user-defined GVKs, but a few + // built-in APIs have a fixed shape that is worth having checked at compile + // time -- SelfSubjectAccessReview (see kubernetes.AccessChecker) is one, + // where an untyped round-trip meant hand-writing the request's keys and + // reading the response back with NestedBool/NestedString. + Kube kubeclient.Interface +} + +// NewClients initializes a bundle of built-in kube clients using the given rest config. +func NewClients(config *rest.Config) (*Clients, error) { + // These three clients underlie all of our client wrapper interfaces. + dynClient, err := makeDynamicClient(config) + if err != nil { + return nil, errors.Wrap(err, "cannot create dynamic client") + } + + disClient, err := makeDiscoveryClient(config) + if err != nil { + return nil, errors.Wrap(err, "cannot create discovery client") + } + + xrmClient, err := makeXrmClient(config) + if err != nil { + return nil, errors.Wrap(err, "cannot create xrm client") + } + + kubeClient, err := makeKubeClient(config) + if err != nil { + return nil, errors.Wrap(err, "cannot create typed kubernetes client") + } + + return &Clients{ + Dynamic: dynClient, + Discovery: disClient, + Tree: xrmClient, + Kube: kubeClient, + }, nil +} + +func makeDynamicClient(config *rest.Config) (dynamic.Interface, error) { + return dynamic.NewForConfig(config) +} + +func makeKubeClient(config *rest.Config) (kubeclient.Interface, error) { + return kubeclient.NewForConfig(config) +} + +func makeXrmClient(config *rest.Config) (*xrm.Client, error) { + c, err := client.New(config, client.Options{ + Scheme: scheme.Scheme, + }) + if err != nil { + return nil, errors.Wrap(err, "cannot create controller runtime client") + } + + xrmClient, err := xrm.NewClient(c, + xrm.WithConnectionSecrets(false), + xrm.WithConcurrency(5)) + if err != nil { + return nil, errors.Wrap(err, "cannot create resource tree client") + } + + return xrmClient, nil +} + +func makeDiscoveryClient(config *rest.Config) (discovery.DiscoveryInterface, error) { + return discovery.NewDiscoveryClientForConfig(config) +} + +// InitializeClients initializes a list of clients, collecting all errors. +func InitializeClients(ctx context.Context, logger logging.Logger, clients ...Initializable) error { + var errs []error + + for _, c := range clients { + clientType := reflect.TypeOf(c).String() + logger.Debug("Initializing client", "type", clientType) + + err := c.Initialize(ctx) + if err != nil { + logger.Debug("Failed to initialize client", "type", clientType, "error", err) + errs = append(errs, fmt.Errorf("failed to initialize %s: %w", clientType, err)) + } + } + + if len(errs) > 0 { + return errors.Join(errs...) + } + + return nil +} diff --git a/internal/diff/dockerrun/lock_unix.go b/internal/diff/dockerrun/lock_unix.go new file mode 100644 index 00000000..e7e84ee9 --- /dev/null +++ b/internal/diff/dockerrun/lock_unix.go @@ -0,0 +1,32 @@ +//go:build unix + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dockerrun + +import ( + "os" + + "golang.org/x/sys/unix" +) + +// lock takes an exclusive flock on f without blocking. A flock belongs to the open file, not the +// process, so two opens of one lease conflict even within a process, and the kernel drops the lock +// when the process exits, however it exits. +func lock(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) +} diff --git a/internal/diff/dockerrun/lock_windows.go b/internal/diff/dockerrun/lock_windows.go new file mode 100644 index 00000000..3c4cf1c3 --- /dev/null +++ b/internal/diff/dockerrun/lock_windows.go @@ -0,0 +1,32 @@ +//go:build windows + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dockerrun + +import ( + "os" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// lock is unsupported on Windows, where a file that is open cannot be renamed or removed, which the +// lease protocol relies on. Without a lease a run's resources are never reclaimed by another run; the +// run still removes its own when it exits. +func lock(_ *os.File) error { + return errors.New("run leases are not supported on Windows") +} diff --git a/internal/diff/dockerrun/run.go b/internal/diff/dockerrun/run.go new file mode 100644 index 00000000..3b4b1bb9 --- /dev/null +++ b/internal/diff/dockerrun/run.go @@ -0,0 +1,346 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package dockerrun ties the Docker resources a crossplane-diff run creates — function containers, the +// render container and the render network — to that run, and reclaims the ones left behind by runs that +// died without cleaning up (SIGKILL, OOM, a second Ctrl+C, a CI runner tearing down the job). +// +// Ownership is carried by labels. A run's ID is the session ID it hands the upstream render engine +// (render.EngineFlags.SessionID), which labels every container and network the engine creates +// render.crossplane.io/session=, alongside render.crossplane.io/managed-by=crossplane-cli. A +// run's resources are exactly the ones carrying both labels with its ID, so a container or network that +// lacks either, such as one created before session labels existed, is never touched. +// +// Liveness is a lease: a run holds an exclusive lock on ".lock" in the user's cache directory +// for as long as it has resources. The operating system drops the lock when the process exits however +// it exits, so a lease file whose lock can be taken belongs to a dead run. That needs no process table, +// cannot be fooled by PID reuse, and keeps concurrent runs — other worktrees, other test processes — +// from touching each other's resources. A lease that is not in this user's cache directory (another +// user, another container sharing the Docker socket) is never seen, so its resources are never touched. +package dockerrun + +import ( + "context" + "crypto/rand" + "encoding/hex" + "os" + "path/filepath" + "strings" + "time" + + cerrdefs "github.com/containerd/errdefs" + "github.com/moby/moby/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" +) + +const ( + // runIDLength is the length of a run ID: 16 lowercase hex characters (64 random bits), so a session + // label carrying one cannot belong to anything but that run. + runIDLength = 16 + + leaseExt = ".lock" + + // reapTimeout bounds reclaiming dead runs' resources, so a slow Docker daemon cannot hold up a run. + reapTimeout = 10 * time.Second +) + +// Docker is the subset of the Docker API client a Run uses. +type Docker interface { + ContainerList(ctx context.Context, options client.ContainerListOptions) (client.ContainerListResult, error) + ContainerRemove(ctx context.Context, containerID string, options client.ContainerRemoveOptions) (client.ContainerRemoveResult, error) + NetworkList(ctx context.Context, options client.NetworkListOptions) (client.NetworkListResult, error) + NetworkRemove(ctx context.Context, networkID string, options client.NetworkRemoveOptions) (client.NetworkRemoveResult, error) + Close() error +} + +// NewDockerClient returns a Docker API client configured from the environment, the same way the +// upstream render engine configures its own. +func NewDockerClient() (Docker, error) { + return client.New(client.FromEnv) +} + +// DefaultLeaseDir is where runs keep their leases: a per-user directory, so one user's runs never judge +// another's. It returns "" when the user has no cache directory, which disables leasing and reaping. +func DefaultLeaseDir() string { + dir, err := os.UserCacheDir() + if err != nil { + return "" + } + + return filepath.Join(dir, "crossplane-diff", "runs") +} + +// Run is one crossplane-diff run's claim on the Docker resources it creates. It is not safe for +// concurrent use; the render engine that owns it serializes access. +type Run struct { + id string + dir string + newDocker func() (Docker, error) + log logging.Logger + + // docker is set while the run is started. + docker Docker + // lease is the open, locked lease file while the run is started; nil when leasing failed. + lease *os.File +} + +// New returns a Run that keeps its lease in dir ("" disables leasing and reaping) and talks to Docker +// through a client from newDocker. New does no I/O. +func New(dir string, newDocker func() (Docker, error), log logging.Logger) *Run { + b := make([]byte, runIDLength/2) + _, _ = rand.Read(b) // Never returns an error. + + return &Run{id: hex.EncodeToString(b), dir: dir, newDocker: newDocker, log: log} +} + +// ID is the run's ID. It must be the render engine's session ID: the session label is what makes a +// container or network this run's. +func (r *Run) ID() string { + return r.id +} + +// Start prepares the run to create Docker resources; call it before starting any. It takes the run's +// lease and reclaims what dead runs left behind. Both are best effort: a failure there is at most a +// warning. Only failing to create a Docker client is an error. Start is a no-op on a started run. +func (r *Run) Start(ctx context.Context) error { + if r.docker != nil { + return nil + } + + docker, err := r.newDocker() + if err != nil { + return errors.Wrap(err, "cannot create Docker client") + } + + r.docker = docker + + if r.dir != "" { + r.lease, err = acquireLease(r.dir, r.id) + if err != nil { + // Unleased resources are invisible to reapers, which is safe: they are only at risk of + // leaking, as they always were. + r.log.Debug("Cannot take a lease on this run's Docker resources; they will not be reclaimed if it dies", + "dir", r.dir, "error", err) + } + + r.reapDead(ctx) + } + + return nil +} + +// Close removes every container this run created, calls release, then removes every network of the +// run still left and gives up its lease. release is the render engine's own cleanup: it removes the +// network the engine created, which it can only do once no container is attached, so it runs between +// the two. Close removes what upstream cleanup should already have removed too, so a container or +// network upstream leaves behind does not outlive the run. If anything cannot be removed the lease file +// is kept, so the next run reclaims it, and a warning is raised. On a run that is not started, Close +// only calls release. +func (r *Run) Close(ctx context.Context, release func()) { + if r.docker == nil { + release() + return + } + + err := r.removeContainers(ctx, r.id) + + release() + + if err == nil { + err = r.removeNetworks(ctx, r.id) + } + + if err != nil { + r.log.Info("Some Docker containers or networks this run created could not be cleaned up; the next crossplane-diff run will retry", + "error", err) + } + + releaseLease(r.lease, leasePath(r.dir, r.id), err == nil) + + if cerr := r.docker.Close(); cerr != nil { + r.log.Debug("Error closing Docker client", "error", cerr) + } + + r.docker, r.lease = nil, nil +} + +// reapDead reclaims the resources of every run whose lease is in r.dir but held by no process. +func (r *Run) reapDead(ctx context.Context) { + paths, err := filepath.Glob(filepath.Join(r.dir, "*"+leaseExt)) + if err != nil { + return + } + + ctx, cancel := context.WithTimeout(ctx, reapTimeout) + defer cancel() + + var errs []error + + for _, p := range paths { + id := strings.TrimSuffix(filepath.Base(p), leaseExt) + if id == r.id || !validRunID(id) { + continue + } + + f, ok := tryLease(p) + if !ok { + continue + } + + err := r.removeContainers(ctx, id) + if err == nil { + // A container still attached would make removing its network fail, so networks wait for + // every container to be gone. + err = r.removeNetworks(ctx, id) + } + + if err != nil { + errs = append(errs, errors.Wrapf(err, "run %s", id)) + } else { + r.log.Debug("Reclaimed Docker resources left by a dead run", "run", id) + } + + releaseLease(f, p, err == nil) + } + + if len(errs) > 0 { + r.log.Info("Cannot clean up Docker containers or networks left behind by an earlier crossplane-diff run; the next run will retry", + "error", errors.Join(errs...)) + } +} + +// removeContainers removes every container of run id, running or not. +func (r *Run) removeContainers(ctx context.Context, id string) error { + res, err := r.docker.ContainerList(ctx, client.ContainerListOptions{All: true, Filters: sessionFilter(id)}) + if err != nil { + return errors.Wrap(err, "cannot list Docker containers") + } + + var errs []error + + for _, c := range res.Items { + // Force stops the container if it is still running. + if _, err := r.docker.ContainerRemove(ctx, c.ID, client.ContainerRemoveOptions{Force: true}); err != nil && !cerrdefs.IsNotFound(err) { + errs = append(errs, errors.Wrapf(err, "cannot remove container %s", c.ID)) + } + } + + return errors.Join(errs...) +} + +// removeNetworks removes every network of run id. A run that used the local render engine, or a +// network the user supplied, has none. +func (r *Run) removeNetworks(ctx context.Context, id string) error { + res, err := r.docker.NetworkList(ctx, client.NetworkListOptions{Filters: sessionFilter(id)}) + if err != nil { + return errors.Wrap(err, "cannot list Docker networks") + } + + var errs []error + + for _, n := range res.Items { + if _, err := r.docker.NetworkRemove(ctx, n.ID, client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { + errs = append(errs, errors.Wrapf(err, "cannot remove network %s", n.Name)) + } + } + + return errors.Join(errs...) +} + +// sessionFilter matches what the upstream render engine created for run id. Docker requires every label +// filter to match, so a resource is matched only if it carries both labels. +func sessionFilter(id string) client.Filters { + return make(client.Filters).Add("label", + render.LabelKeySession+"="+id, + render.LabelKeyManagedBy+"="+render.LabelValueManagedByCrossplane) +} + +func validRunID(id string) bool { + if len(id) != runIDLength { + return false + } + + _, err := hex.DecodeString(id) + + return err == nil && strings.ToLower(id) == id +} + +func leasePath(dir, id string) string { + return filepath.Join(dir, id+leaseExt) +} + +// acquireLease creates and locks run id's lease file. The file is locked under a temporary name and +// renamed into place, so a reaper never sees a lease file that is not yet locked. +func acquireLease(dir, id string) (*os.File, error) { + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, errors.Wrap(err, "cannot create lease directory") + } + + f, err := os.CreateTemp(dir, id+"-*.tmp") + if err != nil { + return nil, errors.Wrap(err, "cannot create lease file") + } + + if err := lock(f); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + + return nil, errors.Wrap(err, "cannot lock lease file") + } + + if err := os.Rename(f.Name(), leasePath(dir, id)); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + + return nil, errors.Wrap(err, "cannot publish lease file") + } + + return f, nil +} + +// tryLease takes the lease at path if no process holds it, i.e. if its run is dead. If another reaper +// reclaimed the run between our open and our lock, reaping it again finds nothing left to remove. +func tryLease(path string) (*os.File, bool) { + f, err := os.OpenFile(path, os.O_RDWR, 0) //nolint:gosec // The path is a lease file in our own lease directory. + if err != nil { + return nil, false + } + + if err := lock(f); err != nil { + _ = f.Close() + return nil, false + } + + return f, true +} + +// releaseLease gives up a lease, removing its file first when the run's resources are all gone, so no +// one can take a lease that no longer protects anything. A nil lease (leasing failed) is a no-op. +func releaseLease(f *os.File, path string, remove bool) { + if f == nil { + return + } + + if remove { + _ = os.Remove(path) + } + + _ = f.Close() +} diff --git a/internal/diff/dockerrun/run_test.go b/internal/diff/dockerrun/run_test.go new file mode 100644 index 00000000..ba734920 --- /dev/null +++ b/internal/diff/dockerrun/run_test.go @@ -0,0 +1,499 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dockerrun + +import ( + "os" + "path/filepath" + "slices" + "testing" + + cerrdefs "github.com/containerd/errdefs" + gcmp "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +const ( + selfID = "00000000000000ff" + deadID = "dead00000000000a" + dead2ID = "dead00000000000b" + liveID = "1111111111111111" +) + +const ( + running = container.StateRunning + exited = container.StateExited +) + +// hostContainers is a Docker host shared by two dead runs, a live run, this run, and things crossplane-diff +// does not own. A function container that already stopped is listed only with All, and one that is still +// running is removed only with Force, so either mistake leaves one behind. Each run's containers are +// attached to its network, which therefore cannot go while they are there. +var hostContainers = []container.Summary{ + // The dead run: a stopped function container and the render container that was running when it died. + tu.DockerContainer("dead-fn", exited, tu.DockerSession(deadID), "dead-net"), + tu.DockerContainer("dead-render", running, tu.DockerSession(deadID), "dead-net"), + + // A second dead run. + tu.DockerContainer("dead2-fn", exited, tu.DockerSession(dead2ID), "dead2-net"), + + // A live run, and this run. + tu.DockerContainer("live-fn", running, tu.DockerSession(liveID), "live-net"), + tu.DockerContainer("self-fn", running, tu.DockerSession(selfID), "self-net"), + + // Not crossplane-diff's to judge: a container from a version that named function containers for their + // run instead of labelling them, `crossplane render` run by hand (its own session), a container that + // claims the dead run's session but not crossplane's management, and an unrelated container. + tu.DockerContainer("legacy", running, nil, "legacy-net"), + tu.DockerContainer("other-render", running, tu.DockerSession("a1b2c3d4e5f6")), + tu.DockerContainer("session-only", running, map[string]string{render.LabelKeySession: deadID}), + tu.DockerContainer("unrelated", running, map[string]string{"app": "db"}), +} + +// hostNetworks is the same host's networks. +var hostNetworks = []network.Summary{ + tu.DockerNetwork("dead-net", tu.DockerSession(deadID)), + tu.DockerNetwork("dead2-net", tu.DockerSession(dead2ID)), + tu.DockerNetwork("live-net", tu.DockerSession(liveID)), + tu.DockerNetwork("self-net", tu.DockerSession(selfID)), + + // Not crossplane-diff's to judge: a network from a version that named the network for its run, one the + // user supplied with CROSSPLANE_DIFF_DOCKER_NETWORK (render never labels it), and one that claims the + // dead run's session but not crossplane's management. + tu.DockerNetwork("legacy-net", map[string]string{"crossplane-diff.io/run-id": deadID}), + tu.DockerNetwork("user-net", nil), + tu.DockerNetwork("session-only-net", map[string]string{render.LabelKeySession: deadID}), +} + +var ( + // untouchedContainers and untouchedNetworks are what reaping dead runs never removes. + untouchedContainers = []string{"legacy", "live-fn", "other-render", "self-fn", "session-only", "unrelated"} + untouchedNetworks = []string{"legacy-net", "live-net", "self-net", "session-only-net", "user-net"} + + allContainers = slices.Concat(untouchedContainers, []string{"dead-fn", "dead-render", "dead2-fn"}) + allNetworks = slices.Concat(untouchedNetworks, []string{"dead-net", "dead2-net"}) +) + +// newRun returns a Run with ID runID whose leases live in dir. +func newRun(dir, runID string, docker *tu.FakeDocker, log logging.Logger) *Run { + r := New(dir, func() (Docker, error) { return docker, nil }, log) + r.id = runID + + return r +} + +// start starts r the way a running crossplane-diff does, holding its lease until the test ends. +func start(t *testing.T, r *Run) { + t.Helper() + + if err := r.Start(t.Context()); err != nil { + t.Fatalf("Start(...) of run %s: unexpected error: %v", r.ID(), err) + } + + t.Cleanup(func() { + if r.lease != nil { + _ = r.lease.Close() + } + }) +} + +// leaveLease leaves behind the lease file of a run that died without cleaning up: the file exists, but no +// process holds its lock. +func leaveLease(t *testing.T, dir, runID string) { + t.Helper() + + f, err := acquireLease(dir, runID) + if err != nil { + t.Fatalf("acquireLease(%q): %v", runID, err) + } + + if err := f.Close(); err != nil { + t.Fatalf("close lease %q: %v", runID, err) + } +} + +// dirEntries lists dir, so tests can assert which lease files survive. +func dirEntries(t *testing.T, dir string) []string { + t.Helper() + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir(%q): %v", dir, err) + } + + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + + return names +} + +// unordered compares what is left regardless of order: only the end state matters. +var unordered = []gcmp.Option{ + cmpopts.SortSlices(func(a, b string) bool { return a < b }), + cmpopts.EquateEmpty(), +} + +func TestRunStart(t *testing.T) { + // want is what is left once the run has started: the host's containers and networks, the files in the + // lease directory, and how many warnings the run raised. + type want struct { + Containers []string + Networks []string + Files []string + Warnings int + } + + tests := map[string]struct { + reason string + + dead []string // runs that died, leaving their lease behind + live []string // runs that are still running + otherFiles []string // files in the lease directory that are not leases + docker tu.FakeDocker // failures to inject; the host is hostContainers and hostNetworks + + want want + }{ + "NoDeadRunRemovesNothing": { + reason: "With no dead run to reclaim, starting a run removes nothing.", + live: []string{liveID}, + want: want{ + Containers: allContainers, + Networks: allNetworks, + Files: []string{liveID + ".lock", selfID + ".lock"}, + }, + }, + "ReapsOnlyTheDeadRunsResources": { + reason: "A dead run's containers, network and lease are removed. A live run's, this run's, a run " + + "whose lease is not in this directory (another user's), and anything without both of the dead " + + "run's labels (including resources named for it by earlier versions) are left alone.", + dead: []string{deadID}, + live: []string{liveID}, + want: want{ + Containers: slices.Concat(untouchedContainers, []string{"dead2-fn"}), + Networks: slices.Concat(untouchedNetworks, []string{"dead2-net"}), + Files: []string{liveID + ".lock", selfID + ".lock"}, + }, + }, + "ReapsEveryDeadRun": { + reason: "Every dead run is reclaimed.", + dead: []string{deadID, dead2ID}, + want: want{ + Containers: untouchedContainers, + Networks: untouchedNetworks, + Files: []string{selfID + ".lock"}, + }, + }, + "ContainerRemovalFailureKeepsNetworkAndLease": { + reason: "A container that could not be removed still holds its network, and the next run should " + + "retry, so the lease is kept. The failure is a warning, not an error.", + dead: []string{deadID}, + docker: tu.FakeDocker{RemoveErr: map[string]error{"dead-fn": errors.New("daemon says no")}}, + want: want{ + Containers: slices.Concat(untouchedContainers, []string{"dead-fn", "dead2-fn"}), + Networks: allNetworks, + Files: []string{deadID + ".lock", selfID + ".lock"}, + Warnings: 1, + }, + }, + "NetworkRemovalFailureKeepsLease": { + reason: "A network that could not be removed is retried by the next run.", + dead: []string{deadID}, + docker: tu.FakeDocker{RemoveErr: map[string]error{"dead-net": errors.New("daemon says no")}}, + want: want{ + Containers: slices.Concat(untouchedContainers, []string{"dead2-fn"}), + Networks: allNetworks, + Files: []string{deadID + ".lock", selfID + ".lock"}, + Warnings: 1, + }, + }, + "ResourcesAlreadyGoneCountAsReaped": { + reason: "A container or network that no longer exists by the time it is removed, e.g. removed by a " + + "concurrent reaper, is already reclaimed.", + dead: []string{deadID}, + docker: tu.FakeDocker{Vanished: map[string]bool{"dead-fn": true, "dead-net": true}}, + want: want{ + Containers: slices.Concat(untouchedContainers, []string{"dead2-fn"}), + Networks: slices.Concat(untouchedNetworks, []string{"dead2-net"}), + Files: []string{selfID + ".lock"}, + }, + }, + "ContainerListFailureKeepsLeases": { + reason: "If containers cannot be listed nothing is removed and the next run retries.", + dead: []string{deadID}, + docker: tu.FakeDocker{ListErr: errors.New("cannot connect to the Docker daemon")}, + want: want{ + Containers: allContainers, + Networks: allNetworks, + Files: []string{deadID + ".lock", selfID + ".lock"}, + Warnings: 1, + }, + }, + "NetworkListFailureKeepsLeases": { + reason: "If networks cannot be listed the lease is kept, so the next run removes the network.", + dead: []string{deadID}, + docker: tu.FakeDocker{NetworkListErr: errors.New("cannot connect to the Docker daemon")}, + want: want{ + Containers: slices.Concat(untouchedContainers, []string{"dead2-fn"}), + Networks: allNetworks, + Files: []string{deadID + ".lock", selfID + ".lock"}, + Warnings: 1, + }, + }, + "IgnoresFilesThatAreNotLeases": { + reason: "Only .lock files are leases; anything else in the directory is never acted on.", + otherFiles: []string{ + "notes.txt", + "not-a-run-id.lock", + deadID + "-123.tmp", + }, + want: want{ + Containers: allContainers, + Networks: allNetworks, + Files: []string{deadID + "-123.tmp", "not-a-run-id.lock", "notes.txt", selfID + ".lock"}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + + docker := tt.docker + docker.Containers = slices.Clone(hostContainers) + docker.Networks = slices.Clone(hostNetworks) + + // Live runs started before any run died, so they had nothing to reclaim themselves. + for _, runID := range tt.live { + start(t, newRun(dir, runID, &docker, logging.NewNopLogger())) + } + + for _, runID := range tt.dead { + leaveLease(t, dir, runID) + } + + for _, f := range tt.otherFiles { + if err := os.WriteFile(filepath.Join(dir, f), nil, 0o600); err != nil { + t.Fatal(err) + } + } + + log := tu.NewAdvisoryCapturingLogger(t) + start(t, newRun(dir, selfID, &docker, log)) + + got := want{ + Containers: docker.ContainerIDs(), + Networks: docker.NetworkNames(), + Files: dirEntries(t, dir), + Warnings: len(log.Advisories()), + } + if diff := gcmp.Diff(tt.want, got, unordered...); diff != "" { + t.Errorf("\n%s\nStart(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestRunClose(t *testing.T) { + // want is what is left once the run has closed, and how the engine's own cleanup went. + type want struct { + Containers []string + Networks []string + Files []string + Warnings int + + // EngineCleanedUp is whether the engine's cleanup ran, and EngineCleanupFailed whether it could not + // remove its network. + EngineCleanedUp bool + EngineCleanupFailed bool + } + + // The run's function and render containers are attached to the network the engine created for it. + ownContainers := []container.Summary{ + tu.DockerContainer("self-fn", running, tu.DockerSession(selfID), "self-net"), + tu.DockerContainer("self-render", running, tu.DockerSession(selfID), "self-net"), + tu.DockerContainer("live-fn", running, tu.DockerSession(liveID), "live-net"), + tu.DockerContainer("legacy", running, nil), + tu.DockerContainer("unrelated", running, nil), + } + + ownNetworks := []network.Summary{ + tu.DockerNetwork("self-net", tu.DockerSession(selfID)), + tu.DockerNetwork("live-net", tu.DockerSession(liveID)), + tu.DockerNetwork("user-net", nil), + } + + tests := map[string]struct { + reason string + + started bool // whether the run ever started + docker tu.FakeDocker // the host + // engineRemovesNetwork is whether the engine's cleanup removes the network it created, as it does + // unless it fails. + engineRemovesNetwork bool + + want want + }{ + "NeverStartedRemovesNothing": { + reason: "A run that never rendered created nothing, so closing it removes nothing and leaves no " + + "lease. The engine's own cleanup still runs.", + docker: tu.FakeDocker{ + Containers: []container.Summary{tu.DockerContainer("live-fn", running, tu.DockerSession(liveID), "live-net")}, + Networks: []network.Summary{tu.DockerNetwork("live-net", tu.DockerSession(liveID))}, + }, + want: want{Containers: []string{"live-fn"}, Networks: []string{"live-net"}, EngineCleanedUp: true}, + }, + "RemovesEverythingTheRunCreated": { + reason: "Closing removes every container labelled with the run's session before the engine's cleanup " + + "runs, so the engine can remove its network, then gives up its lease. A network the user supplied " + + "carries no session label, so it is never removed.", + started: true, + docker: tu.FakeDocker{Containers: ownContainers, Networks: ownNetworks}, + engineRemovesNetwork: true, + want: want{ + Containers: []string{"legacy", "live-fn", "unrelated"}, + Networks: []string{"live-net", "user-net"}, + EngineCleanedUp: true, + }, + }, + "RemovesTheNetworkTheEngineLeft": { + reason: "A network of the run that the engine's cleanup left behind is removed too.", + started: true, + docker: tu.FakeDocker{Containers: ownContainers, Networks: ownNetworks}, + want: want{ + Containers: []string{"legacy", "live-fn", "unrelated"}, + Networks: []string{"live-net", "user-net"}, + EngineCleanedUp: true, + }, + }, + "FailedContainerRemovalLeavesLeaseForTheNextRun": { + reason: "If the run cannot remove one of its containers, its network cannot go either, and its lease " + + "stays behind (unlocked) so the next run reclaims both. Closing again does not warn again.", + started: true, + docker: tu.FakeDocker{ + Containers: ownContainers, + Networks: ownNetworks, + RemoveErr: map[string]error{"self-render": errors.New("removal already in progress")}, + }, + engineRemovesNetwork: true, + want: want{ + Containers: []string{"legacy", "live-fn", "self-render", "unrelated"}, + Networks: []string{"live-net", "self-net", "user-net"}, + Files: []string{selfID + ".lock"}, + Warnings: 1, + EngineCleanedUp: true, + EngineCleanupFailed: true, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + + docker := tt.docker + docker.Containers = slices.Clone(docker.Containers) + docker.Networks = slices.Clone(docker.Networks) + + log := tu.NewAdvisoryCapturingLogger(t) + r := newRun(dir, selfID, &docker, log) + + if tt.started { + // The engine calls Start for every new batch of functions. + start(t, r) + start(t, r) + } + + got := want{} + release := func() { + got.EngineCleanedUp = true + + if !tt.engineRemovesNetwork { + return + } + + if _, err := docker.NetworkRemove(t.Context(), "self-net", client.NetworkRemoveOptions{}); err != nil && !cerrdefs.IsNotFound(err) { + got.EngineCleanupFailed = true + } + } + + // The processor's Cleanup runs before rendering and again on exit. + r.Close(t.Context(), release) + r.Close(t.Context(), release) + + got.Containers = docker.ContainerIDs() + got.Networks = docker.NetworkNames() + got.Files = dirEntries(t, dir) + got.Warnings = len(log.Advisories()) + + if diff := gcmp.Diff(tt.want, got, unordered...); diff != "" { + t.Errorf("\n%s\nClose(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +// TestRunReclaimsARunThatDied pins reaping end to end, with the run IDs New generates and the leases runs +// really take: a later run leaves a run's resources alone while that run is alive, and removes them once +// its process is gone. +func TestRunReclaimsARunThatDied(t *testing.T) { + type left struct { + Containers []string + Networks []string + } + + dir := t.TempDir() + docker := &tu.FakeDocker{} + newRun := func() *Run { return New(dir, func() (Docker, error) { return docker, nil }, logging.NewNopLogger()) } + + first := newRun() + start(t, first) + + docker.Containers = []container.Summary{tu.DockerContainer("fn", running, tu.DockerSession(first.ID()), "net")} + docker.Networks = []network.Summary{tu.DockerNetwork("net", tu.DockerSession(first.ID()))} + + start(t, newRun()) + + got := map[string]left{"WhileAlive": {Containers: docker.ContainerIDs(), Networks: docker.NetworkNames()}} + + // The first run's process dies: the kernel drops its lock, and its lease file stays. + if err := first.lease.Close(); err != nil { + t.Fatal(err) + } + + start(t, newRun()) + + got["AfterItDied"] = left{Containers: docker.ContainerIDs(), Networks: docker.NetworkNames()} + + want := map[string]left{ + "WhileAlive": {Containers: []string{"fn"}, Networks: []string{"net"}}, + "AfterItDied": {}, + } + if diff := gcmp.Diff(want, got, unordered...); diff != "" { + t.Errorf("what later runs left of a run, -want, +got:\n%s", diff) + } +} diff --git a/internal/diff/ref/ref.go b/internal/diff/ref/ref.go new file mode 100644 index 00000000..0ad7fa0b --- /dev/null +++ b/internal/diff/ref/ref.go @@ -0,0 +1,105 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package ref handles parsing and formatting of [namespace/]name composite +// references supplied via the `--resource` CLI flag. Parse and Format are +// inverses: Parse turns a user-typed string into a NamespacedName, Format +// turns a NamespacedName back into the user's original spelling +// (bare "name" for cluster-scoped, "namespace/name" for namespaced). +package ref + +import ( + "strings" + + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// ParseAll parses each value via Parse and returns the resulting slice. +// Returns (nil, error) on the first parse failure — no partial results. +// Returns (nil, nil) for an empty input. +func ParseAll(values []string) ([]k8stypes.NamespacedName, error) { + if len(values) == 0 { + return nil, nil + } + + out := make([]k8stypes.NamespacedName, 0, len(values)) + + for _, v := range values { + n, err := Parse(v) + if err != nil { + return nil, err + } + + out = append(out, n) + } + + return out, nil +} + +// Parse parses a "[namespace/]name" CLI arg into a NamespacedName. +// Bare "name" (no slash) means cluster-scoped (v1 XRs, v2 cluster-scoped XRs). +// "ns/name" means namespaced (Claims, v2 namespaced XRs). +// "/name" (empty namespace before slash) is rejected because the user's intent is clearly namespaced. +func Parse(value string) (k8stypes.NamespacedName, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return k8stypes.NamespacedName{}, errors.Errorf("invalid --resource value %q: cannot be empty", value) + } + + parts := strings.Split(trimmed, "/") + // Trim each part so inputs like "default / my-claim" — which would otherwise + // parse as namespace "default " and fail downstream with a confusing error — + // are normalized at the point of CLI parsing. Kubernetes names/namespaces + // can't contain whitespace, so per-part trimming never loses information. + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + + switch len(parts) { + case 1: + return k8stypes.NamespacedName{Name: parts[0]}, nil + case 2: + ns, name := parts[0], parts[1] + if ns == "" { + return k8stypes.NamespacedName{}, errors.Errorf("invalid --resource value %q: namespace must not be empty (use bare name for cluster-scoped composites)", value) + } + + if name == "" { + return k8stypes.NamespacedName{}, errors.Errorf("invalid --resource value %q: name must not be empty", value) + } + + return k8stypes.NamespacedName{Namespace: ns, Name: name}, nil + default: + return k8stypes.NamespacedName{}, errors.Errorf("invalid --resource value %q: expected [namespace/]name format, got %d slashes", value, len(parts)-1) + } +} + +// Format renders a NamespacedName the way the user typed it on the command line: +// bare "name" for cluster-scoped, "namespace/name" for namespaced. +// +// NamespacedName.String() always renders "namespace/name" (so "/foo" for +// cluster-scoped), which is wrong for human-facing output where users expect +// their original spelling. +func Format(n k8stypes.NamespacedName) string { + switch n.Namespace { + case "": + return n.Name + default: + return n.Namespace + "/" + n.Name + } +} diff --git a/internal/diff/ref/ref_test.go b/internal/diff/ref/ref_test.go new file mode 100644 index 00000000..68aab87c --- /dev/null +++ b/internal/diff/ref/ref_test.go @@ -0,0 +1,199 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package ref + +import ( + "strings" + "testing" + + k8stypes "k8s.io/apimachinery/pkg/types" +) + +func TestParse(t *testing.T) { + tests := map[string]struct { + input string + want k8stypes.NamespacedName + wantErr bool + }{ + "BareName_ClusterScoped": { + input: "my-xr", + want: k8stypes.NamespacedName{Namespace: "", Name: "my-xr"}, + }, + "NamespaceAndName": { + input: "default/my-claim", + want: k8stypes.NamespacedName{Namespace: "default", Name: "my-claim"}, + }, + "WhitespaceTrimmed": { + input: " default/my-claim ", + want: k8stypes.NamespacedName{Namespace: "default", Name: "my-claim"}, + }, + "WhitespaceAroundSlash_TrimmedPerPart": { + input: "default / my-claim", + want: k8stypes.NamespacedName{Namespace: "default", Name: "my-claim"}, + }, + "WhitespaceAroundClusterScopedName_Trimmed": { + input: " my-xr ", + want: k8stypes.NamespacedName{Name: "my-xr"}, + }, + "Empty": { + input: "", + wantErr: true, + }, + "OnlyWhitespace": { + input: " ", + wantErr: true, + }, + "EmptyNameAfterSlash": { + input: "default/", + wantErr: true, + }, + "TooManySlashes": { + input: "default/foo/bar", + wantErr: true, + }, + "EmptyNamespaceLeadingSlash": { + input: "/foo", + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := Parse(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error for input %q, got %+v", tt.input, got) + } + + if !strings.Contains(err.Error(), tt.input) && tt.input != "" { + t.Errorf("error message %q should reference offending input %q", err.Error(), tt.input) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error for input %q: %v", tt.input, err) + } + + if got != tt.want { + t.Errorf("Parse(%q) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseAll(t *testing.T) { + tests := map[string]struct { + input []string + want []k8stypes.NamespacedName + wantErr bool + wantErrSubstr string + }{ + "Empty_NilInput": { + input: nil, + want: nil, + }, + "Empty_EmptySlice": { + input: []string{}, + want: nil, + }, + "AllValid": { + input: []string{"foo", "ns/bar", "default/baz"}, + want: []k8stypes.NamespacedName{ + {Name: "foo"}, + {Namespace: "ns", Name: "bar"}, + {Namespace: "default", Name: "baz"}, + }, + }, + "FirstErrorStopsParsing": { + input: []string{"good", "/bad", "would-be-good"}, + wantErr: true, + wantErrSubstr: "/bad", + }, + "InvalidEntry": { + input: []string{"a/b/c"}, + wantErr: true, + wantErrSubstr: "a/b/c", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := ParseAll(tt.input) + + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got %+v", got) + } + + if tt.wantErrSubstr != "" && !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantErrSubstr) + } + + if got != nil { + t.Errorf("expected nil result on error, got %+v", got) + } + + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != len(tt.want) { + t.Fatalf("got %d refs, want %d (%+v vs %+v)", len(got), len(tt.want), got, tt.want) + } + + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("ParseAll[%d] = %+v, want %+v", i, got[i], tt.want[i]) + } + } + }) + } +} + +func TestFormat(t *testing.T) { + tests := map[string]struct { + ref k8stypes.NamespacedName + want string + }{ + "ClusterScoped_BareName": { + ref: k8stypes.NamespacedName{Name: "my-xr"}, + want: "my-xr", + }, + "Namespaced": { + ref: k8stypes.NamespacedName{Namespace: "default", Name: "my-claim"}, + want: "default/my-claim", + }, + "EmptyEverything": { + ref: k8stypes.NamespacedName{}, + want: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := Format(tt.ref); got != tt.want { + t.Errorf("Format(%+v) = %q, want %q", tt.ref, got, tt.want) + } + }) + } +} From e4fe74f765c08c6c493caab291dade4579044550 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:36:19 -0400 Subject: [PATCH 006/126] chore(diff): resync ported packages with crossplane-diff 9fa7d31 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/dockerrun/run.go | 19 +++-- internal/diff/dockerrun/run_test.go | 23 +++--- internal/diff/renderer/diff_renderer.go | 2 +- internal/diff/renderer/dry_run_warnings.go | 2 +- internal/diff/renderer/types/types.go | 13 +++- internal/diff/testutils/utils.go | 84 +++++----------------- 6 files changed, 58 insertions(+), 85 deletions(-) diff --git a/internal/diff/dockerrun/run.go b/internal/diff/dockerrun/run.go index 3b4b1bb9..90c1edcf 100644 --- a/internal/diff/dockerrun/run.go +++ b/internal/diff/dockerrun/run.go @@ -48,6 +48,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/cli/v2/cmd/crossplane/render" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" ) const ( @@ -94,6 +95,8 @@ type Run struct { dir string newDocker func() (Docker, error) log logging.Logger + // advisories is where a failure to clean up is reported, since it leaves Docker resources behind. + advisories dt.Advisories // docker is set while the run is started. docker Docker @@ -103,11 +106,11 @@ type Run struct { // New returns a Run that keeps its lease in dir ("" disables leasing and reaping) and talks to Docker // through a client from newDocker. New does no I/O. -func New(dir string, newDocker func() (Docker, error), log logging.Logger) *Run { +func New(dir string, newDocker func() (Docker, error), log logging.Logger, advisories dt.Advisories) *Run { b := make([]byte, runIDLength/2) _, _ = rand.Read(b) // Never returns an error. - return &Run{id: hex.EncodeToString(b), dir: dir, newDocker: newDocker, log: log} + return &Run{id: hex.EncodeToString(b), dir: dir, newDocker: newDocker, log: log, advisories: advisories} } // ID is the run's ID. It must be the render engine's session ID: the session label is what makes a @@ -168,8 +171,10 @@ func (r *Run) Close(ctx context.Context, release func()) { } if err != nil { - r.log.Info("Some Docker containers or networks this run created could not be cleaned up; the next crossplane-diff run will retry", - "error", err) + r.advisories.Raise(dt.OutputWarning{ + Message: "Some Docker containers or networks this run created could not be cleaned up; the next crossplane-diff run will retry", + Context: map[string]string{"error": err.Error()}, + }) } releaseLease(r.lease, leasePath(r.dir, r.id), err == nil) @@ -221,8 +226,10 @@ func (r *Run) reapDead(ctx context.Context) { } if len(errs) > 0 { - r.log.Info("Cannot clean up Docker containers or networks left behind by an earlier crossplane-diff run; the next run will retry", - "error", errors.Join(errs...)) + r.advisories.Raise(dt.OutputWarning{ + Message: "Cannot clean up Docker containers or networks left behind by an earlier crossplane-diff run; the next run will retry", + Context: map[string]string{"error": errors.Join(errs...).Error()}, + }) } } diff --git a/internal/diff/dockerrun/run_test.go b/internal/diff/dockerrun/run_test.go index ba734920..55c7446e 100644 --- a/internal/diff/dockerrun/run_test.go +++ b/internal/diff/dockerrun/run_test.go @@ -33,6 +33,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/cli/v2/cmd/crossplane/render" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -98,8 +99,8 @@ var ( ) // newRun returns a Run with ID runID whose leases live in dir. -func newRun(dir, runID string, docker *tu.FakeDocker, log logging.Logger) *Run { - r := New(dir, func() (Docker, error) { return docker, nil }, log) +func newRun(dir, runID string, docker *tu.FakeDocker, advisories dt.Advisories) *Run { + r := New(dir, func() (Docker, error) { return docker, nil }, logging.NewNopLogger(), advisories) r.id = runID return r @@ -289,7 +290,7 @@ func TestRunStart(t *testing.T) { // Live runs started before any run died, so they had nothing to reclaim themselves. for _, runID := range tt.live { - start(t, newRun(dir, runID, &docker, logging.NewNopLogger())) + start(t, newRun(dir, runID, &docker, &tu.AdvisoryRecorder{})) } for _, runID := range tt.dead { @@ -302,14 +303,14 @@ func TestRunStart(t *testing.T) { } } - log := tu.NewAdvisoryCapturingLogger(t) - start(t, newRun(dir, selfID, &docker, log)) + advisories := &tu.AdvisoryRecorder{} + start(t, newRun(dir, selfID, &docker, advisories)) got := want{ Containers: docker.ContainerIDs(), Networks: docker.NetworkNames(), Files: dirEntries(t, dir), - Warnings: len(log.Advisories()), + Warnings: len(advisories.Advisories()), } if diff := gcmp.Diff(tt.want, got, unordered...); diff != "" { t.Errorf("\n%s\nStart(...): -want, +got:\n%s", tt.reason, diff) @@ -419,8 +420,8 @@ func TestRunClose(t *testing.T) { docker.Containers = slices.Clone(docker.Containers) docker.Networks = slices.Clone(docker.Networks) - log := tu.NewAdvisoryCapturingLogger(t) - r := newRun(dir, selfID, &docker, log) + advisories := &tu.AdvisoryRecorder{} + r := newRun(dir, selfID, &docker, advisories) if tt.started { // The engine calls Start for every new batch of functions. @@ -448,7 +449,7 @@ func TestRunClose(t *testing.T) { got.Containers = docker.ContainerIDs() got.Networks = docker.NetworkNames() got.Files = dirEntries(t, dir) - got.Warnings = len(log.Advisories()) + got.Warnings = len(advisories.Advisories()) if diff := gcmp.Diff(tt.want, got, unordered...); diff != "" { t.Errorf("\n%s\nClose(...): -want, +got:\n%s", tt.reason, diff) @@ -468,7 +469,9 @@ func TestRunReclaimsARunThatDied(t *testing.T) { dir := t.TempDir() docker := &tu.FakeDocker{} - newRun := func() *Run { return New(dir, func() (Docker, error) { return docker, nil }, logging.NewNopLogger()) } + newRun := func() *Run { + return New(dir, func() (Docker, error) { return docker, nil }, logging.NewNopLogger(), &tu.AdvisoryRecorder{}) + } first := newRun() start(t, first) diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index 999d1368..0fe19a86 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -37,7 +37,7 @@ type DiffRenderer interface { // // The warnings parameter carries non-fatal advisories for structured output only. Unlike errors, // warnings have ALREADY been written to stderr, at the moment they were raised (see - // diffprocessor.WarningLogger) — emitting them at render time instead would lose any warning + // diffprocessor.AdvisoryCollector) — emitting them at render time instead would lose any warning // raised during a run that fails before rendering, and would report them out of chronological // order with the work that produced them. The human renderer therefore ignores this parameter. // diff --git a/internal/diff/renderer/dry_run_warnings.go b/internal/diff/renderer/dry_run_warnings.go index ddcc55d4..bcb768cb 100644 --- a/internal/diff/renderer/dry_run_warnings.go +++ b/internal/diff/renderer/dry_run_warnings.go @@ -199,7 +199,7 @@ func compDryRunWarnings(output *CompDiffOutput) []dt.OutputWarning { // writeWarnings writes each warning to w as its human-readable stderr line. // // Only warnings derived at render time belong here. Those raised during the run were written when they -// were raised (see diffprocessor.WarningLogger), and writing them again would print them twice. +// were raised (see diffprocessor.AdvisoryCollector), and writing them again would print them twice. func writeWarnings(w io.Writer, warnings []dt.OutputWarning) error { for _, warning := range warnings { if _, err := fmt.Fprintln(w, warning.FormatWarning()); err != nil { diff --git a/internal/diff/renderer/types/types.go b/internal/diff/renderer/types/types.go index 2b5819a0..828a3160 100644 --- a/internal/diff/renderer/types/types.go +++ b/internal/diff/renderer/types/types.go @@ -425,8 +425,8 @@ func (e OutputError) FormatError() string { // stderr for humans and included in structured output for machines — but deliberately do NOT affect // the exit code. A consumer gating on failure checks errors[], not warnings[]. // -// Context carries the log key/value pairs from the emitting call site (e.g. which composition, how -// many credentials were fetched). It is a map rather than being flattened into Message so machine +// Context carries the details of the occurrence from the raising call site (e.g. which composition, +// which secrets are missing). It is a map rather than being flattened into Message so machine // consumers can read the individual values instead of parsing prose. Unlike OutputError there is no // ResourceID: warnings originate deep in the call stack, where the user-supplied input that led // there is not known, so the anchoring information lives in Context under whatever key the call @@ -436,6 +436,15 @@ type OutputWarning struct { Context map[string]string `json:"context,omitempty"` } +// Advisories is the channel for user-facing advisories. A component that discovers something the +// user should know, but that does not fail the diff, raises it here rather than logging it: logging +// is tracing, shown only under --verbose, while an advisory reaches stderr and structured output's +// warnings[]. Put whatever distinguishes one occurrence from another in Context, because the +// implementation may drop an advisory identical to one already raised. +type Advisories interface { + Raise(w OutputWarning) +} + // FormatWarning renders the warning as a single human-readable stderr line, mirroring // OutputError.FormatError. Context pairs are appended in sorted key order so output is stable // across runs (Go map iteration is not). diff --git a/internal/diff/testutils/utils.go b/internal/diff/testutils/utils.go index 35eb096a..632d1659 100644 --- a/internal/diff/testutils/utils.go +++ b/internal/diff/testutils/utils.go @@ -3,6 +3,7 @@ package testutils import ( stdlog "log" "regexp" + "slices" "strings" "sync" "testing" @@ -16,6 +17,8 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" ) // Colors for terminal output. @@ -106,77 +109,28 @@ func TestLogger(t *testing.T, verbose bool) logging.Logger { return logging.NewLogrLogger(testr.NewWithOptions(t, testr.Options{Verbosity: verbosity})) } -// AdvisoryCapturingLogger wraps a logging.Logger and records the messages passed to Info, so a test -// can assert that a call site raises a user-facing advisory rather than mere tracing. -// -// Info is the codebase's advisory level: the CLI wraps the injected logger so every Info becomes a -// stderr WARNING line and a structured warnings[] entry. Emitters deliberately know nothing about -// that wrapper, so tests for them assert the contract they actually have — "this calls Info, not -// Debug" — instead of coupling to the wrapper. That also lets packages the wrapper's own package -// imports (the clients) assert their advisories without an import cycle. -type AdvisoryCapturingLogger struct { - wrapped logging.Logger - - mu sync.Mutex - advisory []string -} - -// NewAdvisoryCapturingLogger returns a logger that forwards to a standard test logger while recording -// Info messages for assertion. -func NewAdvisoryCapturingLogger(t *testing.T) *AdvisoryCapturingLogger { - t.Helper() - - return &AdvisoryCapturingLogger{wrapped: TestLogger(t, false)} -} - -// Info records msg as an advisory and forwards to the wrapped logger. -func (l *AdvisoryCapturingLogger) Info(msg string, keysAndValues ...any) { - l.mu.Lock() - l.advisory = append(l.advisory, msg) - l.mu.Unlock() - - l.wrapped.Info(msg, keysAndValues...) -} - -// Debug forwards to the wrapped logger without recording: tracing is not an advisory. -func (l *AdvisoryCapturingLogger) Debug(msg string, keysAndValues ...any) { - l.wrapped.Debug(msg, keysAndValues...) -} - -// WithValues returns a derived logger that records into the same place, so an advisory raised through -// a derived logger is still captured. -func (l *AdvisoryCapturingLogger) WithValues(keysAndValues ...any) logging.Logger { - return &advisoryChild{parent: l, wrapped: l.wrapped.WithValues(keysAndValues...)} +// AdvisoryRecorder is a dt.Advisories that records every advisory raised on it, in order, so a test +// in a package that cannot import diffprocessor's AdvisoryCollector (dockerrun, which it imports) can +// assert what was raised. Unlike the collector it does not collapse repeats. +type AdvisoryRecorder struct { + mu sync.Mutex + advisories []dt.OutputWarning } -// Advisories returns the Info messages recorded so far, in call order. -func (l *AdvisoryCapturingLogger) Advisories() []string { - l.mu.Lock() - defer l.mu.Unlock() - - return append([]string{}, l.advisory...) -} +// Raise records w. +func (r *AdvisoryRecorder) Raise(w dt.OutputWarning) { + r.mu.Lock() + defer r.mu.Unlock() -// advisoryChild is a WithValues-derived logger that records into its parent. -type advisoryChild struct { - parent *AdvisoryCapturingLogger - wrapped logging.Logger + r.advisories = append(r.advisories, w) } -func (l *advisoryChild) Info(msg string, keysAndValues ...any) { - l.parent.mu.Lock() - l.parent.advisory = append(l.parent.advisory, msg) - l.parent.mu.Unlock() - - l.wrapped.Info(msg, keysAndValues...) -} - -func (l *advisoryChild) Debug(msg string, keysAndValues ...any) { - l.wrapped.Debug(msg, keysAndValues...) -} +// Advisories returns the advisories recorded so far, in the order they were raised. +func (r *AdvisoryRecorder) Advisories() []dt.OutputWarning { + r.mu.Lock() + defer r.mu.Unlock() -func (l *advisoryChild) WithValues(keysAndValues ...any) logging.Logger { - return &advisoryChild{parent: l.parent, wrapped: l.wrapped.WithValues(keysAndValues...)} + return slices.Clone(r.advisories) } // CreateFakeDiscoveryClient is a helper function to create a fake discovery client for testing. From 8cda5574c7de930922a67e921592de9c17fef679 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:37:20 -0400 Subject: [PATCH 007/126] feat(diff): port kubernetes client from crossplane-diff 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 Signed-off-by: Jonathan Ogilvie --- .../diff/client/kubernetes/access_client.go | 190 +++ .../client/kubernetes/access_client_test.go | 628 +++++++++ .../diff/client/kubernetes/apply_client.go | 205 +++ .../client/kubernetes/apply_client_test.go | 572 ++++++++ internal/diff/client/kubernetes/base.go | 12 + .../diff/client/kubernetes/resource_client.go | 235 ++++ .../client/kubernetes/resource_client_test.go | 1198 +++++++++++++++++ .../diff/client/kubernetes/schema_client.go | 405 ++++++ .../client/kubernetes/schema_client_test.go | 959 +++++++++++++ .../diff/client/kubernetes/type_converter.go | 102 ++ .../client/kubernetes/type_converter_test.go | 366 +++++ 11 files changed, 4872 insertions(+) create mode 100644 internal/diff/client/kubernetes/access_client.go create mode 100644 internal/diff/client/kubernetes/access_client_test.go create mode 100644 internal/diff/client/kubernetes/apply_client.go create mode 100644 internal/diff/client/kubernetes/apply_client_test.go create mode 100644 internal/diff/client/kubernetes/base.go create mode 100644 internal/diff/client/kubernetes/resource_client.go create mode 100644 internal/diff/client/kubernetes/resource_client_test.go create mode 100644 internal/diff/client/kubernetes/schema_client.go create mode 100644 internal/diff/client/kubernetes/schema_client_test.go create mode 100644 internal/diff/client/kubernetes/type_converter.go create mode 100644 internal/diff/client/kubernetes/type_converter_test.go diff --git a/internal/diff/client/kubernetes/access_client.go b/internal/diff/client/kubernetes/access_client.go new file mode 100644 index 00000000..431ac35e --- /dev/null +++ b/internal/diff/client/kubernetes/access_client.go @@ -0,0 +1,190 @@ +package kubernetes + +import ( + "context" + "fmt" + "sync" + + authorizationv1 "k8s.io/api/authorization/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + kubeclient "k8s.io/client-go/kubernetes" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +// AccessChecker answers authorization questions about the current credentials. +type AccessChecker interface { + // Can reports whether the current credentials may perform verb on objects + // of the given GVK in the given namespace (empty namespace for + // cluster-scoped resources). When allowed is false, reason carries a + // human-readable explanation suitable for display. A non-nil error means the + // question could not be answered at all, which callers must treat as a tool + // error rather than as a denial. + Can(ctx context.Context, gvk schema.GroupVersionKind, namespace string, verb dtypes.Verb) (allowed bool, reason string, err error) +} + +// accessCacheKey identifies a single authorization question. All three +// components matter: authorization is per-resource (plural) rather than +// per-Kind, per-namespace (cluster-scoped resources key on the empty +// namespace), and per-verb. The verb in particular cannot be dropped -- holding +// patch but not create on one GVK is precisely the configuration this feature +// exists to cope with, so reusing one verb's answer for another would invert +// the decision it is being asked to make. +type accessCacheKey struct { + gvr schema.GroupVersionResource + namespace string + verb dtypes.Verb +} + +// accessDecision is a memoized answer to one authorization question. +type accessDecision struct { + allowed bool + reason string +} + +// DefaultAccessClient implements AccessChecker via SelfSubjectAccessReview, +// memoizing each answer for the lifetime of the client. +type DefaultAccessClient struct { + kube kubeclient.Interface + typeConverter TypeConverter + logger logging.Logger + + // Decision caching. Reviews are consulted lazily -- only once a dry-run has + // actually come back 403 -- so the happy path costs nothing. On the unhappy + // path a composition rendering 40 objects of one kind would otherwise issue + // 40 identical SSARs; memoization makes it one. + // + // The mutex is released across the API call, so two goroutines racing on a + // cold key can both issue the SSAR. That is benign -- they compute the same + // decision and the second write is idempotent -- and is preferred over + // holding a lock across network I/O. + cache map[accessCacheKey]accessDecision + cacheMutex sync.RWMutex +} + +// NewAccessClient creates a new DefaultAccessClient. +func NewAccessClient(clients *core.Clients, converter TypeConverter, logger logging.Logger) AccessChecker { + return &DefaultAccessClient{ + kube: clients.Kube, + typeConverter: converter, + logger: logger, + cache: make(map[accessCacheKey]accessDecision), + } +} + +// Can reports whether the current credentials may perform verb on objects of the +// given GVK in the given namespace. +func (c *DefaultAccessClient) Can(ctx context.Context, gvk schema.GroupVersionKind, namespace string, verb dtypes.Verb) (bool, string, error) { + // SSAR's resourceAttributes are expressed in terms of the plural resource + // name, not the Kind, so the GVK must be resolved first. DefaultTypeConverter + // memoizes this itself, so a cache hit below costs no API call either. + gvr, err := c.typeConverter.GVKToGVR(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to convert GVK to GVR", "gvk", gvk.String(), "error", err) + return false, "", errors.Wrapf(err, "cannot check %s permission for %s", verb, gvk.String()) + } + + key := accessCacheKey{gvr: gvr, namespace: namespace, verb: verb} + + c.cacheMutex.RLock() + + if decision, ok := c.cache[key]; ok { + c.cacheMutex.RUnlock() + c.logger.Debug("Using cached permission", "verb", verb, "resource", gvr.String(), "namespace", namespace, "allowed", decision.allowed) + + return decision.allowed, decision.reason, nil + } + + c.cacheMutex.RUnlock() + + decision, err := c.review(ctx, gvr, namespace, verb) + if err != nil { + return false, "", err + } + + c.cacheMutex.Lock() + c.cache[key] = decision + c.cacheMutex.Unlock() + + return decision.allowed, decision.reason, nil +} + +// review issues one SelfSubjectAccessReview and interprets its status. +func (c *DefaultAccessClient) review(ctx context.Context, gvr schema.GroupVersionResource, namespace string, verb dtypes.Verb) (accessDecision, error) { + c.logger.Debug("Checking permission", "verb", verb, "resource", gvr.String(), "namespace", namespace) + + target := describeTarget(gvr, namespace) + + // SelfSubjectAccessReview is cluster-scoped, so there is no namespace on the + // request itself; the namespace being asked about travels in + // resourceAttributes. resourceAttributes is also expressed in terms of the + // plural resource rather than the Kind, which is why the GVK had to be + // resolved before we got here. + review := &authorizationv1.SelfSubjectAccessReview{ + Spec: authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: gvr.Group, + Resource: gvr.Resource, + Namespace: namespace, + Verb: string(verb), + }, + }, + } + + result, err := c.kube.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, review, metav1.CreateOptions{}) + if err != nil { + c.logger.Debug("SelfSubjectAccessReview failed", "verb", verb, "resource", gvr.String(), "namespace", namespace, "error", err) + + return accessDecision{}, errors.Wrapf(err, "cannot create SelfSubjectAccessReview for %s on %s", verb, target) + } + + allowed := result.Status.Allowed + reason := result.Status.Reason + + // status.evaluationError means part of the authorizer chain failed to + // evaluate. Kubernetes documents that allowed may still be meaningful in + // that case, but "may still be" is not good enough here: this check exists so + // that a 403 from a dry-run can be attributed to admission rather than to + // RBAC. A wrongly-allowed answer converts an RBAC denial into a reported + // cluster rejection (exit 2) -- a false finding -- whereas a wrongly-denied + // answer only costs fidelity on that one resource. So an evaluation error is + // never read as permission. + // + // It is also not returned as an error: the authorizer answered, just + // incompletely. Failing the whole diff over a partial authorizer hiccup + // would be a worse outcome than the graceful degradation this feature is + // designed around. The message is surfaced in reason so the cause is visible. + if evalErr := result.Status.EvaluationError; evalErr != "" { + allowed = false + + if reason == "" { + reason = fmt.Sprintf("authorizer evaluation error: %s", evalErr) + } else { + reason = fmt.Sprintf("%s (authorizer evaluation error: %s)", reason, evalErr) + } + } + + // The apiserver is not obliged to give a reason, but reason is displayed to + // the user, so an empty denial needs saying something. + if !allowed && reason == "" { + reason = fmt.Sprintf("not authorized to %s %s", verb, target) + } + + c.logger.Debug("Permission checked", "verb", verb, "resource", gvr.String(), "namespace", namespace, "allowed", allowed, "reason", reason) + + return accessDecision{allowed: allowed, reason: reason}, nil +} + +// describeTarget renders the subject of an authorization question for messages. +func describeTarget(gvr schema.GroupVersionResource, namespace string) string { + if namespace == "" { + return fmt.Sprintf("%s at cluster scope", gvr.GroupResource().String()) + } + + return fmt.Sprintf("%s in namespace %q", gvr.GroupResource().String(), namespace) +} diff --git a/internal/diff/client/kubernetes/access_client_test.go b/internal/diff/client/kubernetes/access_client_test.go new file mode 100644 index 00000000..e1ba960c --- /dev/null +++ b/internal/diff/client/kubernetes/access_client_test.go @@ -0,0 +1,628 @@ +package kubernetes + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + authorizationv1 "k8s.io/api/authorization/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" + kt "k8s.io/client-go/testing" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +// ssarResource is the plural name of the reviews themselves, used to target +// reactors and to filter the action log. +const ssarResource = "selfsubjectaccessreviews" + +// newSSARClient returns a fake clientset for SelfSubjectAccessReview traffic. +// Every case installs a reactor, because the fake's default create reaction goes +// to an object tracker that rejects a nameless object -- and a review has no +// name. +func newSSARClient() *fake.Clientset { + return fake.NewClientset() +} + +// withSSARStatus installs a reactor answering every SelfSubjectAccessReview with +// the given status. +func withSSARStatus(cs *fake.Clientset, status authorizationv1.SubjectAccessReviewStatus) { + cs.PrependReactor("create", ssarResource, func(action kt.Action) (bool, runtime.Object, error) { + submitted, err := submittedSSAR(action) + if err != nil { + return true, nil, err + } + + result := submitted.DeepCopy() + result.Status = status + + return true, result, nil + }) +} + +// withSSARStatusPerVerb installs a reactor that answers according to the verb +// under review, so a test can model credentials that hold one verb but not +// another on the same resource. A verb with no entry fails loudly rather than +// falling back to a default, since a silent default here would let a verb-blind +// implementation look correct. +func withSSARStatusPerVerb(cs *fake.Clientset, byVerb map[dtypes.Verb]authorizationv1.SubjectAccessReviewStatus) { + cs.PrependReactor("create", ssarResource, func(action kt.Action) (bool, runtime.Object, error) { + submitted, err := submittedSSAR(action) + if err != nil { + return true, nil, err + } + + if submitted.Spec.ResourceAttributes == nil { + return true, nil, errors.New("review carried no spec.resourceAttributes") + } + + verb := dtypes.Verb(submitted.Spec.ResourceAttributes.Verb) + + status, ok := byVerb[verb] + if !ok { + return true, nil, errors.Errorf("test did not define an answer for verb %q", verb) + } + + result := submitted.DeepCopy() + result.Status = status + + return true, result, nil + }) +} + +// withSSARError installs a reactor that fails the review call itself. +func withSSARError(cs *fake.Clientset, err error) { + cs.PrependReactor("create", ssarResource, func(kt.Action) (bool, runtime.Object, error) { + return true, nil, err + }) +} + +func submittedSSAR(action kt.Action) (*authorizationv1.SelfSubjectAccessReview, error) { + create, ok := action.(kt.CreateAction) + if !ok { + return nil, errors.Errorf("expected a create action, got %T", action) + } + + review, ok := create.GetObject().(*authorizationv1.SelfSubjectAccessReview) + if !ok { + return nil, errors.Errorf("expected a SelfSubjectAccessReview, got %T", create.GetObject()) + } + + return review, nil +} + +// ssarCreates returns every SelfSubjectAccessReview create recorded by the fake +// client, so tests can assert on the number of API calls actually issued. +func ssarCreates(cs *fake.Clientset) []kt.CreateAction { + want := authorizationv1.SchemeGroupVersion.WithResource(ssarResource) + + var creates []kt.CreateAction + + for _, a := range cs.Actions() { + // "create" here is the verb of the call that submits the review, not the + // verb under review -- the latter lives in spec.resourceAttributes. + if a.GetVerb() != "create" || a.GetResource() != want { + continue + } + + if create, ok := a.(kt.CreateAction); ok { + creates = append(creates, create) + } + } + + return creates +} + +// accessResult is one Can answer, in a cmp-comparable shape. +type accessResult struct { + Allowed bool + Reason string +} + +func TestAccessClient_Can(t *testing.T) { + exampleGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ExampleResource"} + otherGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "OtherResource"} + + // step is one Can call plus its expectation. Steps run in order against a + // single client, which is what makes the caching cases expressible. + type step struct { + gvk schema.GroupVersionKind + namespace string + verb dtypes.Verb + want accessResult + wantErr string // substring; empty means no error expected + } + + tests := map[string]struct { + reason string + setup func() (*fake.Clientset, TypeConverter) + steps []step + wantSSARs int + }{ + "Allowed": { + reason: "An SSAR that allows the action should report allowed with no error", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: true}) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{Allowed: true}}, + }, + wantSSARs: 1, + }, + "AllowedWithReason": { + reason: "A reason accompanying an allow should be surfaced, not discarded", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{ + Allowed: true, + Reason: `RBAC: allowed by ClusterRoleBinding "cluster-admin"`, + }) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: true, + Reason: `RBAC: allowed by ClusterRoleBinding "cluster-admin"`, + }}, + }, + wantSSARs: 1, + }, + "DeniedWithReason": { + reason: "A denied SSAR should report not-allowed and surface the SSAR's own reason", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{ + Allowed: false, + Denied: true, + Reason: "not permitted by any authorizer", + }) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "not permitted by any authorizer", + }}, + }, + wantSSARs: 1, + }, + "DeniedWithoutReason": { + reason: "A denial with no reason should still produce a displayable reason naming the verb and target", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: false}) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: `not authorized to create exampleresources.example.org in namespace "test-namespace"`, + }}, + }, + wantSSARs: 1, + }, + "DeniedClusterScopedWithoutReason": { + reason: "A cluster-scoped denial should not claim a namespace it was never asked about", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: false}) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbPatch, namespace: "", want: accessResult{ + Allowed: false, + Reason: "not authorized to patch exampleresources.example.org at cluster scope", + }}, + }, + wantSSARs: 1, + }, + "EvaluationErrorIsNotPermission": { + reason: "An allow accompanied by an evaluation error must not be read as permission", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{ + Allowed: true, + EvaluationError: "webhook authorizer unreachable", + }) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "authorizer evaluation error: webhook authorizer unreachable", + }}, + }, + wantSSARs: 1, + }, + "EvaluationErrorAugmentsReason": { + reason: "An evaluation error alongside a reason should keep both, since either may explain the denial", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{ + Allowed: false, + Reason: "no opinion", + EvaluationError: "webhook authorizer unreachable", + }) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "no opinion (authorizer evaluation error: webhook authorizer unreachable)", + }}, + }, + wantSSARs: 1, + }, + "SecondCallIsCached": { + reason: "Two calls for the same GVR, namespace and verb should issue exactly one API call", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{ + Allowed: false, + Reason: "not permitted by any authorizer", + }) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "not permitted by any authorizer", + }}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "not permitted by any authorizer", + }}, + }, + wantSSARs: 1, + }, + "RepeatedCallsIssueOneReview": { + reason: "Every call after the first for one GVR, namespace and verb should be served from the cache", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: true}) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + }, + wantSSARs: 1, + }, + "DifferentNamespacesAreCachedSeparately": { + reason: "Authorization is per-namespace, so a different namespace must issue its own call", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: true}) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-b", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + }, + wantSSARs: 3, + }, + "DifferentResourcesAreCachedSeparately": { + reason: "Authorization is per-resource, so a different GVR must issue its own call", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: true}) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + {gvk: otherGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + {gvk: otherGVK, verb: dtypes.VerbCreate, namespace: "ns-a", want: accessResult{Allowed: true}}, + }, + wantSSARs: 2, + }, + "DifferentVerbsAreCachedSeparately": { + reason: "Holding patch but not create on one resource is the configuration this feature exists " + + "for, so each verb must get its own review and its own answer", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatusPerVerb(cs, map[dtypes.Verb]authorizationv1.SubjectAccessReviewStatus{ + dtypes.VerbCreate: {Allowed: false, Reason: "no create on exampleresources"}, + dtypes.VerbPatch: {Allowed: true}, + }) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "no create on exampleresources", + }}, + {gvk: exampleGVK, verb: dtypes.VerbPatch, namespace: "test-namespace", want: accessResult{Allowed: true}}, + // Repeat both, reversed, to prove each verb's answer is cached + // under its own key rather than overwriting the other's. + {gvk: exampleGVK, verb: dtypes.VerbPatch, namespace: "test-namespace", want: accessResult{Allowed: true}}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", want: accessResult{ + Allowed: false, + Reason: "no create on exampleresources", + }}, + }, + wantSSARs: 2, + }, + "ReviewCallFails": { + reason: "An SSAR that itself errors must propagate an error rather than be read as a denial", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARError(cs, apierrors.NewForbidden( + authorizationv1.Resource(ssarResource), "", errors.New("no permission to review"))) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + { + gvk: exampleGVK, + verb: dtypes.VerbCreate, + namespace: "test-namespace", + wantErr: `cannot create SelfSubjectAccessReview for create on exampleresources.example.org in namespace "test-namespace"`, + }, + }, + wantSSARs: 1, + }, + "ReviewFailureIsNotCached": { + reason: "A failed review must not be memoized, since it never produced a decision", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARError(cs, errors.New("transient failure")) + + return cs, tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build() + }, + steps: []step{ + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", wantErr: "transient failure"}, + {gvk: exampleGVK, verb: dtypes.VerbCreate, namespace: "test-namespace", wantErr: "transient failure"}, + }, + wantSSARs: 2, + }, + "ConverterFailureFails": { + reason: "Without a plural resource name there is no question to ask, so the failure must propagate", + setup: func() (*fake.Clientset, TypeConverter) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: true}) + + converter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, errors.New("conversion error") + }).Build() + + return cs, converter + }, + steps: []step{ + { + gvk: exampleGVK, + verb: dtypes.VerbCreate, + namespace: "test-namespace", + wantErr: "cannot check create permission for example.org/v1, Kind=ExampleResource", + }, + }, + wantSSARs: 0, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cs, converter := tc.setup() + c := NewAccessClient(&core.Clients{Kube: cs}, converter, tu.TestLogger(t, false)) + + var want, got []accessResult + + for i, s := range tc.steps { + allowed, gotReason, err := c.Can(t.Context(), s.gvk, s.namespace, s.verb) + + if s.wantErr != "" { + switch { + case err == nil: + t.Errorf("\n%s\nCan(...) step %d: expected error but got none", tc.reason, i) + case !strings.Contains(err.Error(), s.wantErr): + t.Errorf("\n%s\nCan(...) step %d: expected error containing %q, got %q", + tc.reason, i, s.wantErr, err.Error()) + } + + if allowed { + t.Errorf("\n%s\nCan(...) step %d: an error must never report allowed", tc.reason, i) + } + + continue + } + + if err != nil { + t.Errorf("\n%s\nCan(...) step %d: unexpected error: %v", tc.reason, i, err) + continue + } + + want = append(want, s.want) + got = append(got, accessResult{Allowed: allowed, Reason: gotReason}) + } + + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("\n%s\nCan(...): -want, +got:\n%s", tc.reason, diff) + } + + if gotSSARs := len(ssarCreates(cs)); gotSSARs != tc.wantSSARs { + t.Errorf("\n%s\nCan(...): want %d SelfSubjectAccessReview API calls, got %d", + tc.reason, tc.wantSSARs, gotSSARs) + } + }) + } +} + +// TestAccessClient_ReviewRequest pins the question we ask. Asking the wrong one +// -- the Kind instead of the plural resource, a hardcoded verb, a namespace in +// the wrong place -- would yield a confidently wrong answer, which is the one +// failure mode this client must not have. +func TestAccessClient_ReviewRequest(t *testing.T) { + tests := map[string]struct { + reason string + gvk schema.GroupVersionKind + namespace string + verb dtypes.Verb + want *authorizationv1.ResourceAttributes + }{ + "NamespacedResource": { + reason: "A namespaced request should carry the namespace in resourceAttributes", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ExampleResource"}, + namespace: "test-namespace", + verb: dtypes.VerbCreate, + want: &authorizationv1.ResourceAttributes{ + Group: "example.org", + Resource: "exampleresources", + Namespace: "test-namespace", + Verb: "create", + }, + }, + "ClusterScopedResource": { + reason: "A cluster-scoped request should carry an empty namespace", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ClusterResource"}, + namespace: "", + verb: dtypes.VerbCreate, + want: &authorizationv1.ResourceAttributes{ + Group: "example.org", + Resource: "clusterresources", + Verb: "create", + }, + }, + "CoreGroupResource": { + reason: "A core-group resource should send an empty group, as the API expects", + gvk: schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + namespace: "test-namespace", + verb: dtypes.VerbCreate, + want: &authorizationv1.ResourceAttributes{ + Resource: "configmaps", + Namespace: "test-namespace", + Verb: "create", + }, + }, + "PatchVerb": { + reason: "The verb under review should be whatever the caller asked about, not a hardcoded create", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ExampleResource"}, + namespace: "test-namespace", + verb: dtypes.VerbPatch, + want: &authorizationv1.ResourceAttributes{ + Group: "example.org", + Resource: "exampleresources", + Namespace: "test-namespace", + Verb: "patch", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{Allowed: true}) + + c := NewAccessClient(&core.Clients{Kube: cs}, + tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build(), + tu.TestLogger(t, false)) + + if _, _, err := c.Can(t.Context(), tc.gvk, tc.namespace, tc.verb); err != nil { + t.Fatalf("\n%s\nCan(...): unexpected error: %v", tc.reason, err) + } + + creates := ssarCreates(cs) + if len(creates) != 1 { + t.Fatalf("\n%s\nCan(...): want 1 SelfSubjectAccessReview API call, got %d", tc.reason, len(creates)) + } + + // The review is cluster-scoped; a namespace on the action itself + // would mean the namespace under review had leaked into the request. + if ns := creates[0].GetNamespace(); ns != "" { + t.Errorf("\n%s\nCan(...): SelfSubjectAccessReview must be cluster-scoped, got namespace %q", tc.reason, ns) + } + + review, err := submittedSSAR(creates[0]) + if err != nil { + t.Fatalf("\n%s\nCan(...): %v", tc.reason, err) + } + + if diff := cmp.Diff(tc.want, review.Spec.ResourceAttributes); diff != "" { + t.Errorf("\n%s\nCan(...): -want resourceAttributes, +got resourceAttributes:\n%s", tc.reason, diff) + } + }) + } +} + +// TestAccessClient_CanIsConcurrencySafe checks the decision cache under +// concurrent use; the calculator holding this client may process several XRs. +// The real assertion here is made by -race: an unguarded map write would be +// reported. Concurrent cold-key callers may each issue a review, which is +// benign, so the call count is bounded rather than pinned. +func TestAccessClient_CanIsConcurrencySafe(t *testing.T) { + const goroutines = 16 + + reason := "Concurrent callers should get one consistent decision with no data race" + + cs := newSSARClient() + withSSARStatus(cs, authorizationv1.SubjectAccessReviewStatus{ + Allowed: false, + Reason: "not permitted by any authorizer", + }) + + c := NewAccessClient(&core.Clients{Kube: cs}, + tu.NewMockTypeConverter().WithDefaultGVKToGVR().Build(), + tu.TestLogger(t, false)) + + gvk := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ExampleResource"} + + results := make([]accessResult, goroutines) + errs := make([]error, goroutines) + + var wg sync.WaitGroup + + for i := range goroutines { + wg.Go(func() { + allowed, gotReason, err := c.Can(t.Context(), gvk, "test-namespace", dtypes.VerbCreate) + results[i] = accessResult{Allowed: allowed, Reason: gotReason} + errs[i] = err + }) + } + + wg.Wait() + + want := make([]accessResult, goroutines) + for i := range want { + want[i] = accessResult{Allowed: false, Reason: "not permitted by any authorizer"} + } + + for i, err := range errs { + if err != nil { + t.Fatalf("\n%s\nCan(...) goroutine %d: unexpected error: %v", reason, i, err) + } + } + + if diff := cmp.Diff(want, results); diff != "" { + t.Errorf("\n%s\nCan(...): -want, +got:\n%s", reason, diff) + } + + if got := len(ssarCreates(cs)); got < 1 || got > goroutines { + t.Errorf("\n%s\nCan(...): want between 1 and %d reviews, got %d", reason, goroutines, got) + } +} diff --git a/internal/diff/client/kubernetes/apply_client.go b/internal/diff/client/kubernetes/apply_client.go new file mode 100644 index 00000000..6b2d9537 --- /dev/null +++ b/internal/diff/client/kubernetes/apply_client.go @@ -0,0 +1,205 @@ +package kubernetes + +import ( + "context" + "fmt" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/dynamic" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" +) + +const ( + // FieldOwnerComposedPrefix is the prefix for field owners of composed resources. + // This matches Crossplane's FieldOwnerComposedPrefix in composition_functions.go. + FieldOwnerComposedPrefix = "apiextensions.crossplane.io/composed" + + // FieldOwnerDefault is the default field owner when no specific owner is provided. + FieldOwnerDefault = "crossplane-diff" +) + +// GetComposedFieldOwner extracts the Crossplane composed resource field owner from +// an existing object's managedFields. Returns empty string if not found. +// This is used to ensure dry-run apply uses the same field owner as Crossplane, +// which correctly handles field removal detection. +func GetComposedFieldOwner(obj *un.Unstructured) string { + if obj == nil { + return "" + } + + for _, mf := range obj.GetManagedFields() { + if strings.HasPrefix(mf.Manager, FieldOwnerComposedPrefix) { + return mf.Manager + } + } + + return "" +} + +// ErrUnresolvableGVK reports that a dry run never reached the apiserver because the object's +// group/version/kind could not be resolved to a resource at all. +// +// It exists because the two failures are indistinguishable by error shape and must not be. Both +// dry-run methods resolve the GVK through discovery first, and a discovery 404 for an unserved +// group/version is apierrors-NotFound — exactly like NamespaceLifecycle refusing a resource whose +// type is perfectly fine. A caller inferring the cause from the apierrors shape alone would blame a +// missing namespace for a type that does not exist, sending the user after entirely the wrong root +// cause. Callers MUST test for this sentinel before classifying an error by its apierrors shape. +// +// The gap is narrow but real: scope determination falls back to a CRD lookup when discovery fails, +// whereas GVKToGVR is discovery-only, so a CRD whose version is not `served` (or one created moments +// earlier, before discovery caught up) passes the earlier guards and fails here. +var ErrUnresolvableGVK = errors.New("cannot resolve resource type") + +// ApplyClient handles server-side apply operations. +type ApplyClient interface { + // DryRunApply performs a dry-run server-side apply. + // If fieldOwner is empty, uses the default field owner. + DryRunApply(ctx context.Context, obj *un.Unstructured, fieldOwner string) (*un.Unstructured, error) + + // DryRunCreate performs a dry-run create, for resources that do not yet exist. + DryRunCreate(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, error) +} + +// DefaultApplyClient implements ApplyClient. +type DefaultApplyClient struct { + dynamicClient dynamic.Interface + typeConverter TypeConverter + logger logging.Logger +} + +// NewApplyClient creates a new DefaultApplyClient. +func NewApplyClient(clients *core.Clients, converter TypeConverter, logger logging.Logger) ApplyClient { + return &DefaultApplyClient{ + dynamicClient: clients.Dynamic, + typeConverter: converter, + logger: logger, + } +} + +// DryRunApply performs a dry-run server-side apply. +// If fieldOwner is empty, uses the default field owner. +func (c *DefaultApplyClient) DryRunApply(ctx context.Context, obj *un.Unstructured, fieldOwner string) (*un.Unstructured, error) { + resourceID := fmt.Sprintf("%s/%s", obj.GetKind(), obj.GetName()) + + // Use default field owner if not specified + if fieldOwner == "" { + fieldOwner = FieldOwnerDefault + } + + c.logger.Debug("Performing dry-run apply", "resource", resourceID, "namespace", obj.GetNamespace(), "fieldOwner", fieldOwner) + + // Get the GVK from the object + gvk := obj.GroupVersionKind() + + // Convert GVK to GVR + gvr, err := c.typeConverter.GVKToGVR(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to convert GVK to GVR", "gvk", gvk.String(), "error", err) + + // Joined, not wrapped, so the sentinel AND the discovery error both stay reachable via + // errors.Is/As: the caller needs the sentinel to classify, and the underlying message to + // explain. See ErrUnresolvableGVK for why the distinction matters. + return nil, errors.Join(ErrUnresolvableGVK, errors.Wrapf(err, "cannot perform dry-run apply for %s (%s)", resourceID, gvk)) + } + + // Get the resource client for the namespace + resourceClient := c.dynamicClient.Resource(gvr).Namespace(obj.GetNamespace()) + + // Create apply options for a dry run with the specified field owner + applyOptions := metav1.ApplyOptions{ + FieldManager: fieldOwner, + Force: true, + DryRun: []string{metav1.DryRunAll}, + } + + // Perform a dry-run server-side apply + result, err := resourceClient.Apply(ctx, obj.GetName(), obj, applyOptions) + if err != nil { + c.logger.Debug("Dry-run apply failed", "resource", resourceID, "error", err) + + return nil, errors.Wrapf(err, "failed to apply resource %s/%s", + obj.GetNamespace(), obj.GetName()) + } + + c.logger.Debug("Dry-run apply successful", "resource", resourceID, "resourceVersion", result.GetResourceVersion()) + + return result, nil +} + +// DryRunCreate performs a dry-run create, so that a resource which does not yet +// exist in the cluster still picks up apiserver defaulting and admission before +// it is diffed. +// +// Server-side apply cannot serve this path: DryRunApply issues a PUT-shaped +// request to a named path, and an addition that relies on metadata.generateName +// has no name yet - SSA has no generateName equivalent. Create is also the +// narrower permission ask (create alone, versus create+patch for a creating SSA +// apply) and the semantically correct primitive for "plan before apply". +// +// There is deliberately no fieldOwner parameter. A brand-new object has no +// managedFields, so GetComposedFieldOwner has nothing to read; creates therefore +// always use FieldOwnerDefault. Do not reintroduce the parameter. +// +// Errors are wrapped with errors.Wrapf, which is implemented as +// fmt.Errorf("...: %w", err) and therefore implements Unwrap. That is load +// bearing: callers discriminate outcomes with apierrors.IsInvalid, +// IsForbidden, IsAlreadyExists, IsInternalError, IsServiceUnavailable and +// IsTimeout, all of which reach the apiserver's APIStatus through errors.As and +// so need an unwrappable chain. Any replacement wrapper MUST also implement +// Unwrap. +func (c *DefaultApplyClient) DryRunCreate(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + // An addition may carry only metadata.generateName, so the name can be + // empty. The identifier must neither degenerate to "Kind/" nor imply a name + // that does not exist yet. + resourceID := obj.GetKind() + + switch { + case obj.GetName() != "": + resourceID = fmt.Sprintf("%s/%s", obj.GetKind(), obj.GetName()) + case obj.GetGenerateName() != "": + resourceID = fmt.Sprintf("%s (generateName %s)", obj.GetKind(), obj.GetGenerateName()) + } + + c.logger.Debug("Performing dry-run create", "resource", resourceID, "namespace", obj.GetNamespace(), "fieldOwner", FieldOwnerDefault) + + // Get the GVK from the object + gvk := obj.GroupVersionKind() + + // Convert GVK to GVR + gvr, err := c.typeConverter.GVKToGVR(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to convert GVK to GVR", "gvk", gvk.String(), "error", err) + + // See the matching branch in DryRunApply, and ErrUnresolvableGVK, for why this must be + // distinguishable from an admission-time NotFound. + return nil, errors.Join(ErrUnresolvableGVK, errors.Wrapf(err, "cannot perform dry-run create for %s (%s)", resourceID, gvk)) + } + + // Get the resource client for the namespace + resourceClient := c.dynamicClient.Resource(gvr).Namespace(obj.GetNamespace()) + + // Create options for a dry run + createOptions := metav1.CreateOptions{ + FieldManager: FieldOwnerDefault, + DryRun: []string{metav1.DryRunAll}, + } + + // Perform a dry-run create + result, err := resourceClient.Create(ctx, obj, createOptions) + if err != nil { + c.logger.Debug("Dry-run create failed", "resource", resourceID, "error", err) + + return nil, errors.Wrapf(err, "failed to dry-run create resource %s", resourceID) + } + + c.logger.Debug("Dry-run create successful", "resource", resourceID, "resourceVersion", result.GetResourceVersion()) + + return result, nil +} diff --git a/internal/diff/client/kubernetes/apply_client_test.go b/internal/diff/client/kubernetes/apply_client_test.go new file mode 100644 index 00000000..7cc43e43 --- /dev/null +++ b/internal/diff/client/kubernetes/apply_client_test.go @@ -0,0 +1,572 @@ +package kubernetes + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + kt "k8s.io/client-go/testing" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ ApplyClient = (*tu.MockApplyClient)(nil) + +func TestApplyClient_DryRunApply(t *testing.T) { + scheme := runtime.NewScheme() + + type args struct { + ctx context.Context + obj *un.Unstructured + } + + type want struct { + result *un.Unstructured + err error + } + + tests := map[string]struct { + reason string + setup func() (dynamic.Interface, TypeConverter) + args args + want want + }{ + "NamespacedResourceApplied": { + reason: "Should successfully apply a namespaced resource", + setup: func() (dynamic.Interface, TypeConverter) { + obj := tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build() + + // Create dynamic client that returns the object with a resource version + dynamicClient := fake.NewSimpleDynamicClient(scheme) + // Add reactor to handle apply operation + dynamicClient.PrependReactor("patch", "exampleresources", func(kt.Action) (bool, runtime.Object, error) { + // For apply, we'd return the "server-modified" version + result := obj.DeepCopy() + result.SetResourceVersion("1000") // Server would set this + + return true, result, nil + }) + + // Create type converter + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "exampleresources", + }, nil + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build(), + }, + want: want{ + result: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build(), + }, + }, + "ClusterScopedResourceApplied": { + reason: "Should successfully apply a cluster-scoped resource", + setup: func() (dynamic.Interface, TypeConverter) { + obj := tu.NewResource("example.org/v1", "ClusterResource", "test-cluster-resource"). + WithSpecField("property", "new-value"). + Build() + + // Create dynamic client that returns the object with a resource version + dynamicClient := fake.NewSimpleDynamicClient(scheme) + // Add reactor to handle apply operation + dynamicClient.PrependReactor("patch", "clusterresources", func(kt.Action) (bool, runtime.Object, error) { + // For apply, we'd return the "server-modified" version + result := obj.DeepCopy() + result.SetResourceVersion("1000") // Server would set this + + return true, result, nil + }) + + // Create type converter + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "clusterresources", + }, nil + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + obj: tu.NewResource("example.org/v1", "ClusterResource", "test-cluster-resource"). + WithSpecField("property", "new-value"). + Build(), + }, + want: want{ + result: tu.NewResource("example.org/v1", "ClusterResource", "test-cluster-resource"). + WithSpecField("property", "new-value"). + Build(), + }, + }, + "ConverterError": { + reason: "Should return error when GVK to GVR conversion fails", + setup: func() (dynamic.Interface, TypeConverter) { + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + // Create type converter that returns an error + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, errors.New("conversion error") + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build(), + }, + want: want{ + err: errors.New("cannot perform dry-run apply for ExampleResource/test-resource"), + }, + }, + "ApplyError": { + reason: "Should return error when apply fails", + setup: func() (dynamic.Interface, TypeConverter) { + dynamicClient := fake.NewSimpleDynamicClient(scheme) + // Add reactor to make apply fail + dynamicClient.PrependReactor("patch", "exampleresources", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("apply failed") + }) + + // Create type converter + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "exampleresources", + }, nil + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build(), + }, + want: want{ + err: errors.New("failed to apply resource"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dynamicClient, converter := tc.setup() + + c := &DefaultApplyClient{ + dynamicClient: dynamicClient, + typeConverter: converter, + logger: tu.TestLogger(t, false), + } + + got, err := c.DryRunApply(tc.args.ctx, tc.args.obj, "") + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nDryRunApply(...): expected error but got none", tc.reason) + return + } + + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nDryRunApply(...): expected error containing %q, got %q", + tc.reason, tc.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nDryRunApply(...): unexpected error: %v", tc.reason, err) + return + } + + // For successful cases, compare the original parts of results + // We remove the resourceVersion before comparing since we set it in our test + gotCopy := got.DeepCopy() + if _, exists, _ := un.NestedString(gotCopy.Object, "metadata", "resourceVersion"); exists { + un.RemoveNestedField(gotCopy.Object, "metadata", "resourceVersion") + } + + wantCopy := tc.want.result.DeepCopy() + if _, exists, _ := un.NestedString(wantCopy.Object, "metadata", "resourceVersion"); exists { + un.RemoveNestedField(wantCopy.Object, "metadata", "resourceVersion") + } + + if diff := cmp.Diff(wantCopy, gotCopy); diff != "" { + t.Errorf("\n%s\nDryRunApply(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestApplyClient_DryRunCreate(t *testing.T) { + scheme := runtime.NewScheme() + + // exampleGVR is what the mock converter resolves every GVK to. + exampleGVR := schema.GroupVersionResource{Group: "example.org", Version: "v1", Resource: "exampleresources"} + + newConverter := func() TypeConverter { + return tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "exampleresources", + }, nil + }).Build() + } + + type want struct { + result *un.Unstructured + // errContains is matched against the error string. + errContains string + // errPredicate, when set, MUST hold for the returned error. This is how + // we prove the wrapping preserves apierrors classification. + errPredicate func(error) bool + // action, when set, asserts on the create action the fake client recorded. + action func(*testing.T, kt.CreateActionImpl) + } + + tests := map[string]struct { + reason string + obj *un.Unstructured + // reaction is registered as a "create" reactor on exampleresources. + reaction func(kt.Action) (bool, runtime.Object, error) + // converter, when nil, resolves every GVK to exampleresources. + converter TypeConverter + want want + }{ + "SendsDryRunAllAndDefaultFieldManager": { + reason: "Should send dryRun=All and the default field manager on the create request", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build(), + reaction: func(action kt.Action) (bool, runtime.Object, error) { + // The reactor is only registered for create actions. + return true, action.(kt.CreateActionImpl).Object.DeepCopyObject(), nil + }, + want: want{ + result: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "new-value"). + Build(), + action: func(t *testing.T, action kt.CreateActionImpl) { + t.Helper() + + if diff := cmp.Diff([]string{metav1.DryRunAll}, action.CreateOptions.DryRun); diff != "" { + t.Errorf("CreateOptions.DryRun: -want, +got:\n%s", diff) + } + + if action.CreateOptions.FieldManager != FieldOwnerDefault { + t.Errorf("CreateOptions.FieldManager: want %q, got %q", FieldOwnerDefault, action.CreateOptions.FieldManager) + } + + if action.Namespace != "test-namespace" { + t.Errorf("action namespace: want %q, got %q", "test-namespace", action.Namespace) + } + }, + }, + }, + "GenerateNameOnlyResourceAccepted": { + reason: "Should accept an addition that carries only generateName, and send it unnamed", + obj: tu.NewResource("example.org/v1", "ExampleResource", ""). + InNamespace("test-namespace"). + WithGenerateName("test-resource-"). + WithSpecField("property", "new-value"). + Build(), + reaction: func(action kt.Action) (bool, runtime.Object, error) { + // The server would invent a name here; mirror that so we also + // prove the response is returned verbatim. + // The reactor is only registered for create actions. + result := action.(kt.CreateActionImpl).Object.DeepCopyObject().(*un.Unstructured) + result.SetName("test-resource-abc12") + + return true, result, nil + }, + want: want{ + result: tu.NewResource("example.org/v1", "ExampleResource", "test-resource-abc12"). + InNamespace("test-namespace"). + WithGenerateName("test-resource-"). + WithSpecField("property", "new-value"). + Build(), + action: func(t *testing.T, action kt.CreateActionImpl) { + t.Helper() + + sent, ok := action.Object.(*un.Unstructured) + if !ok { + t.Fatalf("create action object: want *unstructured.Unstructured, got %T", action.Object) + } + + if sent.GetName() != "" { + t.Errorf("sent name: want empty, got %q", sent.GetName()) + } + + if sent.GetGenerateName() != "test-resource-" { + t.Errorf("sent generateName: want %q, got %q", "test-resource-", sent.GetGenerateName()) + } + + // A named path would have surfaced here; create must not use one. + if action.Name != "" { + t.Errorf("create action name: want empty, got %q", action.Name) + } + }, + }, + }, + "ClusterScopedResourceCreated": { + reason: "Should create a cluster-scoped resource without a namespace", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-cluster-resource"). + WithSpecField("property", "new-value"). + Build(), + reaction: func(action kt.Action) (bool, runtime.Object, error) { + // The reactor is only registered for create actions. + return true, action.(kt.CreateActionImpl).Object.DeepCopyObject(), nil + }, + want: want{ + result: tu.NewResource("example.org/v1", "ExampleResource", "test-cluster-resource"). + WithSpecField("property", "new-value"). + Build(), + action: func(t *testing.T, action kt.CreateActionImpl) { + t.Helper() + + if action.Namespace != "" { + t.Errorf("action namespace: want empty, got %q", action.Namespace) + } + }, + }, + }, + "ForbiddenErrorStaysClassifiable": { + reason: "A Forbidden returned by the apiserver must still satisfy apierrors.IsForbidden after wrapping", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + Build(), + reaction: func(kt.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewForbidden( + exampleGVR.GroupResource(), + "test-resource", + errors.New("exceeded quota: compute-resources"), + ) + }, + want: want{ + errContains: "failed to dry-run create resource ExampleResource/test-resource", + errPredicate: apierrors.IsForbidden, + }, + }, + "InvalidErrorStaysClassifiable": { + reason: "An Invalid returned by the apiserver must still satisfy apierrors.IsInvalid after wrapping", + obj: tu.NewResource("example.org/v1", "ExampleResource", ""). + InNamespace("test-namespace"). + WithGenerateName("test-resource-"). + Build(), + reaction: func(kt.Action) (bool, runtime.Object, error) { + return true, nil, apierrors.NewInvalid( + schema.GroupKind{Group: "example.org", Kind: "ExampleResource"}, + "", + field.ErrorList{field.Required(field.NewPath("spec", "property"), "must be set")}, + ) + }, + want: want{ + // The identifier must not degenerate to "ExampleResource/" for a + // generateName-only object. + errContains: "failed to dry-run create resource ExampleResource (generateName test-resource-)", + errPredicate: apierrors.IsInvalid, + }, + }, + "ConverterErrorCarriesTheUnresolvableGVKSentinel": { + // The sentinel is the whole point of this case, not decoration. The request never reached the + // apiserver, yet a real discovery failure for an unserved group/version is apierrors-NotFound + // — identical in shape to NamespaceLifecycle refusing a resource whose type is perfectly + // fine. Callers classify on this sentinel precisely so they cannot confuse the two; if it + // stops being attached, an unknown type gets reported as a missing namespace. + reason: "A GVK that cannot be resolved is distinguishable from an admission-time rejection", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + Build(), + converter: tu.NewMockTypeConverter(). + WithGVKToGVR(func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error) { + // Shaped like the real thing: discovery 404 for an unserved group/version. + return schema.GroupVersionResource{}, apierrors.NewNotFound(schema.GroupResource{Group: "example.org", Resource: "exampleresources"}, "") + }).Build(), + want: want{ + // The GVK is named so the message points at the type, not at the namespace. + errContains: "cannot perform dry-run create for ExampleResource/test-resource (example.org/v1, Kind=ExampleResource)", + errPredicate: func(err error) bool { return errors.Is(err, ErrUnresolvableGVK) }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dynamicClient := fake.NewSimpleDynamicClient(scheme) + if tc.reaction != nil { + dynamicClient.PrependReactor("create", exampleGVR.Resource, tc.reaction) + } + + converter := tc.converter + if converter == nil { + converter = newConverter() + } + + c := &DefaultApplyClient{ + dynamicClient: dynamicClient, + typeConverter: converter, + logger: tu.TestLogger(t, false), + } + + got, err := c.DryRunCreate(t.Context(), tc.obj) + + if tc.want.errContains != "" { + if err == nil { + t.Fatalf("\n%s\nDryRunCreate(...): expected error but got none", tc.reason) + } + + if !strings.Contains(err.Error(), tc.want.errContains) { + t.Errorf("\n%s\nDryRunCreate(...): expected error containing %q, got %q", + tc.reason, tc.want.errContains, err.Error()) + } + + if tc.want.errPredicate != nil && !tc.want.errPredicate(err) { + t.Errorf("\n%s\nDryRunCreate(...): wrapped error lost its apierrors classification: %v", + tc.reason, err) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nDryRunCreate(...): unexpected error: %v", tc.reason, err) + } + + if diff := cmp.Diff(tc.want.result, got); diff != "" { + t.Errorf("\n%s\nDryRunCreate(...): -want, +got:\n%s", tc.reason, diff) + } + + if tc.want.action != nil { + createAction, ok := findCreateAction(dynamicClient.Actions()) + if !ok { + t.Fatalf("\n%s\nDryRunCreate(...): no create action recorded", tc.reason) + } + + tc.want.action(t, createAction) + } + }) + } +} + +// findCreateAction returns the first recorded create action, if any. +func findCreateAction(actions []kt.Action) (kt.CreateActionImpl, bool) { + for _, a := range actions { + if create, ok := a.(kt.CreateActionImpl); ok && a.GetVerb() == "create" { + return create, true + } + } + + return kt.CreateActionImpl{}, false +} + +func TestGetComposedFieldOwner(t *testing.T) { + tests := map[string]struct { + reason string + obj *un.Unstructured + want string + }{ + "NilObject": { + reason: "Should return empty string for nil object", + obj: nil, + want: "", + }, + "NoManagedFields": { + reason: "Should return empty string when object has no managed fields", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + Build(), + want: "", + }, + "ManagedFieldsWithoutCrossplanePrefix": { + reason: "Should return empty string when managed fields don't contain Crossplane composed prefix", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + WithFieldManagers("kubectl-client-side-apply", "other-controller"). + Build(), + want: "", + }, + "ManagedFieldsWithCrossplaneComposedPrefix": { + reason: "Should return the Crossplane composed field owner when present", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + WithFieldManagers( + "kubectl-client-side-apply", + "apiextensions.crossplane.io/composed/abc123def456", + "other-controller", + ). + Build(), + want: "apiextensions.crossplane.io/composed/abc123def456", + }, + "MultipleCrossplanePrefixes": { + reason: "Should return the first Crossplane composed field owner when multiple present", + obj: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + WithFieldManagers( + "apiextensions.crossplane.io/composed/first-hash", + "apiextensions.crossplane.io/composed/second-hash", + ). + Build(), + want: "apiextensions.crossplane.io/composed/first-hash", + }, + "RealWorldCrossplaneFieldOwner": { + reason: "Should correctly extract a real-world Crossplane field owner hash", + // This simulates a real composed resource from Crossplane + obj: tu.NewResource("nop.crossplane.io/v1alpha1", "ClusterNopResource", "test-xr-abc123"). + WithFieldManagers( + "crossplane", + "apiextensions.crossplane.io/composed/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + ). + Build(), + want: "apiextensions.crossplane.io/composed/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := GetComposedFieldOwner(tc.obj) + + if got != tc.want { + t.Errorf("\n%s\nGetComposedFieldOwner(...): want %q, got %q", tc.reason, tc.want, got) + } + }) + } +} diff --git a/internal/diff/client/kubernetes/base.go b/internal/diff/client/kubernetes/base.go new file mode 100644 index 00000000..0c24324d --- /dev/null +++ b/internal/diff/client/kubernetes/base.go @@ -0,0 +1,12 @@ +// Package kubernetes contains interfaces and implementations for clients that talk directly to Kubernetes +package kubernetes + +// Clients is an aggregation of all of our Kubernetes clients, used to pass them as a bundle, +// typically for initialization where the consumer can select which ones they need. +type Clients struct { + Access AccessChecker + Apply ApplyClient + Resource ResourceClient + Schema SchemaClient + Type TypeConverter +} diff --git a/internal/diff/client/kubernetes/resource_client.go b/internal/diff/client/kubernetes/resource_client.go new file mode 100644 index 00000000..4d085e02 --- /dev/null +++ b/internal/diff/client/kubernetes/resource_client.go @@ -0,0 +1,235 @@ +package kubernetes + +import ( + "context" + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" +) + +// ResourceClient handles basic CRUD operations for Kubernetes resources. +type ResourceClient interface { + // GetResource retrieves a resource by its GVK, namespace, and name + GetResource(ctx context.Context, gvk schema.GroupVersionKind, namespace, name string) (*un.Unstructured, error) + + // ListResources lists resources matching the given GVK and namespace + ListResources(ctx context.Context, gvk schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) + + // GetResourcesByLabel returns resources matching labels in the given namespace + GetResourcesByLabel(ctx context.Context, gvk schema.GroupVersionKind, namespace string, sel metav1.LabelSelector) ([]*un.Unstructured, error) + + // GetGVKsForGroupKind retrieves all GroupVersionKinds for a given group and kind + GetGVKsForGroupKind(ctx context.Context, group, kind string) ([]schema.GroupVersionKind, error) + + // IsNamespacedResource determines if a given GVK represents a namespaced resource + IsNamespacedResource(ctx context.Context, gvk schema.GroupVersionKind) (bool, error) +} + +// DefaultResourceClient implements the ResourceClient interface. +type DefaultResourceClient struct { + dynamicClient dynamic.Interface + discoveryClient discovery.DiscoveryInterface + converter TypeConverter + logger logging.Logger +} + +// NewResourceClient creates a new DefaultResourceClient instance. +func NewResourceClient(clients *core.Clients, converter TypeConverter, logger logging.Logger) ResourceClient { + return &DefaultResourceClient{ + dynamicClient: clients.Dynamic, + discoveryClient: clients.Discovery, + converter: converter, + logger: logger, + } +} + +// GetResource retrieves a resource from the cluster based on its GVK, namespace, and name. +func (c *DefaultResourceClient) GetResource(ctx context.Context, gvk schema.GroupVersionKind, namespace, name string) (*un.Unstructured, error) { + resourceID := fmt.Sprintf("%s/%s/%s", gvk.String(), namespace, name) + c.logger.Debug("Getting resource from cluster", "resource", resourceID) + + // Convert GVK to GVR + gvr, err := c.converter.GVKToGVR(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to convert GVK to GVR", "gvk", gvk.String(), "error", err) + return nil, errors.Wrapf(err, "cannot get resource %s/%s of kind %s", namespace, name, gvk.Kind) + } + + // Get the resource + res, err := c.dynamicClient.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + c.logger.Debug("Failed to get resource", "resource", resourceID, "error", err) + return nil, errors.Wrapf(err, "cannot get resource %s/%s of kind %s", namespace, name, gvk.Kind) + } + + c.logger.Debug("Retrieved resource", + "resource", resourceID, + "uid", res.GetUID(), + "resourceVersion", res.GetResourceVersion()) + + return res, nil +} + +// GetResourcesByLabel returns resources matching labels in the given namespace. +func (c *DefaultResourceClient) GetResourcesByLabel(ctx context.Context, gvk schema.GroupVersionKind, namespace string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + c.logger.Debug("Getting resources by label", + "namespace", namespace, + "gvk", gvk.String(), + "selector", sel.MatchLabels) + + // Convert GVK to GVR + gvr, err := c.converter.GVKToGVR(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to convert GVK to GVR", "gvk", gvk.String(), "error", err) + return nil, errors.Wrapf(err, "cannot list resources for '%s' matching labels", gvk.String()) + } + + // Create list options with label selector + opts := metav1.ListOptions{} + if len(sel.MatchLabels) > 0 { + opts.LabelSelector = metav1.FormatLabelSelector(&sel) + } + + // Perform the list operation + list, err := c.dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, opts) + if err != nil { + c.logger.Debug("Failed to list resources", "gvk", gvk.String(), "namespace", namespace, "labelSelector", opts.LabelSelector, "error", err) + return nil, errors.Wrapf(err, "cannot list resources for '%s/%s' matching '%s'", namespace, gvk.String(), opts.LabelSelector) + } + + // Convert the list items to a slice of pointers + resources := make([]*un.Unstructured, 0, len(list.Items)) + for i := range list.Items { + resources = append(resources, &list.Items[i]) + } + + c.logger.Debug("Resources found by label", "count", len(resources), "gvk", gvk.String()) + + return resources, nil +} + +// ListResources lists resources matching the given GVK and namespace. +func (c *DefaultResourceClient) ListResources(ctx context.Context, gvk schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) { + c.logger.Debug("Listing resources", "gvk", gvk.String(), "namespace", namespace) + + // Convert GVK to GVR + gvr, err := c.converter.GVKToGVR(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to convert GVK to GVR", "gvk", gvk.String(), "error", err) + return nil, errors.Wrapf(err, "cannot list resources for '%s'", gvk.String()) + } + + // Perform the list operation + list, err := c.dynamicClient.Resource(gvr).Namespace(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + c.logger.Debug("Failed to list resources", "gvk", gvk.String(), "namespace", namespace, "error", err) + return nil, errors.Wrapf(err, "cannot list resources for '%s'", gvk.String()) + } + + // Convert from items to slice of pointers + resources := make([]*un.Unstructured, 0, len(list.Items)) + for i := range list.Items { + resources = append(resources, &list.Items[i]) + } + + c.logger.Debug("Listed resources", "gvk", gvk.String(), "namespace", namespace, "count", len(resources)) + + return resources, nil +} + +// GetGVKsForGroupKind returns all available GroupVersionKinds for a given group and kind. +// It queries only the specified API group rather than all server APIs, avoiding issues +// with stale discovery data in unrelated API groups. +func (c *DefaultResourceClient) GetGVKsForGroupKind(_ context.Context, group, kind string) ([]schema.GroupVersionKind, error) { + // Get API groups (lightweight - doesn't fetch all resources) + apiGroups, err := c.discoveryClient.ServerGroups() + if err != nil { + return nil, errors.Wrap(err, "cannot get server groups") + } + + // Find the target group and its versions + var targetGroup *metav1.APIGroup + + for i := range apiGroups.Groups { + if apiGroups.Groups[i].Name == group { + targetGroup = &apiGroups.Groups[i] + break + } + } + + if targetGroup == nil { + return nil, errors.Errorf("API group %q not found on server", group) + } + + var gvks []schema.GroupVersionKind + + // Query only the versions of the target group + for _, version := range targetGroup.Versions { + resourceList, err := c.discoveryClient.ServerResourcesForGroupVersion(version.GroupVersion) + if err != nil { + c.logger.Debug("Failed to get resources for group version", "groupVersion", version.GroupVersion, "error", err) + continue + } + + for _, resource := range resourceList.APIResources { + if resource.Kind == kind { + gv, err := schema.ParseGroupVersion(version.GroupVersion) + if err != nil { + continue + } + + gvk := schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: kind, + } + gvks = append(gvks, gvk) + + break // Found the kind in this version, move to next version + } + } + } + + return gvks, nil +} + +// IsNamespacedResource determines if a given GVK represents a namespaced resource +// by querying the cluster's discovery API. +func (c *DefaultResourceClient) IsNamespacedResource(_ context.Context, gvk schema.GroupVersionKind) (bool, error) { + // Get the server resources for this group/version + groupVersion := gvk.GroupVersion().String() + + resourceList, err := c.discoveryClient.ServerResourcesForGroupVersion(groupVersion) + if err != nil { + return false, errors.Wrapf(err, "cannot get server resources for group version %s", groupVersion) + } + + // Find the resource matching our kind + for _, resource := range resourceList.APIResources { + if resource.Kind == gvk.Kind { + // The Namespaced field indicates whether the resource is namespaced + c.logger.Debug("Determined resource scope from discovery", + "gvk", gvk.String(), + "namespaced", resource.Namespaced) + + return resource.Namespaced, nil + } + } + + // If we can't find the resource, this is an error condition that should fail the diff + availableKinds := make([]string, len(resourceList.APIResources)) + for i, resource := range resourceList.APIResources { + availableKinds[i] = resource.Kind + } + + return false, errors.Errorf("resource kind %s not found in discovery API for group version %s (available kinds: %v)", gvk.Kind, groupVersion, availableKinds) +} diff --git a/internal/diff/client/kubernetes/resource_client_test.go b/internal/diff/client/kubernetes/resource_client_test.go new file mode 100644 index 00000000..47ddc583 --- /dev/null +++ b/internal/diff/client/kubernetes/resource_client_test.go @@ -0,0 +1,1198 @@ +package kubernetes + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + kt "k8s.io/client-go/testing" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ ResourceClient = (*tu.MockResourceClient)(nil) + +func TestResourceClient_GetResource(t *testing.T) { + scheme := runtime.NewScheme() + + type args struct { + ctx context.Context + gvk schema.GroupVersionKind + namespace string + name string + } + + type want struct { + resource *un.Unstructured + err error + } + + tests := map[string]struct { + reason string + setup func() (dynamic.Interface, TypeConverter) + args args + want want + }{ + "NamespacedResourceFound": { + reason: "Should return the resource when it exists in a namespace", + setup: func() (dynamic.Interface, TypeConverter) { + // Use the resource builder to create test objects + objects := []runtime.Object{ + tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "value"). + Build(), + } + + dynamicClient := fake.NewSimpleDynamicClient(scheme, objects...) + + // Create mock type converter using the builder + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "exampleresources", + }, nil + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ExampleResource", + }, + namespace: "test-namespace", + name: "test-resource", + }, + want: want{ + resource: tu.NewResource("example.org/v1", "ExampleResource", "test-resource"). + InNamespace("test-namespace"). + WithSpecField("property", "value"). + Build(), + }, + }, + "ClusterScopedResourceFound": { + reason: "Should return the resource when it exists at cluster scope", + setup: func() (dynamic.Interface, TypeConverter) { + objects := []runtime.Object{ + tu.NewResource("example.org/v1", "ClusterResource", "test-cluster-resource"). + WithSpecField("property", "value"). + Build(), + } + + dynamicClient := fake.NewSimpleDynamicClient(scheme, objects...) + + // Create mock type converter using the builder + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "clusterresources", + }, nil + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ClusterResource", + }, + namespace: "", // Cluster-scoped + name: "test-cluster-resource", + }, + want: want{ + resource: tu.NewResource("example.org/v1", "ClusterResource", "test-cluster-resource"). + WithSpecField("property", "value"). + Build(), + }, + }, + "ResourceNotFound": { + reason: "Should return an error when the resource doesn't exist", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClient(scheme) + dc.PrependReactor("get", "*", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("resource not found") + }) + + // Create mock type converter using the builder + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "exampleresources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ExampleResource", + }, + namespace: "test-namespace", + name: "nonexistent-resource", + }, + want: want{ + resource: nil, + err: errors.New("cannot get resource test-namespace/nonexistent-resource of kind ExampleResource"), + }, + }, + "ConverterError": { + reason: "Should return an error when GVK to GVR conversion fails", + setup: func() (dynamic.Interface, TypeConverter) { + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + // Create mock type converter that returns an error + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, errors.New("conversion error") + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ExampleResource", + }, + namespace: "test-namespace", + name: "test-resource", + }, + want: want{ + resource: nil, + err: errors.New("cannot get resource test-namespace/test-resource of kind ExampleResource"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dynamicClient, converter := tc.setup() + + c := &DefaultResourceClient{ + dynamicClient: dynamicClient, + converter: converter, + logger: tu.TestLogger(t, false), + } + + got, err := c.GetResource(tc.args.ctx, tc.args.gvk, tc.args.namespace, tc.args.name) + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nGetResource(...): expected error but got none", tc.reason) + return + } + + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nGetResource(...): expected error containing %q, got %q", + tc.reason, tc.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetResource(...): unexpected error: %v", tc.reason, err) + return + } + + // Remove resourceVersion from comparison since it's added by the fake client + gotCopy := got.DeepCopy() + if gotCopy != nil && gotCopy.Object != nil { + meta, found, _ := un.NestedMap(gotCopy.Object, "metadata") + if found && meta != nil { + delete(meta, "resourceVersion") + _ = un.SetNestedMap(gotCopy.Object, meta, "metadata") + } + } + + if diff := cmp.Diff(tc.want.resource, gotCopy); diff != "" { + t.Errorf("\n%s\nGetResource(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestResourceClient_GetResourcesByLabel(t *testing.T) { + scheme := runtime.NewScheme() + + tests := map[string]struct { + reason string + setup func() (dynamic.Interface, TypeConverter) + args struct { + ctx context.Context + namespace string + gvk schema.GroupVersionKind + selector metav1.LabelSelector + } + want struct { + resources []*un.Unstructured + err error + } + }{ + "NoMatchingResources": { + reason: "Should return empty list when no resources match selector", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, + map[schema.GroupVersionResource]string{ + {Group: "example.org", Version: "v1", Resource: "resources"}: "ResourceList", + }) + + // Create mock type converter using the builder + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "resources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: struct { + ctx context.Context + namespace string + gvk schema.GroupVersionKind + selector metav1.LabelSelector + }{ + ctx: t.Context(), + namespace: "test-namespace", + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + want: struct { + resources []*un.Unstructured + err error + }{ + resources: []*un.Unstructured{}, + }, + }, + "MatchingResources": { + reason: "Should return resources matching label selector", + setup: func() (dynamic.Interface, TypeConverter) { + // Use resource builders for cleaner test objects + objects := []runtime.Object{ + // Resource that matches our selector + tu.NewResource("example.org/v1", "Resource", "matched-resource-1"). + InNamespace("test-namespace"). + WithLabels(map[string]string{ + "app": "test", + "env": "dev", + }). + Build(), + + // Resource that matches our selector with different labels + tu.NewResource("example.org/v1", "Resource", "matched-resource-2"). + InNamespace("test-namespace"). + WithLabels(map[string]string{ + "app": "test", + "env": "prod", + }). + Build(), + + // Resource that doesn't match our selector + tu.NewResource("example.org/v1", "Resource", "unmatched-resource"). + InNamespace("test-namespace"). + WithLabels(map[string]string{ + "app": "other", + }). + Build(), + } + + dc := fake.NewSimpleDynamicClient(scheme, objects...) + + // Create mock type converter using the builder + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "resources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: struct { + ctx context.Context + namespace string + gvk schema.GroupVersionKind + selector metav1.LabelSelector + }{ + ctx: t.Context(), + namespace: "test-namespace", + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + want: struct { + resources []*un.Unstructured + err error + }{ + resources: []*un.Unstructured{ + // Expected matching resources using builders + tu.NewResource("example.org/v1", "Resource", "matched-resource-1"). + InNamespace("test-namespace"). + WithLabels(map[string]string{ + "app": "test", + "env": "dev", + }). + Build(), + tu.NewResource("example.org/v1", "Resource", "matched-resource-2"). + InNamespace("test-namespace"). + WithLabels(map[string]string{ + "app": "test", + "env": "prod", + }). + Build(), + }, + }, + }, + "ListError": { + reason: "Should propagate errors from the Kubernetes API", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, + map[schema.GroupVersionResource]string{ + {Group: "example.org", Version: "v1", Resource: "resources"}: "ResourceList", + }) + + dc.PrependReactor("list", "resources", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("list error") + }) + + // Create mock type converter using the builder + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "resources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: struct { + ctx context.Context + namespace string + gvk schema.GroupVersionKind + selector metav1.LabelSelector + }{ + ctx: t.Context(), + namespace: "test-namespace", + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + want: struct { + resources []*un.Unstructured + err error + }{ + err: errors.New("cannot list resources for 'test-namespace/example.org/v1, Kind=Resource' matching"), + }, + }, + "ConverterError": { + reason: "Should propagate errors from the type converter", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClient(scheme) + + // Create mock type converter that returns an error + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, errors.New("conversion error") + }).Build() + + return dc, mockConverter + }, + args: struct { + ctx context.Context + namespace string + gvk schema.GroupVersionKind + selector metav1.LabelSelector + }{ + ctx: t.Context(), + namespace: "test-namespace", + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + selector: metav1.LabelSelector{ + MatchLabels: map[string]string{"app": "test"}, + }, + }, + want: struct { + resources []*un.Unstructured + err error + }{ + err: errors.New("cannot list resources for 'example.org/v1, Kind=Resource' matching labels"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dynamicClient, converter := tc.setup() + + c := &DefaultResourceClient{ + dynamicClient: dynamicClient, + converter: converter, + logger: tu.TestLogger(t, false), + } + + got, err := c.GetResourcesByLabel(tc.args.ctx, tc.args.gvk, tc.args.namespace, tc.args.selector) + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nGetResourcesByLabel(...): expected error but got none", tc.reason) + return + } + + // Check that the error contains the expected message + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nGetResourcesByLabel(...): expected error containing %q, got: %v", + tc.reason, tc.want.err.Error(), err) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetResourcesByLabel(...): unexpected error: %v", tc.reason, err) + return + } + + if diff := cmp.Diff(len(tc.want.resources), len(got)); diff != "" { + t.Errorf("\n%s\nGetResourcesByLabel(...): -want resource count, +got resource count:\n%s", tc.reason, diff) + } + + // Compare resources by name to handle ordering differences + wantResources := make(map[string]bool) + for _, res := range tc.want.resources { + wantResources[res.GetName()] = true + } + + for _, gotRes := range got { + if !wantResources[gotRes.GetName()] { + t.Errorf("\n%s\nGetResourcesByLabel(...): unexpected resource: %s", tc.reason, gotRes.GetName()) + } + } + + // Also check if any expected resources are missing + gotResources := make(map[string]bool) + for _, res := range got { + gotResources[res.GetName()] = true + } + + for _, wantRes := range tc.want.resources { + if !gotResources[wantRes.GetName()] { + t.Errorf("\n%s\nGetResourcesByLabel(...): missing expected resource: %s", tc.reason, wantRes.GetName()) + } + } + }) + } +} + +func TestResourceClient_ListResources(t *testing.T) { + scheme := runtime.NewScheme() + + type args struct { + ctx context.Context + gvk schema.GroupVersionKind + namespace string + } + + type want struct { + resources []*un.Unstructured + err error + } + + tests := map[string]struct { + reason string + setup func() (dynamic.Interface, TypeConverter) + args args + want want + }{ + "NoResources": { + reason: "Should return empty list when no resources exist", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, + map[schema.GroupVersionResource]string{ + {Group: "example.org", Version: "v1", Resource: "resources"}: "ResourceList", + }) + + // Create mock type converter + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "resources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + namespace: "", + }, + want: want{ + resources: []*un.Unstructured{}, + }, + }, + "ResourcesExist": { + reason: "Should return all resources when they exist", + setup: func() (dynamic.Interface, TypeConverter) { + objects := []runtime.Object{ + tu.NewResource("example.org/v1", "Resource", "res1"). + InNamespace("test-namespace"). + WithSpecField("field1", "value1"). + Build(), + tu.NewResource("example.org/v1", "Resource", "res2"). + InNamespace("test-namespace"). + WithSpecField("field2", "value2"). + Build(), + } + + dc := fake.NewSimpleDynamicClient(scheme, objects...) + + // Create mock type converter + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "resources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + namespace: "test-namespace", + }, + want: want{ + resources: []*un.Unstructured{ + tu.NewResource("example.org/v1", "Resource", "res1"). + InNamespace("test-namespace"). + WithSpecField("field1", "value1"). + Build(), + tu.NewResource("example.org/v1", "Resource", "res2"). + InNamespace("test-namespace"). + WithSpecField("field2", "value2"). + Build(), + }, + }, + }, + "ListError": { + reason: "Should propagate errors from API server", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClientWithCustomListKinds(scheme, + map[schema.GroupVersionResource]string{ + {Group: "example.org", Version: "v1", Resource: "resources"}: "ResourceList", + }) + + dc.PrependReactor("list", "resources", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("list error") + }) + + // Create mock type converter + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(_ context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: "resources", + }, nil + }).Build() + + return dc, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + namespace: "test-namespace", + }, + want: want{ + err: errors.New("cannot list resources for 'example.org/v1, Kind=Resource'"), + }, + }, + "ConverterError": { + reason: "Should propagate errors from type converter", + setup: func() (dynamic.Interface, TypeConverter) { + dc := fake.NewSimpleDynamicClient(scheme) + + // Create mock type converter that returns an error + mockConverter := tu.NewMockTypeConverter(). + WithGVKToGVR(func(context.Context, schema.GroupVersionKind) (schema.GroupVersionResource, error) { + return schema.GroupVersionResource{}, errors.New("conversion error") + }).Build() + + return dc, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + namespace: "test-namespace", + }, + want: want{ + err: errors.New("cannot list resources for 'example.org/v1, Kind=Resource'"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dynamicClient, converter := tc.setup() + + c := &DefaultResourceClient{ + dynamicClient: dynamicClient, + converter: converter, + logger: tu.TestLogger(t, false), + } + + got, err := c.ListResources(tc.args.ctx, tc.args.gvk, tc.args.namespace) + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nListResources(...): expected error but got none", tc.reason) + return + } + + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nListResources(...): expected error containing %q, got %q", + tc.reason, tc.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nListResources(...): unexpected error: %v", tc.reason, err) + return + } + + if diff := cmp.Diff(len(tc.want.resources), len(got)); diff != "" { + t.Errorf("\n%s\nListResources(...): -want resource count, +got resource count:\n%s", tc.reason, diff) + } + + // Create maps of resource names for easier comparison + wantResources := make(map[string]bool) + gotResources := make(map[string]bool) + + for _, res := range tc.want.resources { + wantResources[res.GetName()] = true + } + + for _, res := range got { + gotResources[res.GetName()] = true + } + + // Check for missing resources + for name := range wantResources { + if !gotResources[name] { + t.Errorf("\n%s\nListResources(...): missing expected resource: %s", tc.reason, name) + } + } + + // Check for unexpected resources + for name := range gotResources { + if !wantResources[name] { + t.Errorf("\n%s\nListResources(...): unexpected resource: %s", tc.reason, name) + } + } + }) + } +} + +// createStaleDiscoveryClient creates a fake discovery client that simulates stale discovery +// for external.metrics.k8s.io but works correctly for Crossplane API groups. +// This reproduces the exact scenario from issue #153. +func createStaleDiscoveryClient() discovery.DiscoveryInterface { + // Create resources for Crossplane API groups + resources := map[string][]metav1.APIResource{ + "apiextensions.crossplane.io/v1": { + {Name: "compositeresourcedefinitions", Kind: "CompositeResourceDefinition"}, + {Name: "compositions", Kind: "Composition"}, + }, + // Include the stale group in the resources list so ServerGroups sees it, + // but we'll make ServerResourcesForGroupVersion fail for it + "external.metrics.k8s.io/v1beta1": { + {Name: "externalmetrics", Kind: "ExternalMetric"}, + }, + } + + // Use the standard CreateFakeDiscoveryClient which sets up groups from resources + fakeDiscovery := tu.CreateFakeDiscoveryClient(resources) + + return fakeDiscovery +} + +// TestResourceClient_GetGVKsForGroupKind_StaleDiscoveryResilience validates that the fix +// for issue #153 works correctly - we can query Crossplane API groups even when unrelated +// API groups (like external.metrics.k8s.io) exist in the cluster. +// +// The key behavior validated here: +// - The new implementation queries only the specific API group needed (apiextensions.crossplane.io) +// - It does NOT query unrelated groups (external.metrics.k8s.io) which could have stale discovery +// - This is achieved by using ServerGroups() + ServerResourcesForGroupVersion() instead of ServerPreferredResources(). +func TestResourceClient_GetGVKsForGroupKind_StaleDiscoveryResilience(t *testing.T) { + // Create a fake discovery client with multiple API groups including external.metrics.k8s.io + // In a real cluster, if external.metrics.k8s.io had stale discovery, ServerPreferredResources() + // would fail. Our fix avoids calling ServerPreferredResources() entirely. + discoveryClient := createStaleDiscoveryClient() + + c := &DefaultResourceClient{ + discoveryClient: discoveryClient, + logger: tu.TestLogger(t, false), + } + + // Test that we can query Crossplane API groups successfully + // The old implementation would call ServerPreferredResources() which queries ALL groups + // The new implementation only queries apiextensions.crossplane.io + got, err := c.GetGVKsForGroupKind(t.Context(), "apiextensions.crossplane.io", "CompositeResourceDefinition") + if err != nil { + t.Errorf("GetGVKsForGroupKind() failed unexpectedly: %v\n"+ + "This error indicates the fix for issue #153 may not be working correctly.", err) + + return + } + + // Verify we got the expected GVK + if len(got) != 1 { + t.Errorf("GetGVKsForGroupKind() returned %d GVKs, want 1", len(got)) + return + } + + expectedGVK := schema.GroupVersionKind{ + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositeResourceDefinition", + } + if got[0] != expectedGVK { + t.Errorf("GetGVKsForGroupKind() = %v, want %v", got[0], expectedGVK) + } + + // Also verify we can query Composition kind in the same group + got2, err := c.GetGVKsForGroupKind(t.Context(), "apiextensions.crossplane.io", "Composition") + if err != nil { + t.Errorf("GetGVKsForGroupKind(Composition) failed unexpectedly: %v", err) + return + } + + if len(got2) != 1 || got2[0].Kind != "Composition" { + t.Errorf("GetGVKsForGroupKind(Composition) = %v, want single Composition GVK", got2) + } +} + +func TestResourceClient_GetGVKsForGroupKind(t *testing.T) { + tests := map[string]struct { + reason string + resources map[string][]metav1.APIResource + group string + kind string + want []schema.GroupVersionKind + wantErr bool + errMsg string + }{ + "SingleVersion": { + reason: "Should return single GVK when kind exists in one version", + resources: map[string][]metav1.APIResource{ + "apiextensions.crossplane.io/v1": { + { + Name: "compositeresourcedefinitions", + Kind: "CompositeResourceDefinition", + }, + }, + }, + group: "apiextensions.crossplane.io", + kind: "CompositeResourceDefinition", + want: []schema.GroupVersionKind{ + { + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositeResourceDefinition", + }, + }, + }, + "MultipleVersions": { + reason: "Should return GVKs for all versions when kind exists in multiple versions", + resources: map[string][]metav1.APIResource{ + "apiextensions.crossplane.io/v1": { + { + Name: "compositeresourcedefinitions", + Kind: "CompositeResourceDefinition", + }, + }, + "apiextensions.crossplane.io/v1beta1": { + { + Name: "compositeresourcedefinitions", + Kind: "CompositeResourceDefinition", + }, + }, + }, + group: "apiextensions.crossplane.io", + kind: "CompositeResourceDefinition", + want: []schema.GroupVersionKind{ + { + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositeResourceDefinition", + }, + { + Group: "apiextensions.crossplane.io", + Version: "v1beta1", + Kind: "CompositeResourceDefinition", + }, + }, + }, + "KindNotFound": { + reason: "Should return empty list when kind doesn't exist in group", + resources: map[string][]metav1.APIResource{ + "apiextensions.crossplane.io/v1": { + { + Name: "compositeresourcedefinitions", + Kind: "CompositeResourceDefinition", + }, + }, + }, + group: "apiextensions.crossplane.io", + kind: "NonExistentKind", + want: nil, + }, + "GroupNotFound": { + reason: "Should return error when group doesn't exist", + resources: map[string][]metav1.APIResource{ + "other.io/v1": { + { + Name: "resources", + Kind: "Resource", + }, + }, + }, + group: "nonexistent.io", + kind: "SomeKind", + wantErr: true, + errMsg: "API group \"nonexistent.io\" not found on server", + }, + "IgnoresOtherGroups": { + reason: "Should only return GVKs from the specified group, ignoring others", + resources: map[string][]metav1.APIResource{ + "apiextensions.crossplane.io/v1": { + { + Name: "compositeresourcedefinitions", + Kind: "CompositeResourceDefinition", + }, + }, + "external.metrics.k8s.io/v1beta1": { + { + Name: "externalmetrics", + Kind: "ExternalMetric", + }, + }, + }, + group: "apiextensions.crossplane.io", + kind: "CompositeResourceDefinition", + want: []schema.GroupVersionKind{ + { + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositeResourceDefinition", + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + discoveryClient := tu.CreateFakeDiscoveryClient(tc.resources) + + c := &DefaultResourceClient{ + discoveryClient: discoveryClient, + logger: tu.TestLogger(t, false), + } + + got, err := c.GetGVKsForGroupKind(t.Context(), tc.group, tc.kind) + + if tc.wantErr { + if err == nil { + t.Errorf("\n%s\nGetGVKsForGroupKind(...): expected error but got none", tc.reason) + return + } + + if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("\n%s\nGetGVKsForGroupKind(...): expected error containing %q, got %q", + tc.reason, tc.errMsg, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetGVKsForGroupKind(...): unexpected error: %v", tc.reason, err) + return + } + + // Compare by converting both to maps for order-independent comparison + wantMap := make(map[string]bool) + for _, gvk := range tc.want { + wantMap[gvk.String()] = true + } + + gotMap := make(map[string]bool) + for _, gvk := range got { + gotMap[gvk.String()] = true + } + + if len(wantMap) != len(gotMap) { + t.Errorf("\n%s\nGetGVKsForGroupKind(...): got %d GVKs, want %d GVKs\ngot: %v\nwant: %v", + tc.reason, len(got), len(tc.want), got, tc.want) + + return + } + + for gvkStr := range wantMap { + if !gotMap[gvkStr] { + t.Errorf("\n%s\nGetGVKsForGroupKind(...): missing expected GVK: %s", tc.reason, gvkStr) + } + } + }) + } +} + +func TestResourceClient_IsNamespacedResource(t *testing.T) { + tests := map[string]struct { + reason string + resources map[string][]metav1.APIResource + gvk schema.GroupVersionKind + want bool + wantErr bool + errMsg string + }{ + "NamespacedResource": { + reason: "Should return true for namespaced resources", + resources: map[string][]metav1.APIResource{ + "example.org/v1": { + { + Name: "testresources", + Kind: "TestResource", + Namespaced: true, + }, + }, + }, + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "TestResource", + }, + want: true, + wantErr: false, + }, + "ClusterScopedResource": { + reason: "Should return false for cluster-scoped resources", + resources: map[string][]metav1.APIResource{ + "example.org/v1": { + { + Name: "clusterresources", + Kind: "ClusterResource", + Namespaced: false, + }, + }, + }, + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ClusterResource", + }, + want: false, + wantErr: false, + }, + "BuiltInNamespacedResource": { + reason: "Should return true for built-in namespaced resources like ConfigMap", + resources: map[string][]metav1.APIResource{ + "v1": { + { + Name: "configmaps", + Kind: "ConfigMap", + Namespaced: true, + }, + }, + }, + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "ConfigMap", + }, + want: true, + wantErr: false, + }, + "BuiltInClusterScopedResource": { + reason: "Should return false for built-in cluster-scoped resources like Node", + resources: map[string][]metav1.APIResource{ + "v1": { + { + Name: "nodes", + Kind: "Node", + Namespaced: false, + }, + }, + }, + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Node", + }, + want: false, + wantErr: false, + }, + "ResourceNotFound": { + reason: "Should return error when resource kind is not found in API resources", + resources: map[string][]metav1.APIResource{ + "example.org/v1": { + { + Name: "otherresources", + Kind: "OtherResource", + Namespaced: true, + }, + }, + }, + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "NonExistentResource", + }, + want: false, + wantErr: true, + errMsg: "resource kind NonExistentResource not found in discovery API for group version example.org/v1", + }, + "MultipleResourcesInGroup": { + reason: "Should find the correct resource when multiple resources exist in same group", + resources: map[string][]metav1.APIResource{ + "example.org/v1": { + { + Name: "clusterresources", + Kind: "ClusterResource", + Namespaced: false, + }, + { + Name: "namespacedresources", + Kind: "NamespacedResource", + Namespaced: true, + }, + { + Name: "otherresources", + Kind: "OtherResource", + Namespaced: true, + }, + }, + }, + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "NamespacedResource", + }, + want: true, + wantErr: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + discoveryClient := tu.CreateFakeDiscoveryClient(tc.resources) + converter := tu.NewMockTypeConverter().Build() + + c := &DefaultResourceClient{ + dynamicClient: nil, // Not needed for this test + converter: converter, + discoveryClient: discoveryClient, + logger: tu.TestLogger(t, false), + } + + got, err := c.IsNamespacedResource(t.Context(), tc.gvk) + + if tc.wantErr { + if err == nil { + t.Errorf("\n%s\nIsNamespacedResource(...): expected error but got none", tc.reason) + return + } + + if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("\n%s\nIsNamespacedResource(...): expected error containing %q, got %q", + tc.reason, tc.errMsg, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nIsNamespacedResource(...): unexpected error: %v", tc.reason, err) + return + } + + if got != tc.want { + t.Errorf("\n%s\nIsNamespacedResource(...): got %v, want %v", tc.reason, got, tc.want) + } + }) + } +} diff --git a/internal/diff/client/kubernetes/schema_client.go b/internal/diff/client/kubernetes/schema_client.go new file mode 100644 index 00000000..791f6218 --- /dev/null +++ b/internal/diff/client/kubernetes/schema_client.go @@ -0,0 +1,405 @@ +package kubernetes + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + "sync" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + xpextv2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" + + "github.com/crossplane/cli/v2/internal/diff/client/core" +) + +// XRD apiVersion strings recognized by the schema client. These mirror the +// runtime SchemeGroupVersion values from the imported xpextv1/xpextv2 packages, +// but as constants they can be used in switch cases. +const ( + xrdAPIVersionV1 = "apiextensions.crossplane.io/v1" + xrdAPIVersionV2 = "apiextensions.crossplane.io/v2" +) + +// SchemaClient handles operations related to Kubernetes schemas and CRDs. +type SchemaClient interface { + // GetCRD gets the CustomResourceDefinition for a given GVK + GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) + + // GetCRDByName gets the CustomResourceDefinition by its name + GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) + + // IsCRDRequired checks if a GVK requires a CRD + IsCRDRequired(ctx context.Context, gvk schema.GroupVersionKind) bool + + // LoadCRDsFromXRDs converts XRDs to CRDs and caches them + LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error + + // GetAllCRDs returns all cached CRDs (needed for external validation library) + GetAllCRDs() []*extv1.CustomResourceDefinition +} + +// DefaultSchemaClient implements SchemaClient. +type DefaultSchemaClient struct { + dynamicClient dynamic.Interface + typeConverter TypeConverter + logger logging.Logger + + // Resource type caching + resourceTypeMap map[schema.GroupVersionKind]bool + resourceMapMu sync.RWMutex + + // CRD caching - consolidated from SchemaValidator + crds []*extv1.CustomResourceDefinition + crdsMu sync.RWMutex + crdByName map[string]*extv1.CustomResourceDefinition // for fast lookup by name + xrdToCRDName map[string]string // maps XRD name to CRD name +} + +// NewSchemaClient creates a new DefaultSchemaClient. +func NewSchemaClient(clients *core.Clients, typeConverter TypeConverter, logger logging.Logger) SchemaClient { + return &DefaultSchemaClient{ + dynamicClient: clients.Dynamic, + typeConverter: typeConverter, + logger: logger, + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } +} + +// GetCRD gets the CustomResourceDefinition for a given GVK. +func (c *DefaultSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + // Get the pluralized resource name to construct CRD name + resourceName, err := c.typeConverter.GetResourceNameForGVK(ctx, gvk) + if err != nil { + return nil, errors.Wrapf(err, "cannot determine CRD name for %s", gvk.String()) + } + + // Construct the full CRD name + crdName := fmt.Sprintf("%s.%s", resourceName, gvk.Group) + + // Check cache first + c.crdsMu.RLock() + + if cached, ok := c.crdByName[crdName]; ok { + c.crdsMu.RUnlock() + c.logger.Debug("Using cached CRD", "gvk", gvk.String(), "crdName", crdName) + + return cached, nil + } + + c.crdsMu.RUnlock() + + c.logger.Debug("Looking up CRD", "gvk", gvk.String(), "crdName", resourceName) + + // Define the CRD GVR directly to avoid recursion + crdGVR := schema.GroupVersionResource{ + Group: "apiextensions.k8s.io", + Version: "v1", + Resource: "customresourcedefinitions", + } + + // Fetch the CRD from cluster + crdObj, err := c.dynamicClient.Resource(crdGVR).Get(ctx, crdName, metav1.GetOptions{}) + if err != nil { + c.logger.Debug("Failed to get CRD", "gvk", gvk.String(), "crdName", crdName, "error", err) + return nil, errors.Wrapf(err, "cannot get CRD %s for %s", crdName, gvk.String()) + } + + c.logger.Debug("Successfully retrieved CRD", "gvk", gvk.String(), "crdName", resourceName) + + // Convert to typed CRD + crdTyped := &extv1.CustomResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crdObj.Object, crdTyped); err != nil { + c.logger.Debug("Error converting CRD", "gvk", gvk.String(), "crdName", crdName, "error", err) + return nil, errors.Wrapf(err, "cannot convert CRD %s to typed", crdName) + } + + // Add to cache + c.addCRD(crdTyped) + + return crdTyped, nil +} + +// IsCRDRequired checks if a GVK requires a CRD. +func (c *DefaultSchemaClient) IsCRDRequired(ctx context.Context, gvk schema.GroupVersionKind) bool { + // Check cache first + c.resourceMapMu.RLock() + + if val, ok := c.resourceTypeMap[gvk]; ok { + c.resourceMapMu.RUnlock() + return val + } + + c.resourceMapMu.RUnlock() + + // Core API resources never need CRDs + if gvk.Group == "" { + c.cacheResourceType(gvk, false) + return false + } + + // Standard Kubernetes API groups + builtInGroups := []string{ + "apps", "batch", "extensions", "policy", "autoscaling", + } + if slices.Contains(builtInGroups, gvk.Group) { + c.cacheResourceType(gvk, false) + return false + } + + // k8s.io domain suffix groups are typically built-in + // (except apiextensions.k8s.io which defines CRDs themselves) + if strings.HasSuffix(gvk.Group, ".k8s.io") && gvk.Group != "apiextensions.k8s.io" { + c.cacheResourceType(gvk, false) + return false + } + + // Try to query the discovery API to see if this resource exists + _, err := c.typeConverter.GetResourceNameForGVK(ctx, gvk) + if err != nil { + // If we can't find it through discovery, assume it requires a CRD + c.logger.Debug("Resource not found in discovery, assuming CRD is required", + "gvk", gvk.String(), + "error", err) + c.cacheResourceType(gvk, true) + + return true + } + + // Default to requiring a CRD + c.cacheResourceType(gvk, true) + + return true +} + +// Helper to cache resource type requirements. +func (c *DefaultSchemaClient) cacheResourceType(gvk schema.GroupVersionKind, requiresCRD bool) { + c.resourceMapMu.Lock() + defer c.resourceMapMu.Unlock() + + c.resourceTypeMap[gvk] = requiresCRD +} + +// extractGVKsFromXRDs extracts GVKs from multiple XRDs. This is a pure function with no side effects. +func extractGVKsFromXRDs(xrds []*un.Unstructured) ([]schema.GroupVersionKind, error) { + var allGVKs []schema.GroupVersionKind + + for _, xrd := range xrds { + gvks, err := extractGVKsFromXRD(xrd) + if err != nil { + return nil, errors.Wrapf(err, "failed to extract GVKs from XRD %s", xrd.GetName()) + } + + allGVKs = append(allGVKs, gvks...) + } + + return allGVKs, nil +} + +// extractGVKsFromXRD extracts all GroupVersionKinds from an XRD using strongly-typed conversion. +// This method handles both v1 and v2 XRDs and leverages Kubernetes runtime conversion. +func extractGVKsFromXRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { + apiVersion := xrd.GetAPIVersion() + + switch apiVersion { + case xrdAPIVersionV1: + return extractGVKsFromV1XRD(xrd) + case xrdAPIVersionV2: + return extractGVKsFromV2XRD(xrd) + default: + return nil, errors.Errorf("unsupported XRD apiVersion %s in XRD %s", apiVersion, xrd.GetName()) + } +} + +// extractGVKsFromV1XRD extracts GVKs from a v1 XRD using strongly-typed conversion. +func extractGVKsFromV1XRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { + typedXRD := &xpextv1.CompositeResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrd.Object, typedXRD); err != nil { + return nil, errors.Wrapf(err, "cannot convert XRD %s to v1 typed object", xrd.GetName()) + } + + // Extract GVKs for each version - no validation needed since XRDs from server are guaranteed valid + gvks := make([]schema.GroupVersionKind, 0, len(typedXRD.Spec.Versions)) + for _, version := range typedXRD.Spec.Versions { + gvks = append(gvks, schema.GroupVersionKind{ + Group: typedXRD.Spec.Group, + Version: version.Name, + Kind: typedXRD.Spec.Names.Kind, + }) + } + + return gvks, nil +} + +// extractGVKsFromV2XRD extracts GVKs from a v2 XRD using strongly-typed conversion. +func extractGVKsFromV2XRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { + typedXRD := &xpextv2.CompositeResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrd.Object, typedXRD); err != nil { + return nil, errors.Wrapf(err, "cannot convert XRD %s to v2 typed object", xrd.GetName()) + } + + // Extract GVKs for each version - no validation needed since XRDs from server are guaranteed valid + gvks := make([]schema.GroupVersionKind, 0, len(typedXRD.Spec.Versions)) + for _, version := range typedXRD.Spec.Versions { + gvks = append(gvks, schema.GroupVersionKind{ + Group: typedXRD.Spec.Group, + Version: version.Name, + Kind: typedXRD.Spec.Names.Kind, + }) + } + + return gvks, nil +} + +// LoadCRDsFromXRDs fetches corresponding CRDs from the cluster for the given XRDs and caches them. +// Instead of converting XRDs to CRDs, this method fetches the actual CRDs that should already +// exist in the cluster since the Crossplane control plane manages both XRDs and their corresponding CRDs. +func (c *DefaultSchemaClient) LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error { + c.logger.Debug("Loading CRDs from cluster for XRDs", "xrdCount", len(xrds)) + + if len(xrds) == 0 { + c.logger.Debug("No XRDs provided, nothing to load") + return nil + } + + // Extract GVKs from XRDs using the pure function + gvks, err := extractGVKsFromXRDs(xrds) + if err != nil { + return err // Error already wrapped with context from ExtractGVKsFromXRDs + } + + // Build XRD-to-CRD name mappings for later use + xrdToCRDMappings := make(map[string]string) // XRD name -> CRD name + + for _, xrd := range xrds { + // Extract the CRD name from XRD spec (format: {plural}.{group}) + group, _, _ := un.NestedString(xrd.Object, "spec", "group") + + plural, _, _ := un.NestedString(xrd.Object, "spec", "names", "plural") + if group != "" && plural != "" { + crdName := plural + "." + group + xrdName := xrd.GetName() + xrdToCRDMappings[xrdName] = crdName + c.logger.Debug("Mapped XRD to CRD", "xrdName", xrdName, "crdName", crdName) + } + } + + // Load CRDs from the extracted GVKs + err = c.loadCRDsFromGVKs(ctx, gvks) + if err != nil { + return err // Error already wrapped with context from LoadCRDsFromGVKs + } + + // Store XRD-to-CRD name mappings + c.crdsMu.Lock() + + maps.Copy(c.xrdToCRDName, xrdToCRDMappings) + + c.crdsMu.Unlock() + + c.logger.Debug("Successfully stored XRD-to-CRD mappings", "count", len(xrdToCRDMappings)) + + return nil +} + +// loadCRDsFromGVKs fetches CRDs from the cluster for the given GVKs and caches them. +// This method fetches the actual CRDs from the cluster for each provided GVK. +func (c *DefaultSchemaClient) loadCRDsFromGVKs(ctx context.Context, gvks []schema.GroupVersionKind) error { + c.logger.Debug("Loading CRDs from cluster for GVKs", "gvkCount", len(gvks)) + + if len(gvks) == 0 { + c.logger.Debug("No GVKs provided, nothing to load") + return nil + } + + // TODO: Consider parallel fetching of CRDs to improve performance for large numbers of GVKs. + // This could significantly speed up initialization when dealing with many XRDs. + // For now, we fetch sequentially to keep the implementation simple. + + // Fetch CRDs from cluster for each GVK - fail fast if any CRD is missing + // Per repository guidelines: never continue in a degraded state + fetchedCRDs := make([]*extv1.CustomResourceDefinition, 0, len(gvks)) + + for _, gvk := range gvks { + crd, err := c.GetCRD(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to fetch required CRD for GVK", "gvk", gvk.String(), "error", err) + return errors.Wrapf(err, "cannot fetch required CRD for %s", gvk.String()) + } + + fetchedCRDs = append(fetchedCRDs, crd) + } + + c.logger.Debug("Successfully fetched all required CRDs from cluster", "count", len(fetchedCRDs)) + + return nil +} + +// GetCRDByName gets a CRD by its name from the cache. +// If the name is not found directly, it will also check if it's an XRD name +// that maps to a different CRD name (e.g., claim XRDs). +func (c *DefaultSchemaClient) GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) { + c.crdsMu.RLock() + defer c.crdsMu.RUnlock() + + // First, try direct lookup by CRD name + if crd, exists := c.crdByName[name]; exists { + return crd, nil + } + + // If not found, check if this is an XRD name that maps to a different CRD name + if crdName, exists := c.xrdToCRDName[name]; exists { + if crd, exists := c.crdByName[crdName]; exists { + c.logger.Debug("Found CRD for XRD via name mapping", "xrdName", name, "crdName", crdName) + return crd, nil + } + } + + return nil, errors.Errorf("CRD with name %s not found in cache", name) +} + +// GetAllCRDs returns all cached CRDs. +func (c *DefaultSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { + c.crdsMu.RLock() + defer c.crdsMu.RUnlock() + + // Return a copy to prevent external modification + result := make([]*extv1.CustomResourceDefinition, len(c.crds)) + copy(result, c.crds) + + return result +} + +// addCRD adds a CRD to the cache. +func (c *DefaultSchemaClient) addCRD(crd *extv1.CustomResourceDefinition) { + c.crdsMu.Lock() + defer c.crdsMu.Unlock() + + // Check if already cached to avoid duplicates + if _, exists := c.crdByName[crd.Name]; exists { + c.logger.Debug("CRD already in cache, skipping", "crdName", crd.Name) + return + } + + // Add to slice + c.crds = append(c.crds, crd) + + // Add to name lookup map + c.crdByName[crd.Name] = crd + + c.logger.Debug("Added CRD to cache", "crdName", crd.Name) +} diff --git a/internal/diff/client/kubernetes/schema_client_test.go b/internal/diff/client/kubernetes/schema_client_test.go new file mode 100644 index 00000000..3d8bdc27 --- /dev/null +++ b/internal/diff/client/kubernetes/schema_client_test.go @@ -0,0 +1,959 @@ +package kubernetes + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/dynamic/fake" + kt "k8s.io/client-go/testing" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +const ( + testXResourceKind = "XResource" + testXResourcePlural = "xresources" + testExampleOrgGroup = "example.org" +) + +var _ SchemaClient = (*tu.MockSchemaClient)(nil) + +func TestSchemaClient_IsCRDRequired(t *testing.T) { + // Set up context for tests + ctx := t.Context() + + tests := map[string]struct { + reason string + setupConverter func() TypeConverter + gvk schema.GroupVersionKind + want bool + }{ + "CoreResource": { + reason: "Core API resources (group='') should not require a CRD", + setupConverter: func() TypeConverter { + // Just need a mock converter as it shouldn't be called for core resources + return tu.NewMockTypeConverter().Build() + }, + gvk: schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Pod", + }, + want: false, // Core API resource should not require a CRD + }, + "KubernetesExtensionResource": { + reason: "Kubernetes extension resources (like apps/v1) should not require a CRD", + setupConverter: func() TypeConverter { + // Just need a mock converter as it shouldn't be called for k8s resources + return tu.NewMockTypeConverter().Build() + }, + gvk: schema.GroupVersionKind{ + Group: "apps", + Version: "v1", + Kind: "Deployment", + }, + want: false, // Kubernetes extension should not require a CRD + }, + "CustomResource": { + reason: "Custom resources (non-standard domain) should require a CRD", + setupConverter: func() TypeConverter { + // For custom resources, our converter should return a successful resource name + return tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + }, + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + want: true, // Custom resource should require a CRD + }, + "APIExtensionResource": { + reason: "API Extensions resources like CRDs themselves should require special handling", + setupConverter: func() TypeConverter { + // For apiextensions resources, our converter should return a successful resource name + return tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }, "customresourcedefinitions").Build() + }, + gvk: schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }, + want: true, // APIExtensions resources are handled specially and require CRDs + }, + "OtherK8sIOButNotAPIExtensions": { + reason: "Other k8s.io resources that are not from apiextensions should not require a CRD", + setupConverter: func() TypeConverter { + // For networking.k8s.io resources, our converter should return a successful resource name + return tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: "networking.k8s.io", + Version: "v1", + Kind: "NetworkPolicy", + }, "networkpolicies").Build() + }, + gvk: schema.GroupVersionKind{ + Group: "networking.k8s.io", + Version: "v1", + Kind: "NetworkPolicy", + }, + want: false, // Other k8s.io resources should not require a CRD + }, + "ConverterError": { + reason: "If type conversion fails, should default to requiring a CRD", + setupConverter: func() TypeConverter { + // Create mock type converter that returns an error + return tu.NewMockTypeConverter(). + WithGetResourceNameForGVK(func(context.Context, schema.GroupVersionKind) (string, error) { + return "", errors.New("conversion error") + }).Build() + }, + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + want: true, // Default to requiring CRD on conversion failure + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // Create a schema client with the test converter + c := &DefaultSchemaClient{ + typeConverter: tc.setupConverter(), + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + xrdToCRDName: make(map[string]string), + } + + // Call the method under test + got := c.IsCRDRequired(ctx, tc.gvk) + + // Verify result + if got != tc.want { + t.Errorf("\n%s\nIsCRDRequired() = %v, want %v", tc.reason, got, tc.want) + } + }) + } +} + +func TestSchemaClient_GetCRD(t *testing.T) { + scheme := runtime.NewScheme() + + type args struct { + ctx context.Context + gvk schema.GroupVersionKind + } + + type want struct { + crd *extv1.CustomResourceDefinition + err error + } + + // Create a test CRD as typed object + testCRD := tu.NewCRD(testXResourcePlural+"."+testExampleOrgGroup, testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + Build() + + // Create the same CRD as unstructured for the mock dynamic client using CRD builder + testCRDUnstructuredObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( + tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + WithNamespaceScope(). + Build()) + testCRDUnstructured := &un.Unstructured{Object: testCRDUnstructuredObj} + + tests := map[string]struct { + reason string + setup func() (dynamic.Interface, TypeConverter) + args args + want want + }{ + "SuccessfulCRDRetrieval": { + reason: "Should retrieve CRD when it exists", + setup: func() (dynamic.Interface, TypeConverter) { + // Set up the dynamic client to return our test CRD + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, testCRDUnstructured, nil + } + + return false, nil, nil + }) + + // Create mock type converter that returns "xresources" for the given GVK + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + }, + want: want{ + crd: testCRD, + err: nil, + }, + }, + "CRDNotFound": { + reason: "Should return error when CRD doesn't exist", + setup: func() (dynamic.Interface, TypeConverter) { + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("CRD not found") + }) + + // Create mock type converter that returns "nonexistentresources" + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: "NonexistentResource", + }, "nonexistentresources").Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: "NonexistentResource", + }, + }, + want: want{ + crd: nil, + err: errors.New("cannot get CRD"), + }, + }, + "TypeConverterError": { + reason: "Should return error when type conversion fails", + setup: func() (dynamic.Interface, TypeConverter) { + dynamicClient := fake.NewSimpleDynamicClient(scheme) + + // Create mock type converter that returns an error + mockConverter := tu.NewMockTypeConverter(). + WithGetResourceNameForGVK(func(context.Context, schema.GroupVersionKind) (string, error) { + return "", errors.New("conversion error") + }).Build() + + return dynamicClient, mockConverter + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + }, + want: want{ + crd: nil, + err: errors.New("cannot determine CRD name for"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + dynamicClient, converter := tc.setup() + + c := &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: converter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + crd, err := c.GetCRD(tc.args.ctx, tc.args.gvk) + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nGetCRD(...): expected error but got none", tc.reason) + return + } + + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nGetCRD(...): expected error containing %q, got %q", + tc.reason, tc.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCRD(...): unexpected error: %v", tc.reason, err) + return + } + + if diff := cmp.Diff(tc.want.crd, crd); diff != "" { + t.Errorf("\n%s\nGetCRD(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestSchemaClient_LoadCRDsFromXRDs(t *testing.T) { + ctx := t.Context() + scheme := runtime.NewScheme() + + // Create sample XRD with proper schema using builder + xrd := tu.NewXRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + WithDefaultVersion(). + WithRawSchema([]byte(`{ + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "field": { + "type": "string" + } + } + }, + "status": { + "type": "object" + } + } + }`)). + BuildAsUnstructured() + + // Create corresponding CRD that should exist in cluster using CRD builder + correspondingCRDObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( + tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + WithDefaultVersion(). + Build()) + correspondingCRD := &un.Unstructured{Object: correspondingCRDObj} + + tests := map[string]struct { + reason string + setupClient func() *DefaultSchemaClient + xrds []*un.Unstructured + expectErr bool + errMsg string + expectedCRDs int + }{ + "SuccessfulFetchFromCluster": { + reason: "Should successfully fetch CRDs from cluster for given XRDs", + setupClient: func() *DefaultSchemaClient { + // Setup dynamic client to return the corresponding CRD + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, correspondingCRD, nil + } + + return false, nil, nil + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + return &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: []*un.Unstructured{xrd}, + expectErr: false, + expectedCRDs: 1, + }, + "EmptyXRDs": { + reason: "Should handle empty XRD list gracefully", + setupClient: func() *DefaultSchemaClient { + return &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: []*un.Unstructured{}, + expectErr: false, + expectedCRDs: 0, + }, + "NilXRDs": { + reason: "Should handle nil XRD list gracefully", + setupClient: func() *DefaultSchemaClient { + return &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: nil, + expectErr: false, + expectedCRDs: 0, + }, + "CRDNotFoundInCluster": { + reason: "Should fail fast when required CRD is not found in cluster", + setupClient: func() *DefaultSchemaClient { + // Setup dynamic client to return error for CRD requests + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(kt.Action) (bool, runtime.Object, error) { + return true, nil, errors.New("CRD not found") + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + return &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + }, + xrds: []*un.Unstructured{xrd}, + expectErr: true, + errMsg: "cannot fetch required CRD", + expectedCRDs: 0, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + client := tc.setupClient() + + err := client.LoadCRDsFromXRDs(ctx, tc.xrds) + + if tc.expectErr { + if err == nil { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected error but got none", tc.reason) + return + } + + if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected error containing %q, got %q", + tc.reason, tc.errMsg, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): unexpected error: %v", tc.reason, err) + return + } + + // Verify expected number of CRDs were cached + crds := client.GetAllCRDs() + if len(crds) != tc.expectedCRDs { + t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected %d CRDs to be cached, got %d", + tc.reason, tc.expectedCRDs, len(crds)) + } + }) + } +} + +func TestSchemaClient_GetCRDByName(t *testing.T) { + // Create test CRDs + testCRDName := testXResourcePlural + "." + testExampleOrgGroup + testCRD := tu.NewCRD(testCRDName, testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + WithDefaultVersion(). + Build() + + tests := map[string]struct { + reason string + setupCRDs []*extv1.CustomResourceDefinition + searchName string + expectError bool + expectedCRD *extv1.CustomResourceDefinition + }{ + "CRDFound": { + reason: "Should return CRD when it exists in cache", + setupCRDs: []*extv1.CustomResourceDefinition{testCRD}, + searchName: testCRDName, + expectError: false, + expectedCRD: testCRD, + }, + "CRDNotFound": { + reason: "Should return error when CRD doesn't exist in cache", + setupCRDs: []*extv1.CustomResourceDefinition{}, + searchName: testCRDName, + expectError: true, + expectedCRD: nil, + }, + "DifferentCRDInCache": { + reason: "Should return error when searching for CRD that doesn't exist", + setupCRDs: []*extv1.CustomResourceDefinition{ + tu.NewCRD("other."+testExampleOrgGroup, testExampleOrgGroup, "OtherKind").WithDefaultVersion().Build(), + }, + searchName: testCRDName, + expectError: true, + expectedCRD: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // Create schema client with test CRDs + client := &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + crds: make([]*extv1.CustomResourceDefinition, 0), + crdByName: make(map[string]*extv1.CustomResourceDefinition), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + xrdToCRDName: make(map[string]string), + } + + // Pre-populate cache with test CRDs + for _, crd := range tc.setupCRDs { + client.addCRD(crd) + } + + // Call GetCRDByName + crd, err := client.GetCRDByName(tc.searchName) + + // Check error expectations + if tc.expectError { + if err == nil { + t.Errorf("\n%s\nGetCRDByName(): expected error but got none", tc.reason) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCRDByName(): unexpected error: %v", tc.reason, err) + return + } + + // Verify returned CRD + if crd == nil { + t.Errorf("\n%s\nGetCRDByName(): expected CRD but got nil", tc.reason) + return + } + + if crd.Name != tc.expectedCRD.Name { + t.Errorf("\n%s\nGetCRDByName(): expected CRD name %s, got %s", + tc.reason, tc.expectedCRD.Name, crd.Name) + } + + if crd.Spec.Group != tc.expectedCRD.Spec.Group { + t.Errorf("\n%s\nGetCRDByName(): expected CRD group %s, got %s", + tc.reason, tc.expectedCRD.Spec.Group, crd.Spec.Group) + } + }) + } +} + +func TestSchemaClient_GetAllCRDs(t *testing.T) { + // Create test CRDs + crd1 := tu.NewCRD("crd1."+testExampleOrgGroup, testExampleOrgGroup, "TestKind1").WithDefaultVersion().Build() + crd2 := tu.NewCRD("crd2."+testExampleOrgGroup, testExampleOrgGroup, "TestKind2").WithDefaultVersion().Build() + + tests := map[string]struct { + reason string + setupCRDs []*extv1.CustomResourceDefinition + expected int + }{ + "NoCRDs": { + reason: "Should return empty slice when no CRDs are cached", + setupCRDs: []*extv1.CustomResourceDefinition{}, + expected: 0, + }, + "MultipleCRDs": { + reason: "Should return all cached CRDs", + setupCRDs: []*extv1.CustomResourceDefinition{crd1, crd2}, + expected: 2, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultSchemaClient{ + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: tc.setupCRDs, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + // Add CRDs to name lookup map + for _, crd := range tc.setupCRDs { + c.crdByName[crd.Name] = crd + } + + crds := c.GetAllCRDs() + + if len(crds) != tc.expected { + t.Errorf("\n%s\nGetAllCRDs(): expected %d CRDs, got %d", tc.reason, tc.expected, len(crds)) + return + } + + // Verify it returns a copy (modifying result shouldn't affect internal state) + if len(crds) > 0 { + originalLen := len(c.crds) + + crds[0] = nil // Modify the returned slice + if len(c.crds) != originalLen || c.crds[0] == nil { + t.Errorf("\n%s\nGetAllCRDs(): should return a copy, not reference to internal slice", tc.reason) + } + } + }) + } +} + +func TestExtractGVKsFromXRD(t *testing.T) { + tests := map[string]struct { + reason string + xrd *un.Unstructured + expectedGVKs []schema.GroupVersionKind + expectErr bool + errMsg string + }{ + "ValidV1XRD": { + reason: "Should extract GVKs from a valid v1 XRD with multiple versions", + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", testXResourcePlural+".example.org"). + WithSpec(map[string]any{ + "group": testExampleOrgGroup, + "names": map[string]any{ + "kind": testXResourceKind, + "plural": testXResourcePlural, + "singular": "xresource", + }, + "versions": []any{ + map[string]any{ + "name": "v1alpha1", + "served": true, + }, + map[string]any{ + "name": "v1", + "served": true, + "referenceable": true, + }, + }, + }).Build(), + expectedGVKs: []schema.GroupVersionKind{ + {Group: testExampleOrgGroup, Version: "v1alpha1", Kind: testXResourceKind}, + {Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind}, + }, + expectErr: false, + }, + "ValidV2XRD": { + reason: "Should extract GVKs from a valid v2 XRD with multiple versions", + xrd: tu.NewResource("apiextensions.crossplane.io/v2", "CompositeResourceDefinition", testXResourcePlural+".example.org"). + WithSpec(map[string]any{ + "group": testExampleOrgGroup, + "names": map[string]any{ + "kind": testXResourceKind, + "plural": testXResourcePlural, + "singular": "xresource", + }, + "versions": []any{ + map[string]any{ + "name": "v1beta1", + "served": true, + }, + map[string]any{ + "name": "v1", + "served": true, + "referenceable": true, + }, + }, + }).Build(), + expectedGVKs: []schema.GroupVersionKind{ + {Group: testExampleOrgGroup, Version: "v1beta1", Kind: testXResourceKind}, + {Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind}, + }, + expectErr: false, + }, + + "UnsupportedAPIVersion": { + reason: "Should fail when XRD has unsupported apiVersion", + // Unsupported version + xrd: tu.NewResource("apiextensions.crossplane.io/v3", "CompositeResourceDefinition", "invalid-xrd"). + WithSpec(map[string]any{ + "group": testExampleOrgGroup, + "names": map[string]any{ + "kind": testXResourceKind, + }, + "versions": []any{ + map[string]any{ + "name": "v1", + "served": true, + }, + }, + }).Build(), + expectErr: true, + errMsg: "unsupported XRD apiVersion", + }, + "ConversionError": { + reason: "Should fail when XRD cannot be converted to typed object", + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "invalid-xrd"). + WithSpec(map[string]any{ + "group": testExampleOrgGroup, + "names": "invalid-names-should-be-object", // Invalid structure + "versions": []any{ + map[string]any{ + "name": "v1", + "served": true, + }, + }, + }).Build(), + expectErr: true, + errMsg: "cannot convert XRD", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + gvks, err := extractGVKsFromXRD(tc.xrd) + + if tc.expectErr { + if err == nil { + t.Errorf("\n%s\nextractGVKsFromXRD(): expected error but got none", tc.reason) + return + } + + if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { + t.Errorf("\n%s\nextractGVKsFromXRD(): expected error containing %q, got %q", + tc.reason, tc.errMsg, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nextractGVKsFromXRD(): unexpected error: %v", tc.reason, err) + + return + } + + if diff := cmp.Diff(tc.expectedGVKs, gvks); diff != "" { + t.Errorf("\n%s\nextractGVKsFromXRD(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestSchemaClient_CachingBehavior(t *testing.T) { + ctx := t.Context() + scheme := runtime.NewScheme() + + // Create test CRD as unstructured for the mock dynamic client using CRD builder + testCRDUnstructuredObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( + tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). + WithPlural(testXResourcePlural). + WithSingular("xresource"). + Build()) + testCRDUnstructured := &un.Unstructured{Object: testCRDUnstructuredObj} + + tests := map[string]struct { + reason string + setupClient func() (*DefaultSchemaClient, *int) + gvk schema.GroupVersionKind + expectedCalls int + expectCRDCached bool + expectError bool + }{ + "FirstCallFetchesFromCluster": { + reason: "First GetCRD call should fetch from dynamic client", + setupClient: func() (*DefaultSchemaClient, *int) { + callCount := 0 + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + callCount++ + + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, testCRDUnstructured, nil + } + + return false, nil, nil + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + client := &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + return client, &callCount + }, + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + expectedCalls: 1, + expectCRDCached: true, + expectError: false, + }, + "SecondCallUsesCache": { + reason: "Second GetCRD call should use cache without calling dynamic client", + setupClient: func() (*DefaultSchemaClient, *int) { + callCount := 0 + dynamicClient := fake.NewSimpleDynamicClient(scheme) + dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { + callCount++ + + getAction := action.(kt.GetAction) + if getAction.GetName() == testXResourcePlural+".example.org" { + return true, testCRDUnstructured, nil + } + + return false, nil, nil + }) + + mockConverter := tu.NewMockTypeConverter(). + WithResourceNameForGVK(schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, testXResourcePlural).Build() + + client := &DefaultSchemaClient{ + dynamicClient: dynamicClient, + typeConverter: mockConverter, + logger: tu.TestLogger(t, false), + resourceTypeMap: make(map[schema.GroupVersionKind]bool), + crds: []*extv1.CustomResourceDefinition{}, + crdByName: make(map[string]*extv1.CustomResourceDefinition), + xrdToCRDName: make(map[string]string), + } + + // Pre-populate cache by making first call + gvk := schema.GroupVersionKind{Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind} + _, _ = client.GetCRD(ctx, gvk) + callCount = 0 // Reset counter after pre-population + + return client, &callCount + }, + gvk: schema.GroupVersionKind{ + Group: testExampleOrgGroup, + Version: "v1", + Kind: testXResourceKind, + }, + expectedCalls: 0, // Should use cache, no additional calls + expectCRDCached: true, + expectError: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + client, callCountPtr := tc.setupClient() + + // Call GetCRD + crd, err := client.GetCRD(ctx, tc.gvk) + + // Check error expectations + if tc.expectError { + if err == nil { + t.Errorf("\n%s\nGetCRD(): expected error but got none", tc.reason) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCRD(): unexpected error: %v", tc.reason, err) + return + } + + // Verify call count + if *callCountPtr != tc.expectedCalls { + t.Errorf("\n%s\nExpected %d calls to dynamic client, got %d", + tc.reason, tc.expectedCalls, *callCountPtr) + } + + // Verify CRD was returned + if crd == nil { + t.Errorf("\n%s\nGetCRD(): expected CRD but got nil", tc.reason) + return + } + + // Verify caching behavior + if tc.expectCRDCached { + allCRDs := client.GetAllCRDs() + if len(allCRDs) == 0 { + t.Errorf("\n%s\nExpected CRD to be cached in GetAllCRDs(), got empty slice", tc.reason) + } + + // Verify consistency between calls + crd2, err2 := client.GetCRD(ctx, tc.gvk) + if err2 != nil { + t.Errorf("\n%s\nSecond GetCRD() call failed: %v", tc.reason, err2) + return + } + + if diff := cmp.Diff(crd, crd2); diff != "" { + t.Errorf("\n%s\nCached CRD differs from original: -want, +got:\n%s", tc.reason, diff) + } + } + }) + } +} diff --git a/internal/diff/client/kubernetes/type_converter.go b/internal/diff/client/kubernetes/type_converter.go new file mode 100644 index 00000000..b3014805 --- /dev/null +++ b/internal/diff/client/kubernetes/type_converter.go @@ -0,0 +1,102 @@ +package kubernetes + +import ( + "context" + "sync" + + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" +) + +// TypeConverter provides conversion between Kubernetes types. +type TypeConverter interface { + // GVKToGVR converts a GroupVersionKind to a GroupVersionResource + GVKToGVR(ctx context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) + + // GetResourceNameForGVK returns the resource name for a given GVK + GetResourceNameForGVK(ctx context.Context, gvk schema.GroupVersionKind) (string, error) +} + +// DefaultTypeConverter implements TypeConverter. +type DefaultTypeConverter struct { + dynamicClient dynamic.Interface + discoveryClient discovery.DiscoveryInterface + logger logging.Logger + + // GVK caching + gvkToGVRMap map[schema.GroupVersionKind]schema.GroupVersionResource + gvkToGVRMutex sync.RWMutex +} + +// NewTypeConverter creates a new DefaultTypeConverter. +func NewTypeConverter(clients *core.Clients, logger logging.Logger) TypeConverter { + return &DefaultTypeConverter{ + dynamicClient: clients.Dynamic, + discoveryClient: clients.Discovery, + logger: logger, + gvkToGVRMap: make(map[schema.GroupVersionKind]schema.GroupVersionResource), + } +} + +// GVKToGVR converts a GroupVersionKind to a GroupVersionResource. +func (c *DefaultTypeConverter) GVKToGVR(ctx context.Context, gvk schema.GroupVersionKind) (schema.GroupVersionResource, error) { + // Use the cached mapping if we have it + c.gvkToGVRMutex.RLock() + + if gvr, ok := c.gvkToGVRMap[gvk]; ok { + c.gvkToGVRMutex.RUnlock() + return gvr, nil + } + + c.gvkToGVRMutex.RUnlock() + + // Get the resource name + resourceName, err := c.GetResourceNameForGVK(ctx, gvk) + if err != nil { + c.logger.Debug("Failed to get resource name for GVK", "gvk", gvk.String(), "error", err) + return schema.GroupVersionResource{}, err + } + + // Create the GVR + gvr := schema.GroupVersionResource{ + Group: gvk.Group, + Version: gvk.Version, + Resource: resourceName, + } + + // Cache this mapping for future use + c.gvkToGVRMutex.Lock() + c.gvkToGVRMap[gvk] = gvr + c.gvkToGVRMutex.Unlock() + + return gvr, nil +} + +// GetResourceNameForGVK returns the resource name for a given GVK. +func (c *DefaultTypeConverter) GetResourceNameForGVK(_ context.Context, gvk schema.GroupVersionKind) (string, error) { + // Get resources for the specified group version + resources, err := c.discoveryClient.ServerResourcesForGroupVersion(gvk.GroupVersion().String()) + if err != nil { + return "", errors.Wrapf(err, "failed to discover resources for %s", gvk.GroupVersion().String()) + } + + if resources == nil || len(resources.APIResources) == 0 { + return "", errors.Errorf("no resources found for group version %s", gvk.GroupVersion().String()) + } + + // Find the API resource that matches our kind + for _, r := range resources.APIResources { + if r.Kind == gvk.Kind { + return r.Name, nil + } + } + + // If we get here, we couldn't find a matching resource kind + return "", errors.Errorf("no resource found for kind %s in group version %s", gvk.Kind, gvk.GroupVersion().String()) +} diff --git a/internal/diff/client/kubernetes/type_converter_test.go b/internal/diff/client/kubernetes/type_converter_test.go new file mode 100644 index 00000000..1df3fec2 --- /dev/null +++ b/internal/diff/client/kubernetes/type_converter_test.go @@ -0,0 +1,366 @@ +package kubernetes + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + testdiscovery "k8s.io/client-go/discovery/fake" + kt "k8s.io/client-go/testing" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ TypeConverter = (*tu.MockTypeConverter)(nil) + +func TestTypeConverter_GVKToGVR(t *testing.T) { + type args struct { + ctx context.Context + gvk schema.GroupVersionKind + } + + type want struct { + gvr schema.GroupVersionResource + err error + } + + tests := map[string]struct { + reason string + resources []*metav1.APIResourceList + args args + want want + }{ + "StandardResourceMapping": { + reason: "Should correctly map a standard resource GVK to GVR", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "resources", + Kind: "Resource", + Namespaced: true, + }, + }, + }, + }, + + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + }, + want: want{ + gvr: schema.GroupVersionResource{ + Group: "example.org", + Version: "v1", + Resource: "resources", + }, + }, + }, + "NonStandardResourceMapping": { + reason: "Should correctly map a non-standard pluralized resource", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "indices", // Non-standard pluralization + Kind: "Index", + Namespaced: true, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Index", + }, + }, + want: want{ + gvr: schema.GroupVersionResource{ + Group: "example.org", + Version: "v1", + Resource: "indices", + }, + }, + }, + "DiscoveryFailure": { + reason: "Should return error when discovery fails", + resources: []*metav1.APIResourceList{}, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + }, + want: want{ + err: errors.New("failed to discover resources for example.org/v1"), + }, + }, + "ResourceNotFound": { + reason: "Should return error when resource kind is not found", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "other-resources", + Kind: "OtherResource", + Namespaced: true, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", // Not in discovery + }, + }, + want: want{ + err: errors.New("no resource found for kind Resource in group version example.org/v1"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + discoveryClient := &testdiscovery.FakeDiscovery{ + Fake: &kt.Fake{}, + } + discoveryClient.Resources = tc.resources + + c := &DefaultTypeConverter{ + discoveryClient: discoveryClient, + logger: tu.TestLogger(t, false), + gvkToGVRMap: make(map[schema.GroupVersionKind]schema.GroupVersionResource), + } + + gvr, err := c.GVKToGVR(tc.args.ctx, tc.args.gvk) + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nGVKToGVR(...): expected error but got none", tc.reason) + return + } + + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nGVKToGVR(...): expected error containing %q, got %q", + tc.reason, tc.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGVKToGVR(...): unexpected error: %v", tc.reason, err) + return + } + + if diff := cmp.Diff(tc.want.gvr, gvr); diff != "" { + t.Errorf("\n%s\nGVKToGVR(...): -want GVR, +got GVR:\n%s", tc.reason, diff) + } + }) + } +} + +func TestTypeConverter_GetResourceNameForGVK(t *testing.T) { + type args struct { + ctx context.Context + gvk schema.GroupVersionKind + } + + type want struct { + resourceName string + err error + } + + tests := map[string]struct { + reason string + resources []*metav1.APIResourceList + args args + want want + }{ + "StandardResource": { + reason: "Should correctly get resource name for a standard resource", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "resources", + Kind: "Resource", + Namespaced: true, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + }, + want: want{ + resourceName: "resources", + }, + }, + "NonStandardPluralization": { + reason: "Should correctly get resource name for a non-standard pluralized resource", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "indices", // Non-standard pluralization + Kind: "Index", + Namespaced: true, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Index", + }, + }, + want: want{ + resourceName: "indices", + }, + }, + "MultipleResourcesForKind": { + reason: "Should get the first resource name when multiple resources exist for the same kind", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "resources", + Kind: "Resource", + Namespaced: true, + }, + { + Name: "resources/status", + Kind: "Resource", // Same kind, subresource + Namespaced: true, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + }, + want: want{ + resourceName: "resources", // Gets the first one + }, + }, + "DiscoveryFailure": { + reason: "Should return error when discovery fails", + resources: []*metav1.APIResourceList{}, + + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", + }, + }, + want: want{ + err: errors.New("failed to discover resources for example.org/v1"), + }, + }, + "ResourceNotFound": { + reason: "Should return error when resource kind is not found", + resources: []*metav1.APIResourceList{ + { + GroupVersion: "example.org/v1", + APIResources: []metav1.APIResource{ + { + Name: "other-resources", + Kind: "OtherResource", + Namespaced: true, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "Resource", // Not in discovery + }, + }, + want: want{ + err: errors.New("no resource found for kind Resource in group version example.org/v1"), + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + discoveryClient := &testdiscovery.FakeDiscovery{ + Fake: &kt.Fake{}, + } + discoveryClient.Resources = tc.resources + + c := &DefaultTypeConverter{ + discoveryClient: discoveryClient, + logger: tu.TestLogger(t, false), + gvkToGVRMap: make(map[schema.GroupVersionKind]schema.GroupVersionResource), + } + + resourceName, err := c.GetResourceNameForGVK(tc.args.ctx, tc.args.gvk) + + if tc.want.err != nil { + if err == nil { + t.Errorf("\n%s\nGetResourceNameForGVK(...): expected error but got none", tc.reason) + return + } + + if !strings.Contains(err.Error(), tc.want.err.Error()) { + t.Errorf("\n%s\nGetResourceNameForGVK(...): expected error containing %q, got %q", + tc.reason, tc.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetResourceNameForGVK(...): unexpected error: %v", tc.reason, err) + return + } + + if diff := cmp.Diff(tc.want.resourceName, resourceName); diff != "" { + t.Errorf("\n%s\nGetResourceNameForGVK(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} From 1f914a1de5fa51d02b1fc19d3fc21b907340d896 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:38:59 -0400 Subject: [PATCH 008/126] feat(diff): port crossplane client from crossplane-diff 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/client/crossplane/base.go | 114 + .../client/crossplane/composition_client.go | 1069 ++++++++ .../crossplane/composition_client_test.go | 2271 +++++++++++++++++ .../crossplane/composition_revision_client.go | 334 +++ .../composition_revision_client_test.go | 721 ++++++ .../client/crossplane/credential_client.go | 123 + .../crossplane/credential_client_test.go | 259 ++ .../client/crossplane/definition_client.go | 295 +++ .../crossplane/definition_client_test.go | 960 +++++++ .../client/crossplane/environment_client.go | 92 + .../crossplane/environment_client_test.go | 390 +++ .../diff/client/crossplane/function_client.go | 162 ++ .../client/crossplane/function_client_test.go | 621 +++++ .../client/crossplane/resource_tree_client.go | 75 + .../crossplane/resource_tree_client_test.go | 6 + .../client/crossplane/revision_selector.go | 170 ++ .../crossplane/revision_selector_test.go | 257 ++ 17 files changed, 7919 insertions(+) create mode 100644 internal/diff/client/crossplane/base.go create mode 100644 internal/diff/client/crossplane/composition_client.go create mode 100644 internal/diff/client/crossplane/composition_client_test.go create mode 100644 internal/diff/client/crossplane/composition_revision_client.go create mode 100644 internal/diff/client/crossplane/composition_revision_client_test.go create mode 100644 internal/diff/client/crossplane/credential_client.go create mode 100644 internal/diff/client/crossplane/credential_client_test.go create mode 100644 internal/diff/client/crossplane/definition_client.go create mode 100644 internal/diff/client/crossplane/definition_client_test.go create mode 100644 internal/diff/client/crossplane/environment_client.go create mode 100644 internal/diff/client/crossplane/environment_client_test.go create mode 100644 internal/diff/client/crossplane/function_client.go create mode 100644 internal/diff/client/crossplane/function_client_test.go create mode 100644 internal/diff/client/crossplane/resource_tree_client.go create mode 100644 internal/diff/client/crossplane/resource_tree_client_test.go create mode 100644 internal/diff/client/crossplane/revision_selector.go create mode 100644 internal/diff/client/crossplane/revision_selector_test.go diff --git a/internal/diff/client/crossplane/base.go b/internal/diff/client/crossplane/base.go new file mode 100644 index 00000000..02e87819 --- /dev/null +++ b/internal/diff/client/crossplane/base.go @@ -0,0 +1,114 @@ +// Package crossplane contains interfaces and implementations for clients that talk to Kubernetes about Crossplane +// primitives, often by consuming clients from the kubernetes package. +package crossplane + +import ( + "context" + "fmt" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" +) + +// Crossplane API group and kind constants used across the client package. +const ( + // CrossplaneAPIExtGroup is the Crossplane API extensions group. + CrossplaneAPIExtGroup = "apiextensions.crossplane.io" + // CrossplaneAPIExtGroupV1 is the v1 group/version for Crossplane API extensions. + CrossplaneAPIExtGroupV1 = "apiextensions.crossplane.io/v1" + // CrossplaneAPIExtGroupV2 is the v2 group/version for Crossplane API extensions. + CrossplaneAPIExtGroupV2 = "apiextensions.crossplane.io/v2" + // CrossplanePkgGroup is the Crossplane packages group. + CrossplanePkgGroup = "pkg.crossplane.io" + // CompositionKind is the kind for Compositions. + CompositionKind = "Composition" + // CompositionRevisionKind is the kind for CompositionRevisions. + CompositionRevisionKind = "CompositionRevision" + // FunctionKind is the kind for Crossplane Functions. + FunctionKind = "Function" + + // updatePolicyAutomatic is the Automatic compositionUpdatePolicy value (also the default when + // unset), mirroring Crossplane's CompositionUpdatePolicy. + updatePolicyAutomatic = "Automatic" + // updatePolicyManual is the Manual compositionUpdatePolicy value. + updatePolicyManual = "Manual" +) + +// Initialize initializes all the clients in this bundle. +func (c *Clients) Initialize(ctx context.Context, logger logging.Logger) error { + return core.InitializeClients(ctx, logger, + // definition client before composition client since it's a dependency + c.Definition, + c.Composition, + c.Environment, + c.Function, + c.ResourceTree, + ) +} + +// Clients is an aggregation of all of our Crossplane clients, used to pass them as a bundle, +// typically for initialization where the consumer can select which ones they need. +type Clients struct { + Composition CompositionClient + Credential CredentialClient + Definition DefinitionClient + Environment EnvironmentClient + Function FunctionClient + ResourceTree ResourceTreeClient +} + +func cacheKey(namespace, name string) string { + // Create a unique cache key based on namespace and name + return fmt.Sprintf("%s/%s", namespace, name) +} + +// getFirstMatchingResource retrieves the first resource matching the given GVKs and name. +func getFirstMatchingResource(ctx context.Context, client kubernetes.ResourceClient, gvks []schema.GroupVersionKind, name, namespace string, cache map[string]*un.Unstructured) (*un.Unstructured, error) { + key := cacheKey(namespace, name) + + // Check cache first + if config, ok := cache[key]; ok { + return config, nil + } + + var errs []error + + for _, gvk := range gvks { + // try for this version + item, err := client.GetResource(ctx, gvk, namespace, name) + if err != nil { + errs = append(errs, errors.Wrapf(err, "cannot get %s %s", gvk.String(), name)) + // not found in this version, try next + continue + } + + // Found item; update cache + cache[key] = item + + return item, nil + } + + return nil, errors.Join(errs...) // return all errors if none found +} + +func listMatchingResources(ctx context.Context, client kubernetes.ResourceClient, gvks []schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) { + // List all matching resources for the given GVKs + var envConfigs []*un.Unstructured + + for _, gvk := range gvks { + ecs, err := client.ListResources(ctx, gvk, namespace) + if err != nil { + return nil, errors.Wrapf(err, "cannot list items for GVK %s", gvk.String()) + } + + envConfigs = append(envConfigs, ecs...) + } + + return envConfigs, nil +} diff --git a/internal/diff/client/crossplane/composition_client.go b/internal/diff/client/crossplane/composition_client.go new file mode 100644 index 00000000..bf68da8f --- /dev/null +++ b/internal/diff/client/crossplane/composition_client.go @@ -0,0 +1,1069 @@ +package crossplane + +import ( + "context" + "fmt" + "maps" + "slices" + "strings" + "sync" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/ref" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +// CompositionClient handles operations related to Compositions. +type CompositionClient interface { + core.Initializable + + // FindMatchingComposition finds the composition that matches the given XR or claim, and the + // CompositionRevision it comes from when one applies. See dtypes.ResolvedComposition. + FindMatchingComposition(ctx context.Context, res *un.Unstructured) (dtypes.ResolvedComposition, error) + + // ListCompositions lists all compositions in the cluster + ListCompositions(ctx context.Context) ([]*apiextensionsv1.Composition, error) + + // GetComposition gets a composition by name + GetComposition(ctx context.Context, name string) (*apiextensionsv1.Composition, error) + + // FindComposites locates composites (XRs and Claims) that reference a composition. + // `comp` is taken as the user-supplied unstructured Composition (typically loaded from a YAML + // file); the client converts to a typed *apiextensionsv1.Composition internally only when + // needed (i.e. in refs-mode, which reads spec.compositeTypeRef). Default-discovery only needs + // comp.GetName() and avoids the conversion entirely so it isn't tripped by version skew or + // unknown fields in the input file. + // + // When opts.Refs is non-empty, it performs ref-based lookup: each ref is resolved against the + // composition's XR GVK (then claim GVK on 404 if the XRD defines a claim type). A ref is included + // in the result only when (a) the cluster lookup succeeds AND (b) the resource references this + // composition by name. Refs that don't satisfy both are silently omitted; callers derive + // "unmatched" from the diff between input refs and returned objects. NotFound on per-ref XR/Claim + // lookups is tolerated; non-NotFound transport errors propagate. + // + // When opts.Refs is empty, it performs default discovery scoped to opts.Namespace, listing all + // XRs (and Claims, if the XRD defines them) of the appropriate GVK and filtering by composition. + // XR/Claim list errors are tolerated (each list is best-effort and produces an empty bucket on + // failure). However, the composition itself must be in the cluster cache: when GetComposition + // returns NotFound (a net-new composition not yet applied), FindComposites returns that error to + // the caller. Callers handle that case by treating it as "no affected XRs" — see + // DiffComposition's default-discovery branch. + FindComposites(ctx context.Context, comp *un.Unstructured, opts dtypes.FindCompositesOptions) ([]*un.Unstructured, error) +} + +var ( + // ErrNoCompositionRevisions reports that a composition has no CompositionRevisions (it is new + // or unpublished). + ErrNoCompositionRevisions = errors.New("no composition revisions found") + + // ErrNoMatchingComposition reports that no composition targets a composite's GVK. + ErrNoMatchingComposition = errors.New("no composition found") +) + +// DefaultCompositionClient implements CompositionClient. +type DefaultCompositionClient struct { + resourceClient kubernetes.ResourceClient + definitionClient DefinitionClient + revisionClient CompositionRevisionClient + logger logging.Logger + + // Cache of compositions, guarded by compositionsMu. + compositions map[string]*apiextensionsv1.Composition + compositionsMu sync.RWMutex + gvks []schema.GroupVersionKind +} + +// NewCompositionClient creates a new DefaultCompositionClient. +func NewCompositionClient(resourceClient kubernetes.ResourceClient, definitionClient DefinitionClient, logger logging.Logger) CompositionClient { + return &DefaultCompositionClient{ + resourceClient: resourceClient, + definitionClient: definitionClient, + revisionClient: NewCompositionRevisionClient(resourceClient, logger), + logger: logger, + compositions: make(map[string]*apiextensionsv1.Composition), + } +} + +// Initialize loads compositions into the cache. +func (c *DefaultCompositionClient) Initialize(ctx context.Context) error { + c.logger.Debug("Initializing composition client") + + gvks, err := c.resourceClient.GetGVKsForGroupKind(ctx, CrossplaneAPIExtGroup, CompositionKind) + if err != nil { + return errors.Wrap(err, "cannot get Composition GVKs") + } + + c.gvks = gvks + + // Initialize revision client + if err := c.revisionClient.Initialize(ctx); err != nil { + return errors.Wrap(err, "cannot initialize composition revision client") + } + + // List compositions to populate the cache + comps, err := c.ListCompositions(ctx) + if err != nil { + return errors.Wrap(err, "cannot list compositions") + } + + // Store in cache + c.compositionsMu.Lock() + for _, comp := range comps { + c.compositions[comp.GetName()] = comp + } + c.compositionsMu.Unlock() + + c.logger.Debug("Composition client initialized", "compositionsCount", len(comps)) + + return nil +} + +// ListCompositions lists all compositions in the cluster. +func (c *DefaultCompositionClient) ListCompositions(ctx context.Context) ([]*apiextensionsv1.Composition, error) { + c.logger.Debug("Listing compositions from cluster") + + // Define the composition GVK + gvk := schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: CompositionKind, + } + + // TODO: we don't actually use our cached GVKs here -- but there's only one version of Composition + // and this part is strongly typed which will make a second version hard to handle + + // Get all compositions using the resource client + unComps, err := c.resourceClient.ListResources(ctx, gvk, "") + if err != nil { + c.logger.Debug("Failed to list compositions", "error", err) + return nil, errors.Wrap(err, "cannot list compositions from cluster") + } + + // Convert unstructured to typed + compositions := make([]*apiextensionsv1.Composition, 0, len(unComps)) + for _, obj := range unComps { + comp := &apiextensionsv1.Composition{} + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, comp) + if err != nil { + c.logger.Debug("Failed to convert composition from unstructured", + "name", obj.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot convert unstructured to Composition") + } + + compositions = append(compositions, comp) + } + + c.logger.Debug("Successfully retrieved compositions", "count", len(compositions)) + + return compositions, nil +} + +// GetComposition gets a composition by name. +func (c *DefaultCompositionClient) GetComposition(ctx context.Context, name string) (*apiextensionsv1.Composition, error) { + // Check cache first + c.compositionsMu.RLock() + comp, ok := c.compositions[name] + c.compositionsMu.RUnlock() + + if ok { + return comp, nil + } + + // Not in cache, fetch from cluster + gvk := schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: CompositionKind, + } + + unComp, err := c.resourceClient.GetResource(ctx, gvk, "" /* Compositions are cluster scoped */, name) + if err != nil { + return nil, errors.Wrapf(err, "cannot get composition %s", name) + } + + // Convert to typed # TODO: troublesome because typed has a version + comp = &apiextensionsv1.Composition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unComp.Object, comp); err != nil { + return nil, errors.Wrap(err, "cannot convert unstructured to Composition") + } + + // Update cache + c.compositionsMu.Lock() + c.compositions[name] = comp + c.compositionsMu.Unlock() + + return comp, nil +} + +// cachedCompositions returns the cached compositions, so a caller can range over them while +// another goroutine adds to the cache. +func (c *DefaultCompositionClient) cachedCompositions() []*apiextensionsv1.Composition { + c.compositionsMu.RLock() + defer c.compositionsMu.RUnlock() + + return slices.Collect(maps.Values(c.compositions)) +} + +// getCompositionRevisionRef reads the compositionRevisionRef from an XR/Claim spec. +// Returns the revision name and whether it was found. A non-nil error means the field is malformed +// (present but not a string/object), which is treated as a hard failure by the caller. +// Checks both v2 (spec.crossplane.compositionRevisionRef) and v1 (spec.compositionRevisionRef) paths. +func (c *DefaultCompositionClient) getCompositionRevisionRef(xrd, res *un.Unstructured) (string, bool, error) { + name, found, err := nestedCrossplaneString(res.Object, xrd.GetAPIVersion(), "compositionRevisionRef", "name") + if err != nil { + return "", false, err + } + + return name, found && name != "", nil +} + +// resolveCompositionFromRevisions selects the CompositionRevision Crossplane would compose res with, +// mirroring the composite reconciler's revision fetcher. Returns nil if the composition has no +// revisions yet, in which case the composition itself is used. +func (c *DefaultCompositionClient) resolveCompositionFromRevisions( + ctx context.Context, + xrd, res *un.Unstructured, + compositionName string, + resourceID string, +) (*apiextensionsv1.CompositionRevision, error) { + // Check if there's a composition revision reference + revisionRefName, hasRevisionRef, err := c.getCompositionRevisionRef(xrd, res) + if err != nil { + return nil, errors.Wrapf(err, "cannot read compositionRevisionRef for %s", resourceID) + } + + updatePolicy, err := XRUpdatePolicy(res.Object, xrd.GetAPIVersion()) + if err != nil { + return nil, errors.Wrapf(err, "cannot read compositionUpdatePolicy for %s", resourceID) + } + + c.logger.Debug("Checking revision resolution", + "resource", resourceID, + "hasRevisionRef", hasRevisionRef, + "revisionRef", revisionRefName, + "updatePolicy", updatePolicy) + + switch { + case updatePolicy == updatePolicyAutomatic: + // Case 1: Automatic policy - use the latest revision that matches the XR's + // compositionRevisionSelector (a nil/Everything selector when there is none), mirroring + // Crossplane's revision selection. + selector, err := XRRevisionLabelSelector(res) + if err != nil { + return nil, errors.Wrapf(err, "cannot evaluate compositionRevisionSelector for %s", resourceID) + } + + latest, err := c.revisionClient.GetLatestRevisionForComposition(ctx, compositionName, selector) + if err != nil { + // Check if this is a "no revisions found" case (new/unpublished composition) + if errors.Is(err, ErrNoCompositionRevisions) { + c.logger.Debug("No revisions found for composition (likely unpublished), falling back to composition directly", + "compositionName", compositionName, + "resource", resourceID) + + // Fall back to using composition directly for unpublished compositions + return nil, nil + } + + // For other errors (including a selector that matches no revision), fail the diff to + // ensure accuracy rather than silently rendering against the wrong revision. + return nil, errors.Wrapf(err, + "cannot resolve latest composition revision for %s with Automatic update policy (composition: %s)", + resourceID, compositionName) + } + + c.logger.Debug("Using latest matching revision for Automatic policy", + "resource", resourceID, + "revisionName", latest.GetName(), + "revisionNumber", latest.Spec.Revision, + "selector", selector.String()) + + return latest, nil + + case updatePolicy == updatePolicyManual && hasRevisionRef: + // Case 2: Manual policy with revision reference - use that specific revision + revision, err := c.revisionClient.GetCompositionRevision(ctx, revisionRefName) + if err != nil { + return nil, errors.Wrapf(err, + "cannot get pinned composition revision %s for %s (composition: %s, policy: Manual)", + revisionRefName, resourceID, compositionName) + } + + // Validate that revision belongs to the referenced composition + if labels := revision.GetLabels(); labels != nil { + if revCompName := labels[LabelCompositionName]; revCompName != "" && revCompName != compositionName { + return nil, errors.Errorf( + "composition revision %s belongs to composition %s, not %s (resource: %s)", + revisionRefName, revCompName, compositionName, resourceID) + } + } + + c.logger.Debug("Using pinned revision for Manual policy", + "resource", resourceID, + "revisionName", revisionRefName, + "revisionNumber", revision.Spec.Revision) + + return revision, nil + + default: + // Case 3: Manual policy without revision reference in spec + // When creating a new XR with Manual policy and no compositionRevisionRef, + // Crossplane pins it to the latest revision at creation time. + // Use the latest revision to match this behavior. + c.logger.Debug("Manual policy without revision ref - using latest revision (will be pinned on creation)", + "resource", resourceID, + "compositionName", compositionName) + + latest, err := c.revisionClient.GetLatestRevisionForComposition(ctx, compositionName, nil) + if err != nil { + // Check if this is a "no revisions found" case (new/unpublished composition) + if errors.Is(err, ErrNoCompositionRevisions) { + c.logger.Debug("No revisions found for composition (likely unpublished), falling back to composition directly", + "compositionName", compositionName, + "resource", resourceID) + + return nil, nil + } + + return nil, errors.Wrapf(err, + "cannot resolve latest composition revision for %s with Manual policy (composition: %s)", + resourceID, compositionName) + } + + c.logger.Debug("Using latest revision for Manual policy", + "resource", resourceID, + "revisionName", latest.GetName(), + "revisionNumber", latest.Spec.Revision) + + return latest, nil + } +} + +// FindMatchingComposition finds a composition matching the given resource, and the revision it comes +// from. Only a resource naming its composition resolves a revision: one Crossplane has reconciled always +// does, because the composite reconciler writes compositionRef. One that selects its composition by label +// or by type resolves none, so its RevisionName is empty. +func (c *DefaultCompositionClient) FindMatchingComposition(ctx context.Context, res *un.Unstructured) (dtypes.ResolvedComposition, error) { + gvk := res.GroupVersionKind() + resourceID := fmt.Sprintf("%s/%s", gvk.String(), res.GetName()) + + c.logger.Debug("Finding matching composition", + "resource_name", res.GetName(), + "gvk", gvk.String()) + + // First, check if this is a claim by looking for an XRD that defines this as a claim + xrd, err := c.definitionClient.GetXRDForClaim(ctx, gvk) + if err != nil { + c.logger.Debug("Error checking if resource is claim type", + "resource", resourceID, + "error", err) + // Continue as if not a claim - we'll try normal composition matching + } + + // If it's a claim, we need to find compositions for the corresponding XR type + var targetGVK schema.GroupVersionKind + + switch { + case xrd != nil: + targetGVK, err = c.getXRTypeFromXRD(xrd, resourceID) + if err != nil { + return dtypes.ResolvedComposition{}, errors.Wrapf(err, "claim %s requires its XR type to find a composition", resourceID) + } + default: + targetGVK = gvk + c.logger.Debug("Resource is not a claim type, looking for XRD for XR", + "resource", resourceID, + "targetGVK", targetGVK.String()) + + xrd, err = c.definitionClient.GetXRDForXR(ctx, gvk) + if err != nil { + return dtypes.ResolvedComposition{}, errors.Wrapf(err, "resource %s requires its XR type to find a composition", resourceID) + } + } + + // Case 1: Check for direct composition reference in spec.compositionRef.name + resolved, err := c.findByDirectReference(ctx, xrd, res, targetGVK, resourceID) + if err != nil || resolved.Composition != nil { + return resolved, err + } + + // Case 2: Check for selector-based composition reference + comp, err := c.findByLabelSelector(ctx, xrd, res, targetGVK, resourceID) + if err != nil || comp != nil { + return dtypes.ResolvedComposition{Composition: comp}, err + } + + // Case 3: Look up by composite type reference (default behavior) + comp, err = c.findByTypeReference(ctx, xrd, targetGVK, resourceID) + + return dtypes.ResolvedComposition{Composition: comp}, err +} + +// getXRTypeFromXRD extracts the XR GroupVersionKind from an XRD. +func (c *DefaultCompositionClient) getXRTypeFromXRD(xrdForClaim *un.Unstructured, resourceID string) (schema.GroupVersionKind, error) { + // Get the XR type from the XRD + xrGroup, found, _ := un.NestedString(xrdForClaim.Object, "spec", "group") + xrKind, kindFound, _ := un.NestedString(xrdForClaim.Object, "spec", "names", "kind") + + if !found || !kindFound { + return schema.GroupVersionKind{}, errors.New("could not determine group or kind from XRD") + } + + // Find the referenceable version - there should be exactly one + xrVersion := "" + + versions, versionsFound, _ := un.NestedSlice(xrdForClaim.Object, "spec", "versions") + if versionsFound && len(versions) > 0 { + // Look for the one version that is marked referenceable + for _, versionObj := range versions { + if version, ok := versionObj.(map[string]any); ok { + ref, refFound, _ := un.NestedBool(version, "referenceable") + if refFound && ref { + name, nameFound, _ := un.NestedString(version, "name") + if nameFound { + xrVersion = name + break + } + } + } + } + } + + // If no referenceable version found, we shouldn't guess + if xrVersion == "" { + return schema.GroupVersionKind{}, errors.New("no referenceable version found in XRD") + } + + targetGVK := schema.GroupVersionKind{ + Group: xrGroup, + Version: xrVersion, + Kind: xrKind, + } + + c.logger.Debug("Claim resource detected - targeting XR type for composition matching", + "claim", resourceID, + "targetXR", targetGVK.String()) + + return targetGVK, nil +} + +// getClaimTypeFromXRD extracts the Claim GroupVersionKind from an XRD if it defines one. +// Returns empty GVK and nil error if the XRD doesn't define claims (not an error condition). +func (c *DefaultCompositionClient) getClaimTypeFromXRD(xrd *un.Unstructured) (schema.GroupVersionKind, error) { + // Check if the XRD defines a claim type + claimNames, found, _ := un.NestedMap(xrd.Object, "spec", "claimNames") + if !found || claimNames == nil { + // Not an error - XRD simply doesn't define claims + return schema.GroupVersionKind{}, nil + } + + // Get the claim kind + claimKind, found, _ := un.NestedString(claimNames, "kind") + if !found || claimKind == "" { + return schema.GroupVersionKind{}, errors.New("XRD has claimNames but missing kind") + } + + // Get the group from the XRD + claimGroup, found, _ := un.NestedString(xrd.Object, "spec", "group") + if !found || claimGroup == "" { + return schema.GroupVersionKind{}, errors.New("could not determine group from XRD") + } + + // Find the referenceable version - there should be exactly one + claimVersion := "" + + versions, versionsFound, _ := un.NestedSlice(xrd.Object, "spec", "versions") + if versionsFound && len(versions) > 0 { + // Look for the one version that is marked referenceable + for _, versionObj := range versions { + if version, ok := versionObj.(map[string]any); ok { + ref, refFound, _ := un.NestedBool(version, "referenceable") + if refFound && ref { + name, nameFound, _ := un.NestedString(version, "name") + if nameFound { + claimVersion = name + break + } + } + } + } + } + + // If no referenceable version found, we shouldn't guess + if claimVersion == "" { + return schema.GroupVersionKind{}, errors.New("no referenceable version found in XRD") + } + + claimGVK := schema.GroupVersionKind{ + Group: claimGroup, + Version: claimVersion, + Kind: claimKind, + } + + c.logger.Debug("XRD defines claim type", + "xrd", xrd.GetName(), + "claimGVK", claimGVK.String()) + + return claimGVK, nil +} + +// isCompositionCompatible checks if a composition is compatible with a GVK. +func (c *DefaultCompositionClient) isCompositionCompatible(comp *apiextensionsv1.Composition, xrGVK schema.GroupVersionKind) bool { + return comp.Spec.CompositeTypeRef.APIVersion == xrGVK.GroupVersion().String() && + comp.Spec.CompositeTypeRef.Kind == xrGVK.Kind +} + +// labelsMatch checks if a resource's labels match a selector. +func (c *DefaultCompositionClient) labelsMatch(labels, selector map[string]string) bool { + // A resource matches a selector if all the selector's labels exist in the resource's labels + for k, v := range selector { + if labels[k] != v { + return false + } + } + + return true +} + +// getCrossplaneRefPaths returns possible paths for crossplane spec fields. +// For v2 XRDs, returns both the new path (spec.crossplane.x) and legacy path (spec.x) +// to maintain backward compatibility with XRs that use the v1-style paths. +func getCrossplaneRefPaths(apiVersion string, path ...string) [][]string { + v1Path := append([]string{"spec"}, path...) + v2Path := append([]string{"spec", "crossplane"}, path...) + + switch apiVersion { + case CrossplaneAPIExtGroupV1: + // Crossplane v1 keeps these under spec.x + return [][]string{v1Path} + default: + // Crossplane v2 prefers spec.crossplane.x but also supports legacy spec.x + // Try v2 path first, then fall back to v1 path for compatibility + return [][]string{v2Path, v1Path} + } +} + +// nestedCrossplaneValue reads a value of type T at the v2/v1 crossplane ref paths for the given +// field (v2 preferred), returning the first found. accessor is the typed unstructured getter (e.g. +// un.NestedString, un.NestedMap) and typeName names the expected type for the error message. A +// non-nil error means the field exists at one of the paths but has the wrong type — callers must +// treat that as a hard failure, not "not found", so a malformed value is never silently ignored. +func nestedCrossplaneValue[T any](obj map[string]any, apiVersion, typeName string, accessor func(map[string]any, ...string) (T, bool, error), path ...string) (T, bool, error) { + for _, p := range getCrossplaneRefPaths(apiVersion, path...) { + v, found, err := accessor(obj, p...) + if err != nil { + var zero T + return zero, false, errors.Wrapf(err, "field %q is not %s", strings.Join(path, "."), typeName) + } + + if found { + return v, true, nil + } + } + + var zero T + + return zero, false, nil +} + +// nestedCrossplaneString reads a string at the v2/v1 crossplane ref paths for the given field. +// See nestedCrossplaneValue for semantics. +func nestedCrossplaneString(obj map[string]any, apiVersion string, path ...string) (string, bool, error) { + return nestedCrossplaneValue(obj, apiVersion, "a string", un.NestedString, path...) +} + +// nestedCrossplaneMap reads a map at the v2/v1 crossplane ref paths for the given field. +// See nestedCrossplaneValue for semantics. +func nestedCrossplaneMap(obj map[string]any, apiVersion string, path ...string) (map[string]any, bool, error) { + return nestedCrossplaneValue(obj, apiVersion, "an object", un.NestedMap, path...) +} + +// findByDirectReference attempts to find a composition directly referenced by name, and the revision it +// comes from. The zero value means res references none. +// Checks both v2 (spec.crossplane.compositionRef) and v1 (spec.compositionRef) paths. +func (c *DefaultCompositionClient) findByDirectReference(ctx context.Context, xrd, res *un.Unstructured, targetGVK schema.GroupVersionKind, resourceID string) (dtypes.ResolvedComposition, error) { + // Try all possible paths for compositionRef (v2 path first, then v1 fallback) + var ( + compositionRefName string + compositionRefFound bool + ) + + for _, path := range getCrossplaneRefPaths(xrd.GetAPIVersion(), "compositionRef", "name") { + name, found, err := un.NestedString(res.Object, path...) + if err == nil && found && name != "" { + compositionRefName = name + compositionRefFound = true + + c.logger.Debug("Found compositionRef at path", "path", path, "name", name) + + break + } + } + + if compositionRefFound && compositionRefName != "" { + c.logger.Debug("Found direct composition reference", + "resource", resourceID, + "compositionName", compositionRefName) + + // Check if we should use a revision instead + revision, err := c.resolveCompositionFromRevisions(ctx, xrd, res, compositionRefName, resourceID) + if err != nil { + return dtypes.ResolvedComposition{}, err + } + + if revision != nil { + comp := c.revisionClient.GetCompositionFromRevision(revision) + + // Validate that the composition's compositeTypeRef matches the target GVK + if !c.isCompositionCompatible(comp, targetGVK) { + return dtypes.ResolvedComposition{}, errors.Errorf("composition from revision is not compatible with %s", targetGVK.String()) + } + + return dtypes.ResolvedComposition{Composition: comp, RevisionName: revision.GetName()}, nil + } + + // No revision-based resolution, use composition directly + comp, err := c.GetComposition(ctx, compositionRefName) + if err != nil { + return dtypes.ResolvedComposition{}, errors.Errorf("composition %s referenced in %s not found", + compositionRefName, resourceID) + } + + // Validate that the composition's compositeTypeRef matches the target GVK + if !c.isCompositionCompatible(comp, targetGVK) { + return dtypes.ResolvedComposition{}, errors.Errorf("composition %s is not compatible with %s", + compositionRefName, targetGVK.String()) + } + + c.logger.Debug("Found composition by direct reference", + "resource", resourceID, + "composition", comp.GetName()) + + return dtypes.ResolvedComposition{Composition: comp}, nil + } + + return dtypes.ResolvedComposition{}, nil // No direct reference found +} + +// findByLabelSelector attempts to find compositions that match label selectors. +// Checks both v2 (spec.crossplane.compositionSelector) and v1 (spec.compositionSelector) paths. +func (c *DefaultCompositionClient) findByLabelSelector(ctx context.Context, xrd, res *un.Unstructured, targetGVK schema.GroupVersionKind, resourceID string) (*apiextensionsv1.Composition, error) { + // Read compositionSelector.matchLabels from the v2/v1 paths (v2 preferred). + matchLabels, selectorFound, err := nestedCrossplaneMap(res.Object, xrd.GetAPIVersion(), "compositionSelector", "matchLabels") + if err != nil { + return nil, errors.Wrapf(err, "cannot read compositionSelector for %s", resourceID) + } + + if selectorFound && len(matchLabels) > 0 { + c.logger.Debug("Found compositionSelector", "resource", resourceID) + + c.logger.Debug("Found composition selector", + "resource", resourceID, + "matchLabels", matchLabels) + + // Convert matchLabels to string map for comparison + stringLabels := make(map[string]string) + + for k, v := range matchLabels { + if strVal, ok := v.(string); ok { + stringLabels[k] = strVal + } + } + + // Find compositions matching the labels + var matchingCompositions []*apiextensionsv1.Composition + + // Get all compositions if we haven't loaded them yet + cached := c.cachedCompositions() + if len(cached) == 0 { + if _, err := c.ListCompositions(ctx); err != nil { + return nil, errors.Wrap(err, "cannot list compositions to match selector") + } + } + + // Search through all compositions looking for compatible ones with matching labels + for _, comp := range cached { + // Check if this composition is for the right XR type + if c.isCompositionCompatible(comp, targetGVK) { + // Check if labels match + if c.labelsMatch(comp.GetLabels(), stringLabels) { + matchingCompositions = append(matchingCompositions, comp) + } + } + } + + // Handle matching results + switch len(matchingCompositions) { + case 0: + return nil, errors.Errorf("no compatible composition found matching labels %v for %s", + stringLabels, resourceID) + case 1: + c.logger.Debug("Found composition by label selector", + "resource", resourceID, + "composition", matchingCompositions[0].GetName()) + + return matchingCompositions[0], nil + default: + // Multiple matches - this is ambiguous and should fail + names := make([]string, len(matchingCompositions)) + for i, comp := range matchingCompositions { + names[i] = comp.GetName() + } + + return nil, errors.New("ambiguous composition selection: multiple compositions match") + } + } + + return nil, nil // No label selector found or no matches +} + +// findByTypeReference attempts to find a composition by matching the type reference. +func (c *DefaultCompositionClient) findByTypeReference(ctx context.Context, _ *un.Unstructured, targetGVK schema.GroupVersionKind, resourceID string) (*apiextensionsv1.Composition, error) { + // Get all compositions if we haven't loaded them yet + cached := c.cachedCompositions() + if len(cached) == 0 { + if _, err := c.ListCompositions(ctx); err != nil { + return nil, errors.Wrap(err, "cannot list compositions to match type") + } + } + + // Find all compositions that match this target type + var compatibleCompositions []*apiextensionsv1.Composition + + for _, comp := range cached { + if c.isCompositionCompatible(comp, targetGVK) { + compatibleCompositions = append(compatibleCompositions, comp) + } + } + + if len(compatibleCompositions) == 0 { + c.logger.Debug("No matching composition found", + "targetGVK", targetGVK.String()) + + return nil, errors.Errorf("%w for %s", ErrNoMatchingComposition, targetGVK.String()) + } + + if len(compatibleCompositions) > 1 { + // Multiple compositions match, but no selection criteria was provided + // This is an ambiguous situation + names := make([]string, len(compatibleCompositions)) + for i, comp := range compatibleCompositions { + names[i] = comp.GetName() + } + + return nil, errors.Errorf("ambiguous composition selection: multiple compositions exist for %s", targetGVK.String()) + } + + // We have exactly one matching composition + c.logger.Debug("Found matching composition by type reference", + "resource_name", resourceID, + "composition_name", compatibleCompositions[0].GetName()) + + return compatibleCompositions[0], nil +} + +// findByListing implements default-discovery for FindComposites: list every XR (and Claim, if the +// XRD defines one) of the composition's target GVK, scoped to namespace, and filter by composition. +// If the composition itself isn't in the cluster (net-new), the GetComposition lookup fails and the +// error propagates to the caller, which is expected to handle "net-new" gracefully. +func (c *DefaultCompositionClient) findByListing(ctx context.Context, compositionName string, namespace string) ([]*un.Unstructured, error) { + c.logger.Debug("Finding composites using composition", + "compositionName", compositionName, + "namespace", namespace) + + // First, get the composition to understand what XR type it targets + comp, err := c.GetComposition(ctx, compositionName) + if err != nil { + return nil, errors.Wrapf(err, "cannot get composition %s", compositionName) + } + + // Get the XR type this composition targets + xrAPIVersion := comp.Spec.CompositeTypeRef.APIVersion + xrKind := comp.Spec.CompositeTypeRef.Kind + + // Parse the GVK + gv, err := schema.ParseGroupVersion(xrAPIVersion) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse API version %s", xrAPIVersion) + } + + xrGVK := schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: xrKind, + } + + c.logger.Debug("Composition targets XR type", + "gvk", xrGVK.String()) + + // List all resources of this XR type in the specified namespace + // Note: If namespace is specified and XRs are cluster-scoped, this will fail gracefully + // and we'll continue to search for Claims + xrs, err := c.resourceClient.ListResources(ctx, xrGVK, namespace) + + var matchingResources []*un.Unstructured + + if err != nil { + // Log the error but don't fail - we'll try to find Claims instead + c.logger.Debug("Cannot list XRs (will search for claims if XRD defines them)", + "xrGVK", xrGVK.String(), + "namespace", namespace, + "error", err) + } else { + c.logger.Debug("Found XRs of target type", "count", len(xrs)) + + // Filter XRs that use this specific composition + for _, xr := range xrs { + if c.resourceUsesComposition(xr, compositionName) { + matchingResources = append(matchingResources, xr) + } + } + } + + c.logger.Debug("Found XRs using composition", + "compositionName", compositionName, + "count", len(matchingResources)) + + // Now check if the XRD defines a claim type and search for claims too + xrd, err := c.definitionClient.GetXRDForXR(ctx, xrGVK) + if err != nil { + // If we can't get the XRD, just return the XRs we found + c.logger.Debug("Cannot get XRD for XR type (will not search for claims)", + "xrGVK", xrGVK.String(), + "error", err) + + return matchingResources, nil + } + + // Try to get the claim type from the XRD + claimGVK, err := c.getClaimTypeFromXRD(xrd) + if err != nil { + // Error extracting claim type - log and return XRs only + c.logger.Debug("Error extracting claim type from XRD", + "xrd", xrd.GetName(), + "error", err) + + return matchingResources, nil + } + + // If XRD doesn't define claims, just return the XRs + if claimGVK.Empty() { + c.logger.Debug("XRD does not define claims", + "xrd", xrd.GetName()) + + return matchingResources, nil + } + + // XRD defines claims - search for them + c.logger.Debug("Searching for claims using composition", + "claimGVK", claimGVK.String()) + + claims, err := c.resourceClient.ListResources(ctx, claimGVK, namespace) + if err != nil { + // Log error but don't fail - we still have the XRs + c.logger.Debug("Cannot list claims of type (will only return XRs)", + "claimGVK", claimGVK.String(), + "error", err) + + return matchingResources, nil + } + + c.logger.Debug("Found claims of target type", "count", len(claims)) + + // Filter claims that use this specific composition + for _, claim := range claims { + if c.resourceUsesComposition(claim, compositionName) { + matchingResources = append(matchingResources, claim) + } + } + + c.logger.Debug("Found resources (XRs and Claims) using composition", + "compositionName", compositionName, + "totalCount", len(matchingResources)) + + return matchingResources, nil +} + +// resourceUsesComposition checks if a resource (XR or Claim) uses the specified composition. +// Checks both v2 (spec.crossplane.compositionRef) and v1 (spec.compositionRef) paths since +// we don't have access to the XRD to determine which path structure is expected. +func (c *DefaultCompositionClient) resourceUsesComposition(resource *un.Unstructured, compositionName string) bool { + // Try both v1 and v2 paths - we use a non-v1 apiVersion to get both paths from getCrossplaneRefPaths + // since getCrossplaneRefPaths returns both paths for non-v1 XRDs + for _, path := range getCrossplaneRefPaths(CrossplaneAPIExtGroupV2, "compositionRef", "name") { + if refName, found, _ := un.NestedString(resource.Object, path...); found && refName == compositionName { + return true + } + } + + return false +} + +// findByRefs implements ref-based lookup for FindComposites: each ref is resolved against the +// composition's XR GVK first, then the claim GVK on 404 if the XRD defines a claim type. Refs that +// fail both lookups, or that exist but reference a different composition, are silently dropped from +// the result. NotFound responses are tolerated; other errors propagate. +// +// Converts the unstructured Composition to a typed *apiextensionsv1.Composition internally because +// resolveCompositeTypes needs spec.compositeTypeRef. Conversion errors propagate. +func (c *DefaultCompositionClient) findByRefs(ctx context.Context, comp *un.Unstructured, refs []k8stypes.NamespacedName) ([]*un.Unstructured, error) { + if len(refs) == 0 { + return nil, nil + } + + typedComp := &apiextensionsv1.Composition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(comp.Object, typedComp); err != nil { + return nil, errors.Wrapf(err, "cannot convert composition %s to typed for refs lookup", comp.GetName()) + } + + types, err := c.resolveCompositeTypes(ctx, typedComp) + if err != nil { + return nil, err + } + + var matched []*un.Unstructured + + for _, n := range refs { + obj, err := c.lookupRef(ctx, n, types, comp.GetName()) + if err != nil { + return nil, err + } + + if obj != nil { + matched = append(matched, obj) + } + } + + return matched, nil +} + +// compositeTypes holds the GVKs needed to look up a composite by name. claimGVK is empty +// when the XRD has no claimNames or could not be retrieved (graceful degradation). +type compositeTypes struct { + xrGVK schema.GroupVersionKind + claimGVK schema.GroupVersionKind +} + +// resolveCompositeTypes derives the XR GVK from the composition's CompositeTypeRef and +// best-effort resolves the claim GVK from the XRD. XRD lookup failures and missing claimNames +// produce an empty claim GVK without erroring (graceful degradation). +func (c *DefaultCompositionClient) resolveCompositeTypes(ctx context.Context, comp *apiextensionsv1.Composition) (compositeTypes, error) { + gv, err := schema.ParseGroupVersion(comp.Spec.CompositeTypeRef.APIVersion) + if err != nil { + return compositeTypes{}, errors.Wrapf(err, "cannot parse compositeTypeRef apiVersion %q", comp.Spec.CompositeTypeRef.APIVersion) + } + + types := compositeTypes{ + xrGVK: schema.GroupVersionKind{ + Group: gv.Group, + Version: gv.Version, + Kind: comp.Spec.CompositeTypeRef.Kind, + }, + } + + xrd, xrdErr := c.definitionClient.GetXRDForXR(ctx, types.xrGVK) + + switch { + case xrdErr != nil: + c.logger.Debug("XRD lookup failed; skipping claim-GVK fallback for --resource lookups", + "xrGVK", types.xrGVK.String(), "error", xrdErr) + case xrd != nil: + gvk, err := c.getClaimTypeFromXRD(xrd) + if err != nil { + c.logger.Debug("could not extract claim type from XRD; skipping claim-GVK fallback", + "xrd", xrd.GetName(), "error", err) + } else { + types.claimGVK = gvk + } + } + + return types, nil +} + +// lookupRef resolves a single ref against (compName, types). Tries the XR GVK first; on miss +// (404 OR found-but-wrong-composition) falls through to the claim GVK if non-empty. Returns the +// matched object, or nil if neither GVK yielded a resource referencing compName. Non-NotFound +// cluster errors propagate. +// +// "Found-but-wrong-composition" falls through (rather than short-circuiting to nil) so a same-name +// XR + Claim collision in v2 namespaces resolves correctly: e.g. an XR `default/foo` (XBucket) +// using composition Y and a Claim `default/foo` (Bucket) using composition X both exist; a +// `--resource=default/foo` lookup against composition X must reach the Claim via the claim-GVK +// path even though the XR GET succeeds first. +func (c *DefaultCompositionClient) lookupRef(ctx context.Context, n k8stypes.NamespacedName, types compositeTypes, compName string) (*un.Unstructured, error) { + obj, err := c.tryLookupAtGVK(ctx, types.xrGVK, n, compName, "XR GVK") + if err != nil { + return nil, err + } + + if obj != nil { + return obj, nil + } + + if types.claimGVK.Empty() { + c.logger.Debug("ref not matched via XR GVK and no claim GVK to try", + "ref", n.String()) + + return nil, nil + } + + return c.tryLookupAtGVK(ctx, types.claimGVK, n, compName, "claim GVK") +} + +// tryLookupAtGVK fetches a single resource at (gvk, n.Namespace, n.Name) and returns it iff +// the resource exists AND references compName. Returns (nil, nil) for both 404 and +// found-but-wrong-composition — callers distinguish "missed at this GVK, try the next" from +// "matched at this GVK". Non-NotFound errors propagate. +func (c *DefaultCompositionClient) tryLookupAtGVK(ctx context.Context, gvk schema.GroupVersionKind, n k8stypes.NamespacedName, compName, kindLabel string) (*un.Unstructured, error) { + obj, err := c.resourceClient.GetResource(ctx, gvk, n.Namespace, n.Name) + + switch { + case apierrors.IsNotFound(err): + c.logger.Debug("ref not found at GVK", + "ref", n.String(), "gvk", gvk.String(), "via", kindLabel) + + return nil, nil + case err != nil: + // Render the ref the way the user typed it on the CLI ("foo" for cluster-scoped, + // "ns/foo" for namespaced) — n.String() always renders "/foo" for cluster-scoped, + // which contradicts the documented --resource format in user-facing errors. + return nil, errors.Wrapf(err, "cannot fetch composite %s as %s", ref.Format(n), gvk) + } + + if c.resourceUsesComposition(obj, compName) { + c.logger.Debug("matched ref", + "ref", n.String(), "composition", compName, "via", kindLabel) + + return obj, nil + } + + c.logger.Debug("ref exists at GVK but does not reference this composition", + "ref", n.String(), "gvk", gvk.String(), "composition", compName, "via", kindLabel) + + return nil, nil +} + +// FindComposites dispatches to ref-based or listing-based discovery based on opts.Refs. +func (c *DefaultCompositionClient) FindComposites(ctx context.Context, comp *un.Unstructured, opts dtypes.FindCompositesOptions) ([]*un.Unstructured, error) { + switch { + case len(opts.Refs) > 0: + return c.findByRefs(ctx, comp, opts.Refs) + default: + return c.findByListing(ctx, comp.GetName(), opts.Namespace) + } +} diff --git a/internal/diff/client/crossplane/composition_client_test.go b/internal/diff/client/crossplane/composition_client_test.go new file mode 100644 index 00000000..141911e1 --- /dev/null +++ b/internal/diff/client/crossplane/composition_client_test.go @@ -0,0 +1,2271 @@ +package crossplane + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +var _ CompositionClient = (*tu.MockCompositionClient)(nil) + +func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { + type fields struct { + compositions map[string]*apiextensionsv1.Composition + } + + type args struct { + ctx context.Context + res *un.Unstructured + } + + type want struct { + composition *apiextensionsv1.Composition + // revisionName is the CompositionRevision the composition comes from; empty when none applies. + revisionName string + err error + } + + // Create test compositions + matchingComp := tu.NewComposition("matching-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + + nonMatchingComp := tu.NewComposition("non-matching-comp"). + WithCompositeTypeRef("example.org/v1", "OtherXR"). + Build() + + referencedComp := tu.NewComposition("referenced-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + + incompatibleComp := tu.NewComposition("incompatible-comp"). + WithCompositeTypeRef("example.org/v1", "OtherXR"). + Build() + + labeledComp := func() *apiextensionsv1.Composition { + comp := tu.NewComposition("labeled-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + comp.SetLabels(map[string]string{ + "environment": "production", + "tier": "standard", + }) + + return comp + }() + + aComp := func() *apiextensionsv1.Composition { + comp := tu.NewComposition("a-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + comp.SetLabels(map[string]string{ + "environment": "production", + }) + + return comp + }() + + bComp := func() *apiextensionsv1.Composition { + comp := tu.NewComposition("b-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + comp.SetLabels(map[string]string{ + "environment": "production", + }) + + return comp + }() + + versionMismatchComp := tu.NewComposition("version-mismatch-comp"). + WithCompositeTypeRef("example.org/v2", "XR1"). + Build() + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + mockDef tu.MockDefinitionClient + fields fields + args args + want want + }{ + "NoMatchingComposition": { + reason: "Should return error when no matching composition exists", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "non-matching-comp": nonMatchingComp, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + want: want{ + err: errors.Errorf("no composition found for %s", "example.org/v1, Kind=XR1"), + }, + }, + "MatchingComposition": { + reason: "Should return the matching composition", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": matchingComp, + "non-matching-comp": nonMatchingComp, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + want: want{ + composition: matchingComp, + }, + }, + "DirectCompositionReference": { + reason: "Should return the composition referenced by spec.compositionRef.name", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "default-comp": matchingComp, + "referenced-comp": referencedComp, + }, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedField(xr.Object, "referenced-comp", "spec", "compositionRef", "name") + + return xr + }(), + }, + want: want{ + composition: referencedComp, + }, + }, + "DirectCompositionReferenceResolvesRevision": { + reason: "Should return the composition from the referenced composition's revision, and that revision's name", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + tu.NewResource(CrossplaneAPIExtGroupV1, "CompositionRevision", "referenced-comp-abc1234"). + WithLabels(map[string]string{LabelCompositionName: "referenced-comp"}). + WithSpecField("revision", int64(1)). + WithSpecField("compositeTypeRef", map[string]any{"apiVersion": "example.org/v1", "kind": "XR1"}). + Build(), + }, LabelCompositionName, "referenced-comp"). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "referenced-comp": referencedComp, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{"name": "referenced-comp"}). + Build(), + }, + want: want{ + composition: referencedComp, + revisionName: "referenced-comp-abc1234", + }, + }, + "DirectCompositionReferenceIncompatible": { + reason: "Should return error when directly referenced composition is incompatible", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": matchingComp, + "incompatible-comp": incompatibleComp, + }, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedField(xr.Object, "incompatible-comp", "spec", "compositionRef", "name") + + return xr + }(), + }, + want: want{ + err: errors.Errorf("composition incompatible-comp is not compatible with example.org/v1, Kind=XR1"), + }, + }, + "ReferencedCompositionNotFound": { + reason: "Should return error when referenced composition doesn't exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "existing-comp": matchingComp, + }, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedField(xr.Object, "non-existent-comp", "spec", "compositionRef", "name") + + return xr + }(), + }, + want: want{ + err: errors.Errorf("composition non-existent-comp referenced in example.org/v1, Kind=XR1/my-xr not found"), + }, + }, + "CompositionSelectorMatch": { + reason: "Should return composition matching the selector labels", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "labeled-comp": labeledComp, + "non-matching-comp": nonMatchingComp, + }, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedStringMap(xr.Object, map[string]string{ + "environment": "production", + }, "spec", "compositionSelector", "matchLabels") + + return xr + }(), + }, + want: want{ + composition: labeledComp, + }, + }, + "CompositionSelectorNoMatch": { + reason: "Should return error when no composition matches the selector", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "labeled-comp": func() *apiextensionsv1.Composition { + comp := tu.NewComposition("labeled-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + comp.SetLabels(map[string]string{ + "environment": "staging", + }) + + return comp + }(), + }, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedStringMap(xr.Object, map[string]string{ + "environment": "production", + }, "spec", "compositionSelector", "matchLabels") + + return xr + }(), + }, + want: want{ + err: errors.Errorf("no compatible composition found matching labels map[environment:production] for example.org/v1, Kind=XR1/my-xr"), + }, + }, + "MultipleCompositionMatches": { + reason: "Should return an error when multiple compositions match the selector", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "a-comp": aComp, + "b-comp": bComp, + }, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedStringMap(xr.Object, map[string]string{ + "environment": "production", + }, "spec", "compositionSelector", "matchLabels") + + return xr + }(), + }, + want: want{ + err: errors.New("ambiguous composition selection: multiple compositions match"), + }, + }, + "EmptyCompositionCache_DefaultLookup": { + reason: "Should return error when composition cache is empty (default lookup)", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{}, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + want: want{ + err: errors.Errorf("no composition found for %s", "example.org/v1, Kind=XR1"), + }, + }, + "EmptyCompositionCache_DirectReference": { + reason: "Should return error when composition cache is empty (direct reference)", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{}, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedField(xr.Object, "referenced-comp", "spec", "compositionRef", "name") + + return xr + }(), + }, + want: want{ + err: errors.Errorf("composition referenced-comp referenced in example.org/v1, Kind=XR1/my-xr not found"), + }, + }, + "EmptyCompositionCache_Selector": { + reason: "Should return error when composition cache is empty (selector)", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{}, + }, + args: args{ + ctx: t.Context(), + res: func() *un.Unstructured { + xr := tu.NewResource("example.org/v1", "XR1", "my-xr").Build() + _ = un.SetNestedStringMap(xr.Object, map[string]string{ + "environment": "production", + }, "spec", "compositionSelector", "matchLabels") + + return xr + }(), + }, + want: want{ + err: errors.Errorf("no compatible composition found matching labels map[environment:production] for example.org/v1, Kind=XR1/my-xr"), + }, + }, + "AmbiguousDefaultSelection": { + reason: "Should return error when multiple compositions match by type but no selection criteria provided", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "comp1": matchingComp, + "comp2": referencedComp, // Both match same XR type + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + want: want{ + err: errors.New("ambiguous composition selection: multiple compositions exist for example.org/v1, Kind=XR1"), + }, + }, + "DifferentVersions": { + reason: "Should not match compositions with different versions", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV1XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "version-mismatch-comp": versionMismatchComp, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + want: want{ + err: errors.Errorf("no composition found for %s", "example.org/v1, Kind=XR1"), + }, + }, + "ClaimResource": { + reason: "Should find composition for a claim type by determining XR type from XRD", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + // Set up to return XRDs when requested + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == CompositeResourceDefinitionKind { + return []*un.Unstructured{ + tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xexampleresources.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "ExampleResourceClaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "referenceable": false, + }, + map[string]any{ + "name": "v2", + "served": true, + "referenceable": true, // This is the version compositions should reference + }, + map[string]any{ + "name": "v3alpha1", + "served": true, + "referenceable": false, + }, + }).Build(), + }, nil + } + + return []*un.Unstructured{}, nil + }). + WithResourcesFoundByLabel([]*un.Unstructured{}, LabelCompositionName, "matching-comp"). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithXRDForClaim( + tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xexampleresources.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "ExampleResourceClaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "referenceable": false, + }, + map[string]any{ + "name": "v2", + "served": true, + "referenceable": true, // This is the version compositions should reference + }, + map[string]any{ + "name": "v3alpha1", + "served": true, + "referenceable": false, + }, + }).Build(), + ). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": { + ObjectMeta: metav1.ObjectMeta{ + Name: "matching-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v2", // Match the referenceable version v2 + Kind: "XExampleResource", + }, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "ExampleResourceClaim", "test-claim"). + WithSpecField("compositionRef", map[string]any{ + "name": "matching-comp", + }). + Build(), + }, + want: want{ + composition: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "matching-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v2", + Kind: "XExampleResource", + }, + }, + }, + err: nil, + }, + }, + "ClaimResourceWithNoReferenceableVersion": { + reason: "Should return error when XRD has no referenceable version", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + // Return XRDs when requested - but this one has NO referenceable version + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == CompositeResourceDefinitionKind { + return []*un.Unstructured{ + tu.NewResource( + CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xexampleresources.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "ExampleResourceClaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "referenceable": false, // No referenceable version + }, + map[string]any{ + "name": "v2", + "served": true, + "referenceable": false, // No referenceable version + }, + }).Build(), + }, nil + } + + return []*un.Unstructured{}, nil + }). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithXRDForClaim( + tu.NewResource( + CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xexampleresources.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "ExampleResourceClaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "referenceable": false, // No referenceable version + }, + map[string]any{ + "name": "v2", + "served": true, + "referenceable": false, // No referenceable version + }, + }).Build(), + ).Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": { + ObjectMeta: metav1.ObjectMeta{ + Name: "matching-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XExampleResource", + }, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v1", "ExampleResourceClaim", "test-claim"). + WithSpecField("compositionRef", map[string]any{ + "name": "matching-comp", + }). + Build(), + }, + want: want{ + composition: nil, + err: errors.New("no referenceable version found in XRD"), // Should fail with this error + }, + }, + // TODO: add more tests against v2 XRDs + "ResourceWithV2XRD": { + reason: "Should determine path to compositionRef by determining XR type from XRD", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + // Set up to return XRDs when requested + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == CompositeResourceDefinitionKind { + return []*un.Unstructured{ + tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xexampleresources.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "referenceable": false, + }, + map[string]any{ + "name": "v2", + "served": true, + "referenceable": true, // This is the version compositions should reference + }, + map[string]any{ + "name": "v3alpha1", + "served": true, + "referenceable": false, + }, + }).Build(), + }, nil + } + + return []*un.Unstructured{}, nil + }). + WithResourcesFoundByLabel([]*un.Unstructured{}, LabelCompositionName, "matching-comp"). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithV2XRDForXR(). + Build(), + fields: fields{ + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": { + ObjectMeta: metav1.ObjectMeta{ + Name: "matching-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v2", // Match the referenceable version v2 + Kind: "XExampleResource", + }, + }, + }, + }, + }, + args: args{ + ctx: t.Context(), + res: tu.NewResource("example.org/v2", "XExampleResource", "my-xr"). + WithSpecField("crossplane", map[string]any{ + "compositionRef": map[string]any{ + "name": "matching-comp", + }, + }). + Build(), + }, + want: want{ + composition: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "matching-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v2", + Kind: "XExampleResource", + }, + }, + }, + err: nil, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create the CompositionClient + c := &DefaultCompositionClient{ + resourceClient: &tt.mockResource, + definitionClient: &tt.mockDef, + revisionClient: NewCompositionRevisionClient(&tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: tt.fields.compositions, + } + + // Test the FindMatchingComposition method + got, err := c.FindMatchingComposition(tt.args.ctx, tt.args.res) + + if tt.want.err != nil { + if err == nil { + t.Errorf("\n%s\nFindMatchingComposition(...): expected error but got none", tt.reason) + return + } + + if !strings.Contains(err.Error(), tt.want.err.Error()) { + t.Errorf("\n%s\nFindMatchingComposition(...): expected error containing %q, got %q", + tt.reason, tt.want.err.Error(), err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nFindMatchingComposition(...): unexpected error: %v", tt.reason, err) + return + } + + if tt.want.composition != nil { + if diff := cmp.Diff(tt.want.composition.Name, got.Composition.Name); diff != "" { + t.Errorf("\n%s\nFindMatchingComposition(...): -want composition name, +got composition name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.want.composition.Spec.CompositeTypeRef, got.Composition.Spec.CompositeTypeRef); diff != "" { + t.Errorf("\n%s\nFindMatchingComposition(...): -want composition type ref, +got composition type ref:\n%s", tt.reason, diff) + } + } + + if diff := cmp.Diff(tt.want.revisionName, got.RevisionName); diff != "" { + t.Errorf("\n%s\nFindMatchingComposition(...): -want revision name, +got revision name:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionClient_GetComposition(t *testing.T) { + ctx := t.Context() + + // Create a test composition + testComp := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + + // Mock resource client + mockResource := tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == "Composition" && name == "test-comp" { + u := &un.Unstructured{} + u.SetGroupVersionKind(gvk) + u.SetName(name) + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(testComp) + if err != nil { + return nil, err + } + + u.SetUnstructuredContent(obj) + + return u, nil + } + + return nil, errors.New("composition not found") + }). + Build() + + tests := map[string]struct { + reason string + name string + cache map[string]*apiextensionsv1.Composition + expectComp *apiextensionsv1.Composition + expectError bool + }{ + "CachedComposition": { + reason: "Should return composition from cache when available", + name: "cached-comp", + cache: map[string]*apiextensionsv1.Composition{ + "cached-comp": testComp, + }, + expectComp: testComp, + expectError: false, + }, + "FetchFromCluster": { + reason: "Should fetch composition from cluster when not in cache", + name: "test-comp", + cache: map[string]*apiextensionsv1.Composition{}, + expectComp: testComp, + expectError: false, + }, + "NotFound": { + reason: "Should return error when composition doesn't exist", + name: "nonexistent-comp", + cache: map[string]*apiextensionsv1.Composition{}, + expectComp: nil, + expectError: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: mockResource, + revisionClient: NewCompositionRevisionClient(mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: tt.cache, + } + + comp, err := c.GetComposition(ctx, tt.name) + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nGetComposition(...): expected error but got none", tt.reason) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetComposition(...): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.expectComp.GetName(), comp.GetName()); diff != "" { + t.Errorf("\n%s\nGetComposition(...): -want name, +got name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.expectComp.Spec.CompositeTypeRef, comp.Spec.CompositeTypeRef); diff != "" { + t.Errorf("\n%s\nGetComposition(...): -want type ref, +got type ref:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionClient_ListCompositions(t *testing.T) { + ctx := t.Context() + + // Create test compositions + comp1 := tu.NewComposition("comp1"). + WithCompositeTypeRef("example.org/v1", "XR1"). + Build() + comp2 := tu.NewComposition("comp2"). + WithCompositeTypeRef("example.org/v1", "XR2"). + Build() + + // Convert compositions to unstructured + u1 := &un.Unstructured{} + obj1, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(comp1) + u1.SetUnstructuredContent(obj1) + u1.SetGroupVersionKind(schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: "Composition", + }) + + u2 := &un.Unstructured{} + obj2, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(comp2) + u2.SetUnstructuredContent(obj2) + u2.SetGroupVersionKind(schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: "Composition", + }) + + tests := map[string]struct { + reason string + mockResource *tu.MockResourceClient + expectComps []*apiextensionsv1.Composition + expectError bool + errorContains string + }{ + "SuccessfulList": { + reason: "Should return compositions when list succeeds", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == "Composition" { + return []*un.Unstructured{u1, u2}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + expectComps: []*apiextensionsv1.Composition{comp1, comp2}, + expectError: false, + }, + "ListError": { + reason: "Should return error when list fails", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + expectComps: nil, + expectError: true, + errorContains: "cannot list compositions", + }, + "ConversionError": { + reason: "Should return error when conversion fails", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + // Create an invalid unstructured that will definitely fail conversion + invalid := &un.Unstructured{} + invalid.SetGroupVersionKind(gvk) + invalid.SetName("invalid") + + // Include invalid data that won't convert to a Composition + invalid.Object["spec"] = "not-a-map-but-a-string" // This will cause conversion to fail + + return []*un.Unstructured{invalid}, nil + }). + Build(), + expectComps: nil, + expectError: true, + errorContains: "cannot convert unstructured to Composition", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: tt.mockResource, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: make(map[string]*apiextensionsv1.Composition), + } + + comps, err := c.ListCompositions(ctx) + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nListCompositions(...): expected error but got none", tt.reason) + } else if tt.errorContains != "" && !strings.Contains(err.Error(), tt.errorContains) { + t.Errorf("\n%s\nListCompositions(...): expected error containing %q, got %q", + tt.reason, tt.errorContains, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nListCompositions(...): unexpected error: %v", tt.reason, err) + return + } + + if len(comps) != len(tt.expectComps) { + t.Errorf("\n%s\nListCompositions(...): expected %d compositions, got %d", + tt.reason, len(tt.expectComps), len(comps)) + + return + } + + // Check that we got the expected compositions + for i, expected := range tt.expectComps { + found := false + + for _, actual := range comps { + if actual.GetName() == expected.GetName() { + found = true + break + } + } + + if !found { + t.Errorf("\n%s\nListCompositions(...): composition %s not found in result", + tt.reason, tt.expectComps[i].GetName()) + } + } + }) + } +} + +func TestDefaultCompositionClient_Initialize(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + reason string + mockResource *tu.MockResourceClient + expectError bool + }{ + "SuccessfulInitialization": { + reason: "Should successfully initialize the client", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{{Group: CrossplaneAPIExtGroup, Kind: "Composition"}}). + WithEmptyListResources(). + Build(), + expectError: false, + }, + "ResourceClientInitFailed": { + reason: "Should return error when resource client initialization fails", + mockResource: tu.NewMockResourceClient(). + WithInitialize(func(_ context.Context) error { + return errors.New("init error") + }). + Build(), + expectError: true, + }, + "ListCompositionsFailed": { + reason: "Should return error when listing compositions fails", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + expectError: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: tt.mockResource, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: make(map[string]*apiextensionsv1.Composition), + } + + err := c.Initialize(ctx) + + if tt.expectError && err == nil { + t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) + } else if !tt.expectError && err != nil { + t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + } + }) + } +} + +func TestDefaultCompositionClient_ResolveCompositionFromRevisions(t *testing.T) { + ctx := t.Context() + + // Create test revisions + rev1 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + rev2 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev2", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 2, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Convert revisions to unstructured + toUnstructured := func(rev *apiextensionsv1.CompositionRevision) *un.Unstructured { + u := &un.Unstructured{} + obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(rev) + u.SetUnstructuredContent(obj) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: "CompositionRevision", + }) + + return u + } + + // Create test XRD + v1XRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xr1s.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XR1", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "referenceable": true, + }, + }).Build() + + v2XRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xr1s.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XR1", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v2", + "served": true, + "referenceable": true, + }, + }).Build() + + tests := map[string]struct { + reason string + xrd *un.Unstructured + res *un.Unstructured + compositionName string + mockResource *tu.MockResourceClient + // wantRevision names the revision selected; empty when none is, so the composition is used. + wantRevision string + expectError bool + errorPattern string + }{ + "AutomaticPolicyUsesLatestRevision": { + reason: "Should use latest revision when update policy is Automatic", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Automatic"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + wantRevision: "test-comp-rev2", + expectError: false, + }, + "ManualPolicyWithRevisionRefUsesSpecifiedRevision": { + reason: "Should use specified revision when update policy is Manual with revision ref", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionRevisionRef", map[string]any{ + "name": "test-comp-rev1", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if name == "test-comp-rev1" { + return toUnstructured(rev1), nil + } + + return nil, errors.New("not found") + }). + Build(), + wantRevision: "test-comp-rev1", + expectError: false, + }, + "ManualPolicyWithoutRevisionRefUsesLatestRevision": { + reason: "Should use latest revision when update policy is Manual without revision ref (net new XR case)", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + wantRevision: "test-comp-rev2", + expectError: false, + }, + "V2XRWithAutomaticPolicy": { + reason: "Should use latest revision for v2 XR with Automatic policy", + xrd: v2XRD, + res: tu.NewResource("example.org/v2", "XR1", "my-xr"). + WithSpecField("crossplane", map[string]any{ + "compositionRef": map[string]any{ + "name": "test-comp", + }, + "compositionUpdatePolicy": "Automatic", + }). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + wantRevision: "test-comp-rev2", + expectError: false, + }, + "V2XRWithManualPolicyWithoutRevisionRef": { + reason: "Should use latest revision for v2 XR with Manual policy but no revision ref", + xrd: v2XRD, + res: tu.NewResource("example.org/v2", "XR1", "my-xr"). + WithSpecField("crossplane", map[string]any{ + "compositionRef": map[string]any{ + "name": "test-comp", + }, + "compositionUpdatePolicy": "Manual", + }). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + wantRevision: "test-comp-rev2", + expectError: false, + }, + "NoRevisionsFoundFallsBackToNil": { + reason: "Should return nil when no revisions exist (unpublished composition)", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Automatic"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{}, LabelCompositionName, "test-comp"). + Build(), + expectError: false, + }, + // Bug B (issue #388): an Automatic XR with a compositionRevisionSelector that matches none of + // the composition's revisions must fail loudly rather than silently render against the newest + // (non-matching) revision. This proves the resolve path actually consults the selector. + "AutomaticPolicyWithNonMatchingRevisionSelectorErrors": { + reason: "Should error when the compositionRevisionSelector matches no revision", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Automatic"). + WithSpecField("compositionRevisionSelector", map[string]any{ + "matchLabels": map[string]any{"channel": "nonexistent"}, + }). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + expectError: true, + errorPattern: "match selector", + }, + "ManualPolicyWithNonexistentRevisionRef": { + reason: "Should return error when specified revision doesn't exist", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionRevisionRef", map[string]any{ + "name": "nonexistent-rev", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, _ string) (*un.Unstructured, error) { + return nil, errors.New("not found") + }). + Build(), + expectError: true, + errorPattern: "cannot get pinned composition revision", + }, + "ManualPolicyWithRevisionFromDifferentComposition": { + reason: "Should return error when revision belongs to different composition", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]any{ + "name": "test-comp", + }). + WithSpecField("compositionRevisionRef", map[string]any{ + "name": "other-comp-rev1", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if name == "other-comp-rev1" { + wrongRev := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "other-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + return toUnstructured(wrongRev), nil + } + + return nil, errors.New("not found") + }). + Build(), + expectError: true, + errorPattern: "belongs to composition other-comp, not test-comp", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: tt.mockResource, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: make(map[string]*apiextensionsv1.Composition), + } + + revision, err := c.resolveCompositionFromRevisions(ctx, tt.xrd, tt.res, tt.compositionName, "test-resource-id") + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): expected error but got none", tt.reason) + return + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): expected error containing %q, got %q", + tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): unexpected error: %v", tt.reason, err) + return + } + + gotRevision := "" + if revision != nil { + gotRevision = revision.GetName() + } + + if diff := cmp.Diff(tt.wantRevision, gotRevision); diff != "" { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): -want revision, +got revision:\n%s", tt.reason, diff) + } + }) + } +} + +func TestGetCrossplaneRefPaths(t *testing.T) { + tests := map[string]struct { + reason string + apiVersion string + path []string + want [][]string + }{ + "V1XRDReturnsOnlyV1Path": { + reason: "Should return only v1 path for v1 XRD", + apiVersion: "apiextensions.crossplane.io/v1", + path: []string{"compositionRef", "name"}, + want: [][]string{ + {"spec", "compositionRef", "name"}, + }, + }, + "V2XRDReturnsBothPaths": { + reason: "Should return both v2 and v1 paths for v2 XRD (v2 first)", + apiVersion: "apiextensions.crossplane.io/v2", + path: []string{"compositionRef", "name"}, + want: [][]string{ + {"spec", "crossplane", "compositionRef", "name"}, + {"spec", "compositionRef", "name"}, + }, + }, + "NonCrossplaneAPIVersionReturnsBothPaths": { + reason: "Should return both paths for non-Crossplane API version (treated as v2)", + apiVersion: "example.org/v1", + path: []string{"compositionRef", "name"}, + want: [][]string{ + {"spec", "crossplane", "compositionRef", "name"}, + {"spec", "compositionRef", "name"}, + }, + }, + "CompositionSelector": { + reason: "Should handle compositionSelector path correctly", + apiVersion: "apiextensions.crossplane.io/v2", + path: []string{"compositionSelector", "matchLabels"}, + want: [][]string{ + {"spec", "crossplane", "compositionSelector", "matchLabels"}, + {"spec", "compositionSelector", "matchLabels"}, + }, + }, + "CompositionUpdatePolicy": { + reason: "Should handle compositionUpdatePolicy path correctly", + apiVersion: "apiextensions.crossplane.io/v2", + path: []string{"compositionUpdatePolicy"}, + want: [][]string{ + {"spec", "crossplane", "compositionUpdatePolicy"}, + {"spec", "compositionUpdatePolicy"}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := getCrossplaneRefPaths(tt.apiVersion, tt.path...) + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("\n%s\ngetCrossplaneRefPaths(%q, %v): -want, +got:\n%s", + tt.reason, tt.apiVersion, tt.path, diff) + } + }) + } +} + +func TestDefaultCompositionClient_V2XRDWithV1StylePaths(t *testing.T) { + // This test verifies the fix for issue #206 - v2 XRDs using v1-style composition paths. + // When an XRD is v2, but the XR specifies composition fields at v1-style paths + // (e.g., spec.compositionRef instead of spec.crossplane.compositionRef), + // the client should still find them. + matchingComp := tu.NewComposition("matching-comp"). + WithCompositeTypeRef("example.org/v1", "XExampleResource"). + Build() + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + mockDef tu.MockDefinitionClient + compositions map[string]*apiextensionsv1.Composition + res *un.Unstructured + wantComp *apiextensionsv1.Composition + wantErr string + }{ + "V2XRDWithV1StyleCompositionRef": { + reason: "Should find composition when v2 XRD uses v1-style spec.compositionRef path", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV2XRDForXR(). + Build(), + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": matchingComp, + }, + res: tu.NewResource("example.org/v1", "XExampleResource", "my-xr"). + // v1-style path: spec.compositionRef (not spec.crossplane.compositionRef) + WithSpecField("compositionRef", map[string]any{ + "name": "matching-comp", + }). + Build(), + wantComp: matchingComp, + }, + "V2XRDWithV1StyleCompositionSelector": { + reason: "Should find composition when v2 XRD uses v1-style spec.compositionSelector path", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + mockDef: *tu.NewMockDefinitionClient(). + WithSuccessfulInitialize(). + WithEmptyXRDsFetch(). + WithV2XRDForXR(). + Build(), + compositions: map[string]*apiextensionsv1.Composition{ + "matching-comp": func() *apiextensionsv1.Composition { + comp := tu.NewComposition("matching-comp"). + WithCompositeTypeRef("example.org/v1", "XExampleResource"). + Build() + comp.SetLabels(map[string]string{ + "environment": "production", + }) + + return comp + }(), + }, + res: tu.NewResource("example.org/v1", "XExampleResource", "my-xr"). + // v1-style path: spec.compositionSelector (not spec.crossplane.compositionSelector) + WithSpecField("compositionSelector", map[string]any{ + "matchLabels": map[string]any{ + "environment": "production", + }, + }). + Build(), + wantComp: func() *apiextensionsv1.Composition { + comp := tu.NewComposition("matching-comp"). + WithCompositeTypeRef("example.org/v1", "XExampleResource"). + Build() + comp.SetLabels(map[string]string{ + "environment": "production", + }) + + return comp + }(), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: &tt.mockResource, + definitionClient: &tt.mockDef, + revisionClient: NewCompositionRevisionClient(&tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: tt.compositions, + } + + got, err := c.FindMatchingComposition(t.Context(), tt.res) + + if tt.wantErr != "" { + if err == nil { + t.Errorf("\n%s\nFindMatchingComposition(...): expected error containing %q but got none", + tt.reason, tt.wantErr) + + return + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("\n%s\nFindMatchingComposition(...): expected error containing %q, got %q", + tt.reason, tt.wantErr, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nFindMatchingComposition(...): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.wantComp.Name, got.Composition.Name); diff != "" { + t.Errorf("\n%s\nFindMatchingComposition(...): -want composition name, +got:\n%s", + tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionClient_getClaimTypeFromXRD(t *testing.T) { + type args struct { + xrd *un.Unstructured + } + + type want struct { + gvk schema.GroupVersionKind + errMsg string + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "XRDWithClaimNames": { + reason: "Should extract claim GVK from XRD with claimNames", + args: args{ + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "test-xrd"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XTestResource", + "plural": "xtestresources", + "singular": "xtestresource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "TestClaim", + "plural": "testclaims", + "singular": "testclaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1alpha1", + "referenceable": true, + "served": true, + }, + }). + Build(), + }, + want: want{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "TestClaim", + }, + }, + }, + "XRDWithoutClaimNames": { + reason: "Should return empty GVK when XRD doesn't define claims", + args: args{ + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "test-xrd"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XTestResource", + "plural": "xtestresources", + "singular": "xtestresource", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1alpha1", + "referenceable": true, + "served": true, + }, + }). + Build(), + }, + want: want{ + gvk: schema.GroupVersionKind{}, // empty GVK + }, + }, + "XRDWithClaimNamesButMissingKind": { + reason: "Should return error when claimNames exists but kind is missing", + args: args{ + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "test-xrd"). + WithSpecField("group", "example.org"). + WithSpecField("claimNames", map[string]any{ + "plural": "testclaims", + "singular": "testclaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1alpha1", + "referenceable": true, + "served": true, + }, + }). + Build(), + }, + want: want{ + errMsg: "missing kind", + }, + }, + "XRDWithNoReferenceableVersion": { + reason: "Should return error when no referenceable version found", + args: args{ + xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "test-xrd"). + WithSpecField("group", "example.org"). + WithSpecField("claimNames", map[string]any{ + "kind": "TestClaim", + "plural": "testclaims", + "singular": "testclaim", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1alpha1", + "referenceable": false, + "served": true, + }, + }). + Build(), + }, + want: want{ + errMsg: "no referenceable version", + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + client := &DefaultCompositionClient{ + logger: tu.TestLogger(t, false), + } + + got, err := client.getClaimTypeFromXRD(tt.args.xrd) + + if tt.want.errMsg != "" { + if err == nil { + t.Errorf("\n%s\ngetClaimTypeFromXRD(): expected error containing %q but got none", tt.reason, tt.want.errMsg) + return + } + + if !strings.Contains(err.Error(), tt.want.errMsg) { + t.Errorf("\n%s\ngetClaimTypeFromXRD(): expected error containing %q, got %q", tt.reason, tt.want.errMsg, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\ngetClaimTypeFromXRD(): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.want.gvk, got); diff != "" { + t.Errorf("\n%s\ngetClaimTypeFromXRD(): -want GVK, +got GVK:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionClient_FindComposites_WithRefs(t *testing.T) { + // Composition targeting (example.org/v1, XBucket). FindComposites takes the unstructured form. + comp := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XBucket"). + BuildAsUnstructured() + + // XRD with claim "Bucket" (so claim GVK is example.org/v1, Bucket). + xrd := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket"). + WithPlural("xbuckets"). + WithClaimNames("Bucket", "buckets"). + WithVersion("v1", true, true). + BuildAsUnstructured() + + // XRD with no claim type (just XR). + xrdNoClaim := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket"). + WithPlural("xbuckets"). + WithVersion("v1", true, true). + BuildAsUnstructured() + + // Cluster-scoped XR named "xr-cluster", refs comp via v1 path. + xrCluster := tu.NewResource("example.org/v1", "XBucket", "xr-cluster"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + // Namespaced claim "claim-ns/claim-1", refs comp via v1 path. + claim := tu.NewResource("example.org/v1", "Bucket", "claim-1"). + InNamespace("claim-ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + // XR that uses a DIFFERENT composition. + xrWrongComp := tu.NewResource("example.org/v1", "XBucket", "xr-wrong"). + WithSpecField("compositionRef", map[string]any{"name": "some-other-comp"}). + Build() + + tests := map[string]struct { + reason string + mockResource *tu.MockResourceClient + mockDef *tu.MockDefinitionClient + comp *un.Unstructured + refs []k8stypes.NamespacedName + wantMatched []string // names of matched composites + wantErr bool + }{ + "XRGVKHit_ClusterScoped": { + reason: "Cluster-scoped XR with matching composition is matched via XR-GVK lookup", + mockResource: tu.NewMockResourceClient().WithResourcesExist(xrCluster).Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Name: "xr-cluster"}}, + wantMatched: []string{"xr-cluster"}, + }, + "ClaimGVKHit_NamespacedClaim": { + reason: "Claim found via claim-GVK fallback when XR-GVK 404s", + mockResource: tu.NewMockResourceClient().WithResourcesExist(claim).Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Namespace: "claim-ns", Name: "claim-1"}}, + wantMatched: []string{"claim-1"}, + }, + "BothLookupsNotFound_Unmatched": { + reason: "Returned in unmatched when neither XR-GVK nor claim-GVK lookup succeeds", + mockResource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Namespace: "claim-ns", Name: "ghost"}}, + }, + "FoundButUsesDifferentComposition_Unmatched": { + reason: "Resource exists but its compositionRef points to a different composition", + mockResource: tu.NewMockResourceClient().WithResourcesExist(xrWrongComp).Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Name: "xr-wrong"}}, + }, + "TransportErrorPropagated": { + reason: "Non-NotFound errors from GetResource propagate up", + mockResource: tu.NewMockResourceClient().WithGetResourceError(errors.New("connection refused")).Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Name: "x"}}, + wantErr: true, + }, + "NoClaimType_OnlyXRLookupAttempted": { + reason: "When XRD has no claimNames, claim-GVK lookup is skipped without crashing", + mockResource: tu.NewMockResourceClient().WithResourcesExist(xrCluster).Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrdNoClaim).Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Name: "xr-cluster"}, {Namespace: "x", Name: "ghost"}}, + wantMatched: []string{"xr-cluster"}, + }, + "XRDNotFound_OnlyXRLookupAttempted": { + reason: "When XRD itself is missing, claim-GVK lookup is skipped", + mockResource: tu.NewMockResourceClient().WithResourcesExist(xrCluster).Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXRNotFound().Build(), + comp: comp, + refs: []k8stypes.NamespacedName{{Name: "xr-cluster"}}, + wantMatched: []string{"xr-cluster"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: tt.mockResource, + definitionClient: tt.mockDef, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + } + + matched, err := c.FindComposites(t.Context(), tt.comp, dtypes.FindCompositesOptions{Refs: tt.refs}) + + if tt.wantErr { + if err == nil { + t.Fatalf("\n%s: expected error, got matched=%v", tt.reason, matched) + } + + return + } + + if err != nil { + t.Fatalf("\n%s: unexpected error: %v", tt.reason, err) + } + + var gotMatchedNames []string + for _, m := range matched { + gotMatchedNames = append(gotMatchedNames, m.GetName()) + } + + if diff := cmp.Diff(tt.wantMatched, gotMatchedNames); diff != "" { + t.Errorf("\n%s: matched mismatch:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionClient_FindComposites_DefaultDiscovery(t *testing.T) { + // FindComposites takes the unstructured composition; the typed copy is just for + // seeding the client's GetComposition cache (which findByListing reads through). + compBuilder := tu.NewComposition("test-comp").WithCompositeTypeRef("example.org/v1", "XBucket") + comp := compBuilder.BuildAsUnstructured() + compTyped := compBuilder.Build() + + xrd := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket"). + WithPlural("xbuckets"). + WithClaimNames("Bucket", "buckets"). + WithVersion("v1", true, true). + BuildAsUnstructured() + + xrCluster := tu.NewResource("example.org/v1", "XBucket", "xr-cluster"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"} + + c := &DefaultCompositionClient{ + resourceClient: tu.NewMockResourceClient(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk == xrGVK { + return []*un.Unstructured{xrCluster}, nil + } + // Claim listing returns empty (XRD has Bucket claim type but none exist). + return []*un.Unstructured{}, nil + }). + Build(), + definitionClient: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + compositions: map[string]*apiextensionsv1.Composition{"test-comp": compTyped}, + logger: tu.TestLogger(t, false), + } + + got, err := c.FindComposites(t.Context(), comp, dtypes.FindCompositesOptions{}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(got) != 1 || got[0].GetName() != "xr-cluster" { + t.Errorf("DefaultDiscovery: expected [xr-cluster], got %v", got) + } +} + +func TestDefaultCompositionClient_resolveCompositeTypes(t *testing.T) { + xrdWithClaim := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket"). + WithPlural("xbuckets"). + WithClaimNames("Bucket", "buckets"). + WithVersion("v1", true, true). + BuildAsUnstructured() + + xrdNoClaim := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket"). + WithPlural("xbuckets"). + WithVersion("v1", true, true). + BuildAsUnstructured() + + wantXRGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"} + wantClaimGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"} + + tests := map[string]struct { + reason string + comp *apiextensionsv1.Composition + mockDef *tu.MockDefinitionClient + wantXR schema.GroupVersionKind + wantClaim schema.GroupVersionKind + wantErr bool + wantErrMatch string + }{ + "ValidComp_XRDWithClaim": { + reason: "Composition with valid compositeTypeRef and XRD with claim names → both GVKs populated", + comp: tu.NewComposition("c").WithCompositeTypeRef("example.org/v1", "XBucket").Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrdWithClaim).Build(), + wantXR: wantXRGVK, + wantClaim: wantClaimGVK, + }, + "ValidComp_XRDNoClaim": { + reason: "Composition with valid compositeTypeRef and XRD without claim names → XR GVK populated, claim GVK empty", + comp: tu.NewComposition("c").WithCompositeTypeRef("example.org/v1", "XBucket").Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrdNoClaim).Build(), + wantXR: wantXRGVK, + wantClaim: schema.GroupVersionKind{}, + }, + "ValidComp_XRDLookupFails": { + reason: "Composition with valid compositeTypeRef but XRD lookup fails → XR GVK populated, claim GVK empty, no error", + comp: tu.NewComposition("c").WithCompositeTypeRef("example.org/v1", "XBucket").Build(), + mockDef: tu.NewMockDefinitionClient().WithXRDForXRNotFound().Build(), + wantXR: wantXRGVK, + wantClaim: schema.GroupVersionKind{}, + }, + "MalformedAPIVersion": { + reason: "Composition with malformed compositeTypeRef.apiVersion → returns error", + comp: tu.NewComposition("c").WithCompositeTypeRef("not/a/valid/apiversion", "XBucket").Build(), + mockDef: tu.NewMockDefinitionClient().Build(), + wantErr: true, + wantErrMatch: "cannot parse compositeTypeRef apiVersion", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + definitionClient: tt.mockDef, + logger: tu.TestLogger(t, false), + } + + got, err := c.resolveCompositeTypes(t.Context(), tt.comp) + + if tt.wantErr { + if err == nil { + t.Fatalf("\n%s: expected error, got %+v", tt.reason, got) + } + + if tt.wantErrMatch != "" && !strings.Contains(err.Error(), tt.wantErrMatch) { + t.Errorf("\n%s: error %q must contain %q", tt.reason, err.Error(), tt.wantErrMatch) + } + + return + } + + if err != nil { + t.Fatalf("\n%s: unexpected error: %v", tt.reason, err) + } + + if got.xrGVK != tt.wantXR { + t.Errorf("\n%s: xrGVK = %v, want %v", tt.reason, got.xrGVK, tt.wantXR) + } + + if got.claimGVK != tt.wantClaim { + t.Errorf("\n%s: claimGVK = %v, want %v", tt.reason, got.claimGVK, tt.wantClaim) + } + }) + } +} + +func TestDefaultCompositionClient_lookupRef(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"} + claimGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"} + + xrMatched := tu.NewResource("example.org/v1", "XBucket", "xr"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + xrWrongComp := tu.NewResource("example.org/v1", "XBucket", "xr-wrong"). + WithSpecField("compositionRef", map[string]any{"name": "other-comp"}). + Build() + + claimMatched := tu.NewResource("example.org/v1", "Bucket", "claim"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + // Same-name collision: XR `ns/collision` (XBucket, other-comp) and Claim `ns/collision` + // (Bucket, test-comp) both exist. The lookup against test-comp must reach the Claim. + collisionXR := tu.NewResource("example.org/v1", "XBucket", "collision"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "other-comp"}). + Build() + collisionClaim := tu.NewResource("example.org/v1", "Bucket", "collision"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + tests := map[string]struct { + reason string + ref k8stypes.NamespacedName + types compositeTypes + mockResource *tu.MockResourceClient + wantName string // empty = expect nil result + wantErr bool + }{ + "XRMatch": { + reason: "Ref hits XR GVK and references target composition → returns object", + ref: k8stypes.NamespacedName{Name: "xr"}, + types: compositeTypes{xrGVK: xrGVK, claimGVK: claimGVK}, + mockResource: tu.NewMockResourceClient().WithResourcesExist(xrMatched).Build(), + wantName: "xr", + }, + "XRWrongComposition_ClaimAlso404_ReturnsNil": { + reason: "Ref hits XR GVK with wrong composition AND claim 404s → returns nil (XR was wrong-composition; falling through to claim found nothing)", + ref: k8stypes.NamespacedName{Name: "xr-wrong"}, + types: compositeTypes{xrGVK: xrGVK, claimGVK: claimGVK}, + mockResource: tu.NewMockResourceClient().WithResourcesExist(xrWrongComp).Build(), + wantName: "", + }, + "XRWrongComposition_ClaimMatchesTarget_FallsThroughAndReturnsClaim": { + reason: "Same-name XR + Claim collision: XR uses other-comp but Claim uses test-comp → falls through to claim path, returns Claim", + ref: k8stypes.NamespacedName{Namespace: "ns", Name: "collision"}, + types: compositeTypes{xrGVK: xrGVK, claimGVK: claimGVK}, + mockResource: tu.NewMockResourceClient().WithResourcesExist(collisionXR, collisionClaim).Build(), + wantName: "collision", + }, + "XR404_ClaimMatch": { + reason: "Ref XR-404, matches via claim GVK and references target composition → returns object", + ref: k8stypes.NamespacedName{Namespace: "ns", Name: "claim"}, + types: compositeTypes{xrGVK: xrGVK, claimGVK: claimGVK}, + mockResource: tu.NewMockResourceClient().WithResourcesExist(claimMatched).Build(), + wantName: "claim", + }, + "XR404_ClaimGVKEmpty": { + reason: "Ref XR-404, claim GVK is empty (XRD missing) → returns nil, no error", + ref: k8stypes.NamespacedName{Name: "ghost"}, + types: compositeTypes{xrGVK: xrGVK}, + mockResource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + wantName: "", + }, + "XR404_Claim404": { + reason: "Ref XR-404 and claim 404 → returns nil, no error", + ref: k8stypes.NamespacedName{Namespace: "ns", Name: "ghost"}, + types: compositeTypes{xrGVK: xrGVK, claimGVK: claimGVK}, + mockResource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + wantName: "", + }, + "XRTransportError_Propagates": { + reason: "Non-NotFound errors from GetResource on XR-GVK propagate up", + ref: k8stypes.NamespacedName{Name: "x"}, + types: compositeTypes{xrGVK: xrGVK, claimGVK: claimGVK}, + mockResource: tu.NewMockResourceClient().WithGetResourceError(errors.New("connection refused")).Build(), + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: tt.mockResource, + logger: tu.TestLogger(t, false), + } + + got, err := c.lookupRef(t.Context(), tt.ref, tt.types, "test-comp") + + if tt.wantErr { + if err == nil { + t.Fatalf("\n%s: expected error, got %+v", tt.reason, got) + } + + return + } + + if err != nil { + t.Fatalf("\n%s: unexpected error: %v", tt.reason, err) + } + + switch tt.wantName { + case "": + if got != nil { + t.Errorf("\n%s: expected nil, got %s", tt.reason, got.GetName()) + } + default: + if got == nil { + t.Fatalf("\n%s: expected %s, got nil", tt.reason, tt.wantName) + } + + if got.GetName() != tt.wantName { + t.Errorf("\n%s: name = %q, want %q", tt.reason, got.GetName(), tt.wantName) + } + } + }) + } +} + +func TestSentinelsSurviveWrapping(t *testing.T) { + tests := map[string]struct { + err error + want error + }{ + "NoMatchingComposition": { + err: errors.Wrap(errors.Errorf("%w for %s", ErrNoMatchingComposition, "example.org/v1, Kind=XR1"), "cannot get composition"), + want: ErrNoMatchingComposition, + }, + "NoCompositionRevisions": { + err: errors.Wrap(errors.Errorf("%w for composition %s", ErrNoCompositionRevisions, "comp"), "cannot resolve"), + want: ErrNoCompositionRevisions, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if !errors.Is(tt.err, tt.want) { + t.Errorf("errors.Is(%v, %v) = false, want true", tt.err, tt.want) + } + }) + } +} + +// TestDefaultCompositionClient_ConcurrentUse fetches compositions into the cache from several +// goroutines while others read the whole cache, as concurrent diffs do. Run it with -race. +func TestDefaultCompositionClient_ConcurrentUse(t *testing.T) { + ctx := t.Context() + + names := []string{"comp-a", "comp-b", "comp-c", "comp-d"} + + cluster := make([]*un.Unstructured, 0, len(names)) + for _, name := range names { + cluster = append(cluster, tu.NewComposition(name).WithCompositeTypeRef("example.org/v1", "XR1").BuildAsUnstructured()) + } + + c := &DefaultCompositionClient{ + resourceClient: tu.NewMockResourceClient().WithResourcesExist(cluster...).Build(), + logger: tu.TestLogger(t, false), + compositions: map[string]*apiextensionsv1.Composition{}, + } + + var wg sync.WaitGroup + for _, name := range names { + wg.Go(func() { + comp, err := c.GetComposition(ctx, name) + if err != nil { + t.Errorf("GetComposition(%q): %v", name, err) + return + } + + if comp.GetName() != name { + t.Errorf("GetComposition(%q) returned %q", name, comp.GetName()) + } + }) + wg.Go(func() { _ = c.cachedCompositions() }) + } + + wg.Wait() + + if got := len(c.cachedCompositions()); got != len(names) { + t.Errorf("cached compositions = %d, want %d", got, len(names)) + } +} diff --git a/internal/diff/client/crossplane/composition_revision_client.go b/internal/diff/client/crossplane/composition_revision_client.go new file mode 100644 index 00000000..51d0c7be --- /dev/null +++ b/internal/diff/client/crossplane/composition_revision_client.go @@ -0,0 +1,334 @@ +package crossplane + +import ( + "context" + "sort" + "sync" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" +) + +// Aliases rather than literals so these cannot drift from the values Crossplane's revision-creation +// controller actually stamps (NewCompositionRevision), which is the behaviour every use of them here +// is predicting or matching against. +const ( + // LabelCompositionName is the label key for the composition name on CompositionRevisions. + LabelCompositionName = apiextensionsv1.LabelCompositionName + // LabelCompositionHash is the label key for the composition hash on CompositionRevisions. The + // value is the composition hash truncated to 63 characters, Kubernetes' limit on a label value. + LabelCompositionHash = apiextensionsv1.LabelCompositionHash +) + +// CompositionRevisionClient handles operations related to CompositionRevisions. +type CompositionRevisionClient interface { + core.Initializable + + // GetCompositionRevision gets a composition revision by name + GetCompositionRevision(ctx context.Context, name string) (*apiextensionsv1.CompositionRevision, error) + + // ListCompositionRevisions lists all composition revisions in the cluster + ListCompositionRevisions(ctx context.Context) ([]*apiextensionsv1.CompositionRevision, error) + + // GetLatestRevisionForComposition finds the latest (highest-numbered) revision for a given + // composition whose labels match the provided selector. This mirrors how Crossplane selects a + // revision for an XR with an Automatic compositionUpdatePolicy: with a compositionRevisionSelector + // it picks the newest matching revision, otherwise the newest overall. A nil selector means no + // restriction (latest overall). + GetLatestRevisionForComposition(ctx context.Context, compositionName string, selector labels.Selector) (*apiextensionsv1.CompositionRevision, error) + + // GetCompositionFromRevision extracts a Composition from a CompositionRevision + GetCompositionFromRevision(revision *apiextensionsv1.CompositionRevision) *apiextensionsv1.Composition +} + +// DefaultCompositionRevisionClient implements CompositionRevisionClient. +type DefaultCompositionRevisionClient struct { + resourceClient kubernetes.ResourceClient + logger logging.Logger + + // cacheMu guards revisions and revisionsByComposition. + cacheMu sync.RWMutex + // Cache of composition revisions by name (for individual revision lookups) + revisions map[string]*apiextensionsv1.CompositionRevision + // Cache of revisions per composition (lazy-loaded on demand) + revisionsByComposition map[string][]*apiextensionsv1.CompositionRevision + gvks []schema.GroupVersionKind +} + +// NewCompositionRevisionClient creates a new DefaultCompositionRevisionClient. +func NewCompositionRevisionClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) CompositionRevisionClient { + return &DefaultCompositionRevisionClient{ + resourceClient: resourceClient, + logger: logger, + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } +} + +// Initialize prepares the composition revision client for use. +func (c *DefaultCompositionRevisionClient) Initialize(ctx context.Context) error { + c.logger.Debug("Initializing composition revision client") + + gvks, err := c.resourceClient.GetGVKsForGroupKind(ctx, CrossplaneAPIExtGroup, CompositionRevisionKind) + if err != nil { + return errors.Wrap(err, "cannot get CompositionRevision GVKs") + } + + c.gvks = gvks + + c.logger.Debug("Composition revision client initialized") + + return nil +} + +// listCompositionRevisionsForComposition lists composition revisions for a specific composition using label selector. +func (c *DefaultCompositionRevisionClient) listCompositionRevisionsForComposition(ctx context.Context, compositionName string) ([]*apiextensionsv1.CompositionRevision, error) { + c.logger.Debug("Listing composition revisions for composition", "compositionName", compositionName) + + // Define the composition revision GVK + gvk := schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: CompositionRevisionKind, + } + + // Use label selector to filter server-side + labelSelector := metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelCompositionName: compositionName, + }, + } + + unRevisions, err := c.resourceClient.GetResourcesByLabel(ctx, gvk, "", labelSelector) + if err != nil { + c.logger.Debug("Failed to list composition revisions", "compositionName", compositionName, "error", err) + return nil, errors.Wrapf(err, "cannot list composition revisions for composition %s", compositionName) + } + + // Convert unstructured to typed + revisions := make([]*apiextensionsv1.CompositionRevision, 0, len(unRevisions)) + for _, obj := range unRevisions { + rev := &apiextensionsv1.CompositionRevision{} + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, rev) + if err != nil { + c.logger.Debug("Failed to convert composition revision from unstructured", + "name", obj.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot convert unstructured to CompositionRevision") + } + + revisions = append(revisions, rev) + } + + c.logger.Debug("Successfully retrieved composition revisions", "compositionName", compositionName, "count", len(revisions)) + + return revisions, nil +} + +// ListCompositionRevisions lists all composition revisions in the cluster. +func (c *DefaultCompositionRevisionClient) ListCompositionRevisions(ctx context.Context) ([]*apiextensionsv1.CompositionRevision, error) { + c.logger.Debug("Listing composition revisions from cluster") + + // Define the composition revision GVK + gvk := schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: CompositionRevisionKind, + } + + // Get all composition revisions using the resource client + unRevisions, err := c.resourceClient.ListResources(ctx, gvk, "") + if err != nil { + c.logger.Debug("Failed to list composition revisions", "error", err) + return nil, errors.Wrap(err, "cannot list composition revisions from cluster") + } + + // Convert unstructured to typed + revisions := make([]*apiextensionsv1.CompositionRevision, 0, len(unRevisions)) + for _, obj := range unRevisions { + rev := &apiextensionsv1.CompositionRevision{} + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, rev) + if err != nil { + c.logger.Debug("Failed to convert composition revision from unstructured", + "name", obj.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot convert unstructured to CompositionRevision") + } + + revisions = append(revisions, rev) + } + + c.logger.Debug("Successfully retrieved composition revisions", "count", len(revisions)) + + return revisions, nil +} + +// GetCompositionRevision gets a composition revision by name. +func (c *DefaultCompositionRevisionClient) GetCompositionRevision(ctx context.Context, name string) (*apiextensionsv1.CompositionRevision, error) { + // Check cache first + c.cacheMu.RLock() + rev, ok := c.revisions[name] + c.cacheMu.RUnlock() + + if ok { + return rev, nil + } + + // Not in cache, fetch from cluster + gvk := schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: CompositionRevisionKind, + } + + unRev, err := c.resourceClient.GetResource(ctx, gvk, "" /* CompositionRevisions are cluster scoped */, name) + if err != nil { + return nil, errors.Wrapf(err, "cannot get composition revision %s", name) + } + + // Convert to typed + rev = &apiextensionsv1.CompositionRevision{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unRev.Object, rev); err != nil { + return nil, errors.Wrap(err, "cannot convert unstructured to CompositionRevision") + } + + // Update cache + c.cacheMu.Lock() + c.revisions[name] = rev + c.cacheMu.Unlock() + + return rev, nil +} + +// revisionsForComposition returns all CompositionRevisions for the named composition, loading and +// caching them (by composition and by name) on first access. +func (c *DefaultCompositionRevisionClient) revisionsForComposition(ctx context.Context, compositionName string) ([]*apiextensionsv1.CompositionRevision, error) { + c.cacheMu.RLock() + cached, ok := c.revisionsByComposition[compositionName] + c.cacheMu.RUnlock() + + if ok { + return cached, nil + } + + c.logger.Debug("Loading revisions for composition", "compositionName", compositionName) + + revisions, err := c.listCompositionRevisionsForComposition(ctx, compositionName) + if err != nil { + return nil, errors.Wrap(err, "cannot list composition revisions") + } + + c.cacheMu.Lock() + defer c.cacheMu.Unlock() + + // Cache by name for individual lookups. + for _, rev := range revisions { + c.revisions[rev.GetName()] = rev + } + + // Cache the list even if empty (to avoid re-querying). + c.revisionsByComposition[compositionName] = revisions + + return revisions, nil +} + +// GetLatestRevisionForComposition finds the latest (highest-numbered) revision for a given +// composition whose labels match the provided selector. A nil selector means no restriction +// (latest revision overall). +func (c *DefaultCompositionRevisionClient) GetLatestRevisionForComposition(ctx context.Context, compositionName string, selector labels.Selector) (*apiextensionsv1.CompositionRevision, error) { + // A nil selector means "no restriction"; treat it as matching every revision. + if selector == nil { + selector = labels.Everything() + } + + c.logger.Debug("Finding latest revision for composition", "compositionName", compositionName, "selector", selector.String()) + + revisions, err := c.revisionsForComposition(ctx, compositionName) + if err != nil { + return nil, err + } + + if len(revisions) == 0 { + return nil, errors.Errorf("%w for composition %s", ErrNoCompositionRevisions, compositionName) + } + + // Filter to revisions whose labels match the selector (Crossplane selects among these). + matchingRevisions := make([]*apiextensionsv1.CompositionRevision, 0, len(revisions)) + + for _, rev := range revisions { + if selector.Matches(labels.Set(rev.GetLabels())) { + matchingRevisions = append(matchingRevisions, rev) + } + } + + if len(matchingRevisions) == 0 { + return nil, errors.Errorf("no composition revisions for composition %s match selector %s", compositionName, selector.String()) + } + + // Sort by revision number (highest first) + sort.Slice(matchingRevisions, func(i, j int) bool { + return matchingRevisions[i].Spec.Revision > matchingRevisions[j].Spec.Revision + }) + + latest := matchingRevisions[0] + + // Validate that we don't have duplicate revision numbers (would indicate a serious error) + if len(matchingRevisions) > 1 && matchingRevisions[0].Spec.Revision == matchingRevisions[1].Spec.Revision { + return nil, errors.Errorf( + "multiple composition revisions found with the same revision number %d for composition %s (revisions: %s, %s) - this indicates a serious error in the Crossplane runtime", + latest.Spec.Revision, compositionName, matchingRevisions[0].GetName(), matchingRevisions[1].GetName()) + } + + c.logger.Debug("Found latest revision", + "compositionName", compositionName, + "revisionName", latest.GetName(), + "revisionNumber", latest.Spec.Revision) + + return latest, nil +} + +// GetCompositionFromRevision extracts a Composition from a CompositionRevision. +// CompositionRevision contains the full Composition spec, so we construct a Composition object. +func (c *DefaultCompositionRevisionClient) GetCompositionFromRevision(revision *apiextensionsv1.CompositionRevision) *apiextensionsv1.Composition { + if revision == nil { + return nil + } + + comp := &apiextensionsv1.Composition{ + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: revision.Spec.CompositeTypeRef, + Mode: revision.Spec.Mode, + Pipeline: revision.Spec.Pipeline, + WriteConnectionSecretsToNamespace: revision.Spec.WriteConnectionSecretsToNamespace, + }, + } + + // Copy metadata from the revision to the composition + // Use the composition name from the label if available + if labels := revision.GetLabels(); labels != nil { + if compositionName := labels[LabelCompositionName]; compositionName != "" { + comp.SetName(compositionName) + } + } + + // If we couldn't get the name from labels, use the revision name (minus the hash suffix) + if comp.GetName() == "" { + comp.SetName(revision.GetName()) + } + + return comp +} diff --git a/internal/diff/client/crossplane/composition_revision_client_test.go b/internal/diff/client/crossplane/composition_revision_client_test.go new file mode 100644 index 00000000..b3461734 --- /dev/null +++ b/internal/diff/client/crossplane/composition_revision_client_test.go @@ -0,0 +1,721 @@ +package crossplane + +import ( + "context" + "maps" + "strings" + "sync" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func TestDefaultCompositionRevisionClient_Initialize(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + reason string + mockResource *tu.MockResourceClient + expectError bool + }{ + "SuccessfulInitialization": { + reason: "Should successfully initialize the client", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{{Group: CrossplaneAPIExtGroup, Kind: "CompositionRevision"}}). + Build(), + expectError: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + resourceClient: tt.mockResource, + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + err := c.Initialize(ctx) + + if tt.expectError && err == nil { + t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) + } else if !tt.expectError && err != nil { + t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + } + }) + } +} + +func TestDefaultCompositionRevisionClient_GetCompositionRevision(t *testing.T) { + ctx := t.Context() + + // Create a test composition revision + testRev := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Mock resource client + mockResource := tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == "CompositionRevision" && name == "test-comp-abc123" { + u := &un.Unstructured{} + u.SetGroupVersionKind(gvk) + u.SetName(name) + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(testRev) + if err != nil { + return nil, err + } + + u.SetUnstructuredContent(obj) + + return u, nil + } + + return nil, errors.New("composition revision not found") + }). + Build() + + tests := map[string]struct { + reason string + name string + cache map[string]*apiextensionsv1.CompositionRevision + expectRev *apiextensionsv1.CompositionRevision + expectError bool + errorPattern string + }{ + "CachedRevision": { + reason: "Should return revision from cache when available", + name: "cached-rev", + cache: map[string]*apiextensionsv1.CompositionRevision{ + "cached-rev": testRev, + }, + expectRev: testRev, + expectError: false, + }, + "FetchFromCluster": { + reason: "Should fetch revision from cluster when not in cache", + name: "test-comp-abc123", + cache: map[string]*apiextensionsv1.CompositionRevision{}, + expectRev: testRev, + expectError: false, + }, + "NotFound": { + reason: "Should return error when revision doesn't exist", + name: "nonexistent-rev", + cache: map[string]*apiextensionsv1.CompositionRevision{}, + expectRev: nil, + expectError: true, + errorPattern: "cannot get composition revision", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + resourceClient: mockResource, + logger: tu.TestLogger(t, false), + revisions: tt.cache, + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + rev, err := c.GetCompositionRevision(ctx, tt.name) + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nGetCompositionRevision(...): expected error but got none", tt.reason) + return + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nGetCompositionRevision(...): expected error containing %q, got %q", + tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCompositionRevision(...): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.expectRev.GetName(), rev.GetName()); diff != "" { + t.Errorf("\n%s\nGetCompositionRevision(...): -want name, +got name:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionRevisionClient_GetLatestRevisionForComposition(t *testing.T) { + ctx := t.Context() + + // Create test revisions + rev1 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + rev2 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev2", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 2, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + rev3 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev3", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 3, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Create duplicate revision numbers (error case) + duplicateRev1 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dup-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "dup-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 5, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + duplicateRev2 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dup-comp-rev2", + Labels: map[string]string{ + LabelCompositionName: "dup-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 5, // Same revision number as duplicateRev1 + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Convert revisions to unstructured + toUnstructured := func(rev *apiextensionsv1.CompositionRevision) *un.Unstructured { + u := &un.Unstructured{} + obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(rev) + u.SetUnstructuredContent(obj) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: "CompositionRevision", + }) + + return u + } + + tests := map[string]struct { + reason string + compositionName string + mockResource *tu.MockResourceClient + cachedByComp map[string][]*apiextensionsv1.CompositionRevision + expectRev *apiextensionsv1.CompositionRevision + expectError bool + errorPattern string + }{ + "ReturnsLatestRevision": { + reason: "Should return the revision with the highest revision number", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), toUnstructured(rev3), + }, LabelCompositionName, "test-comp"). + Build(), + expectRev: rev3, + expectError: false, + }, + "SingleRevision": { + reason: "Should return the only revision when there's just one", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), + }, LabelCompositionName, "test-comp"). + Build(), + expectRev: rev1, + expectError: false, + }, + "NoRevisionsFound": { + reason: "Should return error when no revisions exist for the composition", + compositionName: "nonexistent-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + // Set up one composition but query for a different one + toUnstructured(rev1), + }, LabelCompositionName, "test-comp"). + Build(), + expectRev: nil, + expectError: true, + errorPattern: "no composition revisions found", + }, + "DuplicateRevisionNumbers": { + reason: "Should return error when multiple revisions have the same number", + compositionName: "dup-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(duplicateRev1), toUnstructured(duplicateRev2), + }, LabelCompositionName, "dup-comp"). + Build(), + expectRev: nil, + expectError: true, + errorPattern: "multiple composition revisions found with the same revision number", + }, + "UsesCache": { + reason: "Should use cached revisions when available", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ metav1.LabelSelector) ([]*un.Unstructured, error) { + // This should not be called because cache is populated + return nil, errors.New("should not call GetResourcesByLabel when cache is populated") + }). + Build(), + cachedByComp: map[string][]*apiextensionsv1.CompositionRevision{ + "test-comp": {rev1, rev2, rev3}, + }, + expectRev: rev3, + expectError: false, + }, + "ListRevisionsError": { + reason: "Should return error when listing revisions fails", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ metav1.LabelSelector) ([]*un.Unstructured, error) { + return nil, errors.New("list error") + }). + Build(), + expectRev: nil, + expectError: true, + errorPattern: "cannot list composition revisions", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + resourceClient: tt.mockResource, + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: tt.cachedByComp, + } + + if tt.cachedByComp == nil { + c.revisionsByComposition = make(map[string][]*apiextensionsv1.CompositionRevision) + } + + rev, err := c.GetLatestRevisionForComposition(ctx, tt.compositionName, nil) + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): expected error but got none", tt.reason) + return + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): expected error containing %q, got %q", + tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.expectRev.GetName(), rev.GetName()); diff != "" { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): -want name, +got name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.expectRev.Spec.Revision, rev.Spec.Revision); diff != "" { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): -want revision number, +got revision number:\n%s", + tt.reason, diff) + } + }) + } +} + +// TestDefaultCompositionRevisionClient_GetLatestRevisionForComposition_Selector covers the +// selector-aware revision selection used by the xr command (issue #388, Bug B): under Automatic +// policy with a compositionRevisionSelector, Crossplane picks the newest revision whose (inherited) +// labels match the selector — not the newest revision overall. +func TestDefaultCompositionRevisionClient_GetLatestRevisionForComposition_Selector(t *testing.T) { + ctx := t.Context() + + rev := func(name string, revision int64, extraLabels map[string]string) *apiextensionsv1.CompositionRevision { + lbls := map[string]string{LabelCompositionName: "chan-comp"} + maps.Copy(lbls, extraLabels) + + return &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{Name: name, Labels: lbls}, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: revision, + CompositeTypeRef: apiextensionsv1.TypeReference{APIVersion: "example.org/v1", Kind: "XR1"}, + }, + } + } + + // active is revision 1 (channel=active); preview is revision 2 (channel=preview, newest overall). + active := rev("chan-comp-active", 1, map[string]string{"channel": "active"}) + preview := rev("chan-comp-preview", 2, map[string]string{"channel": "preview"}) + // Two revisions sharing a walking tag major=v1: v1old (rev 1), v1new (rev 3, newest matching). + v1old := rev("chan-comp-v1old", 1, map[string]string{"major": "v1"}) + v1new := rev("chan-comp-v1new", 3, map[string]string{"major": "v1"}) + + toUnstructured := func(r *apiextensionsv1.CompositionRevision) *un.Unstructured { + u := &un.Unstructured{} + obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(r) + u.SetUnstructuredContent(obj) + u.SetGroupVersionKind(schema.GroupVersionKind{Group: CrossplaneAPIExtGroup, Version: "v1", Kind: "CompositionRevision"}) + + return u + } + + mustSelector := func(m map[string]string) labels.Selector { + s, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{MatchLabels: m}) + if err != nil { + t.Fatalf("building selector: %v", err) + } + + return s + } + + tests := map[string]struct { + reason string + revisions []*apiextensionsv1.CompositionRevision + selector labels.Selector + expectName string + expectError bool + errorPattern string + }{ + "SelectorPinsOlderActiveRevision": { + reason: "selector channel=active resolves to the active revision, not the newest overall", + revisions: []*apiextensionsv1.CompositionRevision{active, preview}, + selector: mustSelector(map[string]string{"channel": "active"}), + expectName: "chan-comp-active", + }, + "SelectorSelectsPreviewRevision": { + reason: "selector channel=preview resolves to the preview revision", + revisions: []*apiextensionsv1.CompositionRevision{active, preview}, + selector: mustSelector(map[string]string{"channel": "preview"}), + expectName: "chan-comp-preview", + }, + "WalkingTagSelectsNewestMatching": { + reason: "selector major=v1 matches multiple revisions; newest matching wins", + revisions: []*apiextensionsv1.CompositionRevision{v1old, v1new}, + selector: mustSelector(map[string]string{"major": "v1"}), + expectName: "chan-comp-v1new", + }, + "EverythingSelectorSelectsNewestOverall": { + reason: "labels.Everything() selects the newest revision overall", + revisions: []*apiextensionsv1.CompositionRevision{active, preview}, + selector: labels.Everything(), + expectName: "chan-comp-preview", + }, + "NilSelectorSelectsNewestOverall": { // nil is the ergonomic "no restriction" + reason: "a nil selector means no restriction and selects the newest revision overall", + revisions: []*apiextensionsv1.CompositionRevision{active, preview}, + selector: nil, + expectName: "chan-comp-preview", + }, + "NoRevisionMatchesSelector": { + reason: "a selector matching no revision is a hard error", + revisions: []*apiextensionsv1.CompositionRevision{active, preview}, + selector: mustSelector(map[string]string{"channel": "nonexistent"}), + expectError: true, + errorPattern: "match selector", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + unRevs := make([]*un.Unstructured, len(tt.revisions)) + for i, r := range tt.revisions { + unRevs[i] = toUnstructured(r) + } + + mockResource := tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel(unRevs, LabelCompositionName, "chan-comp"). + Build() + + c := &DefaultCompositionRevisionClient{ + resourceClient: mockResource, + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + got, err := c.GetLatestRevisionForComposition(ctx, "chan-comp", tt.selector) + + if tt.expectError { + if err == nil { + t.Fatalf("\n%s\nexpected error but got none", tt.reason) + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nexpected error containing %q, got %q", tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nunexpected error: %v", tt.reason, err) + } + + if got.GetName() != tt.expectName { + t.Errorf("\n%s\ngot revision %q, want %q", tt.reason, got.GetName(), tt.expectName) + } + }) + } +} + +func TestDefaultCompositionRevisionClient_GetCompositionFromRevision(t *testing.T) { + tests := map[string]struct { + reason string + revision *apiextensionsv1.CompositionRevision + expectComp *apiextensionsv1.Composition + expectNil bool + }{ + "ValidRevision": { + reason: "Should extract composition from revision", + revision: &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + Pipeline: []apiextensionsv1.PipelineStep{ + { + Step: "test-step", + FunctionRef: apiextensionsv1.FunctionReference{ + Name: "test-function", + }, + }, + }, + }, + }, + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + Pipeline: []apiextensionsv1.PipelineStep{ + { + Step: "test-step", + FunctionRef: apiextensionsv1.FunctionReference{ + Name: "test-function", + }, + }, + }, + }, + }, + expectNil: false, + }, + "NilRevision": { + reason: "Should return nil when revision is nil", + revision: nil, + expectComp: nil, + expectNil: true, + }, + "NoLabelUsesRevisionName": { + reason: "Should use revision name when label is missing", + revision: &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", // Should use revision name + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectNil: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + comp := c.GetCompositionFromRevision(tt.revision) + + if tt.expectNil { + if comp != nil { + t.Errorf("\n%s\nGetCompositionFromRevision(...): expected nil, got composition", tt.reason) + } + + return + } + + if comp == nil { + t.Errorf("\n%s\nGetCompositionFromRevision(...): unexpected nil composition", tt.reason) + return + } + + if diff := cmp.Diff(tt.expectComp.GetName(), comp.GetName()); diff != "" { + t.Errorf("\n%s\nGetCompositionFromRevision(...): -want name, +got name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.expectComp.Spec.CompositeTypeRef, comp.Spec.CompositeTypeRef); diff != "" { + t.Errorf("\n%s\nGetCompositionFromRevision(...): -want type ref, +got type ref:\n%s", tt.reason, diff) + } + }) + } +} + +// TestDefaultCompositionRevisionClient_ConcurrentUse loads several compositions' revisions into the +// cache from concurrent goroutines while others look revisions up by name, as concurrent diffs do. +// Run it with -race. +func TestDefaultCompositionRevisionClient_ConcurrentUse(t *testing.T) { + ctx := t.Context() + + compositions := []string{"comp-a", "comp-b", "comp-c", "comp-d"} + + mockResource := tu.NewMockResourceClient(). + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + comp := sel.MatchLabels[LabelCompositionName] + rev := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{Name: comp + "-rev1", Labels: map[string]string{LabelCompositionName: comp}}, + Spec: apiextensionsv1.CompositionRevisionSpec{Revision: 1}, + } + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(rev) + if err != nil { + return nil, err + } + + return []*un.Unstructured{{Object: obj}}, nil + }). + WithResourceNotFound(). + Build() + + c := &DefaultCompositionRevisionClient{ + resourceClient: mockResource, + logger: tu.TestLogger(t, false), + revisions: map[string]*apiextensionsv1.CompositionRevision{}, + revisionsByComposition: map[string][]*apiextensionsv1.CompositionRevision{}, + } + + var wg sync.WaitGroup + for _, comp := range compositions { + wg.Go(func() { + rev, err := c.GetLatestRevisionForComposition(ctx, comp, nil) + if err != nil { + t.Errorf("GetLatestRevisionForComposition(%q): %v", comp, err) + return + } + + if want := comp + "-rev1"; rev.GetName() != want { + t.Errorf("GetLatestRevisionForComposition(%q) = %q, want %q", comp, rev.GetName(), want) + } + }) + // Not yet cached when it runs first, so it may miss; only the cache access matters here. + wg.Go(func() { _, _ = c.GetCompositionRevision(ctx, comp+"-rev1") }) + } + + wg.Wait() +} diff --git a/internal/diff/client/crossplane/credential_client.go b/internal/diff/client/crossplane/credential_client.go new file mode 100644 index 00000000..0006191e --- /dev/null +++ b/internal/diff/client/crossplane/credential_client.go @@ -0,0 +1,123 @@ +package crossplane + +import ( + "context" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/types" +) + +// CredentialClient handles fetching credentials referenced by composition pipelines. +type CredentialClient interface { + // FetchCompositionCredentials extracts credential refs from a composition's pipeline steps and + // fetches the referenced secrets from the cluster. Secrets that do not exist are reported in the + // result's Absent list rather than failing the fetch: they may be injected at runtime (e.g. + // workload identity) or supplied by the caller via --function-credentials. Any other failure to + // read a referenced secret is returned as an error. + FetchCompositionCredentials(ctx context.Context, comp *apiextensionsv1.Composition) (types.CredentialFetchResult, error) +} + +// DefaultCredentialClient implements CredentialClient. +type DefaultCredentialClient struct { + resourceClient kubernetes.ResourceClient + logger logging.Logger +} + +// NewCredentialClient creates a new DefaultCredentialClient. +func NewCredentialClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) CredentialClient { + return &DefaultCredentialClient{ + resourceClient: resourceClient, + logger: logger, + } +} + +// FetchCompositionCredentials extracts credential references from a composition's pipeline steps +// and fetches the referenced secrets from the cluster. This enables functions to receive +// credentials for authentication (e.g., Azure Workload Identity for function-msgraph). +// +// A referenced secret that does not exist is recorded in the result's Absent list and the fetch +// continues, because an absent secret is a condition the caller may legitimately have handled: it may +// be injected at runtime by the cluster (e.g. workload identity) or supplied via the CLI's +// --function-credentials flag. +// +// Every other failure is returned as an error. A Forbidden, a transport failure or an unconvertible +// payload means the secret may well exist and be relevant but could not be read, so rendering without +// it would produce a diff that silently does not reflect what the cluster would do. Per the project's +// accuracy-first stance that is a failure, not an advisory. +func (c *DefaultCredentialClient) FetchCompositionCredentials(ctx context.Context, comp *apiextensionsv1.Composition) (types.CredentialFetchResult, error) { + if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { + return types.CredentialFetchResult{}, nil + } + + secretGVK := schema.GroupVersionKind{ + Group: "", + Version: "v1", + Kind: "Secret", + } + + var result types.CredentialFetchResult + + // A composition may reference one secret from several steps; report each absent secret once. + absentSeen := map[k8stypes.NamespacedName]struct{}{} + + for _, step := range comp.Spec.Pipeline { + for _, cred := range step.Credentials { + if cred.Source != apiextensionsv1.FunctionCredentialsSourceSecret || cred.SecretRef == nil { + continue + } + + ref := k8stypes.NamespacedName{Namespace: cred.SecretRef.Namespace, Name: cred.SecretRef.Name} + + c.logger.Debug("Fetching function credential secret", + "step", step.Step, + "credentialName", cred.Name, + "secretRef", ref.String()) + + secretUnstructured, err := c.resourceClient.GetResource(ctx, secretGVK, ref.Namespace, ref.Name) + + switch { + case apierrors.IsNotFound(err): + c.logger.Debug("Function credential secret does not exist on cluster", + "step", step.Step, + "credentialName", cred.Name, + "secretRef", ref.String()) + + if _, dup := absentSeen[ref]; !dup { + absentSeen[ref] = struct{}{} + result.Absent = append(result.Absent, ref) + } + + continue + case err != nil: + return types.CredentialFetchResult{}, errors.Wrapf(err, + "cannot read function credential secret %s referenced by pipeline step %q", ref, step.Step) + } + + secret := corev1.Secret{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(secretUnstructured.Object, &secret); err != nil { + return types.CredentialFetchResult{}, errors.Wrapf(err, + "cannot decode function credential secret %s referenced by pipeline step %q", ref, step.Step) + } + + result.Secrets = append(result.Secrets, secret) + } + } + + c.logger.Debug("Fetched function credential secrets from cluster", + "composition", comp.GetName(), + "fetched", len(result.Secrets), + "absent", len(result.Absent)) + + return result, nil +} diff --git a/internal/diff/client/crossplane/credential_client_test.go b/internal/diff/client/crossplane/credential_client_test.go new file mode 100644 index 00000000..e7285e14 --- /dev/null +++ b/internal/diff/client/crossplane/credential_client_test.go @@ -0,0 +1,259 @@ +package crossplane + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ CredentialClient = (*tu.MockCredentialClient)(nil) + +func TestDefaultCredentialClient_FetchCompositionCredentials(t *testing.T) { + ctx := t.Context() + + // Define common secrets used across multiple tests + // Two secrets in ns1 (for same-namespace tests), one in ns2 (for different-namespace tests) + secret1Builder := tu.NewResource("v1", "Secret", "secret1"). + InNamespace("ns1"). + WithData(map[string][]byte{"key1": []byte("val1")}) + + var secret1 corev1.Secret + secret1Builder.BuildTyped(&secret1) + + secret2Builder := tu.NewResource("v1", "Secret", "secret2"). + InNamespace("ns1"). + WithData(map[string][]byte{"key2": []byte("val2")}) + + var secret2 corev1.Secret + secret2Builder.BuildTyped(&secret2) + + secret3Builder := tu.NewResource("v1", "Secret", "secret3"). + InNamespace("ns2"). + WithData(map[string][]byte{"key3": []byte("val3")}) + + var secret3 corev1.Secret + secret3Builder.BuildTyped(&secret3) + + tests := map[string]struct { + reason string + composition *apiextensionsv1.Composition + mockResource tu.MockResourceClient + wantSecrets []corev1.Secret + // wantAbsent is the set of referenced secrets reported as absent from the cluster. The client + // reports the fact rather than warning about it: whether an absent secret is a problem depends on + // whether the caller supplied it via --function-credentials, which only the caller can see. + wantAbsent []k8stypes.NamespacedName + // wantErr is a substring of the expected error. A failure that is NOT a NotFound means the + // credential may exist and be relevant but could not be read; rendering without it would emit a + // diff that silently does not reflect reality, so the fetch fails rather than degrading. + wantErr string + }{ + "NonPipelineMode": { + reason: "Should return nil for non-pipeline compositions", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + Build(), // Default is not pipeline mode + mockResource: *tu.NewMockResourceClient().Build(), + wantSecrets: nil, + }, + "PipelineModeNoCredentials": { + reason: "Should return nil when pipeline has no credential refs", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build(), + mockResource: *tu.NewMockResourceClient().Build(), + wantSecrets: nil, + }, + "SingleCredentialFetched": { + reason: "Should fetch single credential secret from cluster", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil, + tu.WithCredentials("creds", "ns1", "secret1")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithResourcesExist(secret1Builder.Build()). + Build(), + wantSecrets: []corev1.Secret{secret1}, + }, + "MultipleCredentialsFetched": { + reason: "Should fetch multiple credential secrets from different steps", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-one", nil, + tu.WithCredentials("creds1", "ns1", "secret1")). + WithPipelineStep("step2", "function-two", nil, + tu.WithCredentials("creds2", "ns2", "secret3")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithResourcesExist(secret1Builder.Build(), secret3Builder.Build()). + Build(), + wantSecrets: []corev1.Secret{secret1, secret3}, + }, + "CredentialNotFoundReported": { + reason: "Should report, not fail on, credentials absent from the cluster (e.g., runtime-injected)", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil, + tu.WithCredentials("creds", "crossplane-system", "missing-secret")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithResourceNotFound(). + Build(), + wantSecrets: nil, + wantAbsent: []k8stypes.NamespacedName{{Namespace: "crossplane-system", Name: "missing-secret"}}, + }, + "MixedFetchResults": { + reason: "Should return successfully fetched credentials and report the absent one", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-one", nil, + tu.WithCredentials("creds1", "ns1", "secret1")). + WithPipelineStep("step2", "function-two", nil, + tu.WithCredentials("creds2", "ns2", "missing")). + Build(), + // Only "secret1" is in the map; "missing" yields NotFound + mockResource: *tu.NewMockResourceClient(). + WithResourcesExist(secret1Builder.Build()). + Build(), + wantSecrets: []corev1.Secret{secret1}, + wantAbsent: []k8stypes.NamespacedName{{Namespace: "ns2", Name: "missing"}}, + }, + "SameAbsentSecretReferencedTwiceReportedOnce": { + reason: "One absent secret referenced from two steps is one shortfall, not two", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-one", nil, + tu.WithCredentials("creds1", "ns1", "shared-missing")). + WithPipelineStep("step2", "function-two", nil, + tu.WithCredentials("creds2", "ns1", "shared-missing")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithResourceNotFound(). + Build(), + wantSecrets: nil, + wantAbsent: []k8stypes.NamespacedName{{Namespace: "ns1", Name: "shared-missing"}}, + }, + "ForbiddenFails": { + reason: "RBAC denial leaves the credential state unknown, so the fetch must fail rather than render without it", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil, + tu.WithCredentials("creds", "crossplane-system", "forbidden-secret")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithGetResourceError(apierrors.NewForbidden( + schema.GroupResource{Resource: "secrets"}, "forbidden-secret", errors.New("nope"))). + Build(), + wantErr: `cannot read function credential secret crossplane-system/forbidden-secret referenced by pipeline step "step1"`, + }, + "TransportFailureFails": { + reason: "A transport error is equally unknowable; degrading to a warning would emit a possibly-wrong diff", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil, + tu.WithCredentials("creds", "ns1", "secret1")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithGetResourceError(errors.New("connection refused")). + Build(), + wantErr: "connection refused", + }, + "MultipleCredentialsInSameStep": { + reason: "Should fetch multiple credentials from the same pipeline step", + composition: tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil, + tu.WithCredentials("creds1", "ns1", "secret1"), + tu.WithCredentials("creds2", "ns1", "secret2")). + Build(), + mockResource: *tu.NewMockResourceClient(). + WithResourcesExist(secret1Builder.Build(), secret2Builder.Build()). + Build(), + wantSecrets: []corev1.Secret{secret1, secret2}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCredentialClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + } + + result, err := c.FetchCompositionCredentials(ctx, tt.composition) + + switch { + case tt.wantErr != "": + if err == nil { + t.Fatalf("\n%s\nFetchCompositionCredentials(): expected an error containing %q, got nil", + tt.reason, tt.wantErr) + } + + if !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("\n%s\nFetchCompositionCredentials(): error %q does not contain %q", + tt.reason, err.Error(), tt.wantErr) + } + + return + case err != nil: + t.Fatalf("\n%s\nFetchCompositionCredentials(): unexpected error: %v", tt.reason, err) + } + + if diff := cmp.Diff(tt.wantAbsent, result.Absent); diff != "" { + t.Errorf("\n%s\nFetchCompositionCredentials() absent mismatch (-want +got):\n%s", tt.reason, diff) + } + + got := result.Secrets + + // Compare counts first + if len(got) != len(tt.wantSecrets) { + t.Errorf("\n%s\nFetchCompositionCredentials(): got %d secrets, want %d", + tt.reason, len(got), len(tt.wantSecrets)) + + return + } + + // Compare each secret + for i, wantSecret := range tt.wantSecrets { + gotSecret := got[i] + + if diff := cmp.Diff(wantSecret.Namespace, gotSecret.Namespace); diff != "" { + t.Errorf("\n%s\nFetchCompositionCredentials() secret[%d] namespace mismatch (-want +got):\n%s", + tt.reason, i, diff) + } + + if diff := cmp.Diff(wantSecret.Name, gotSecret.Name); diff != "" { + t.Errorf("\n%s\nFetchCompositionCredentials() secret[%d] name mismatch (-want +got):\n%s", + tt.reason, i, diff) + } + + if diff := cmp.Diff(wantSecret.Data, gotSecret.Data); diff != "" { + t.Errorf("\n%s\nFetchCompositionCredentials() secret[%d] data mismatch (-want +got):\n%s", + tt.reason, i, diff) + } + } + }) + } +} diff --git a/internal/diff/client/crossplane/definition_client.go b/internal/diff/client/crossplane/definition_client.go new file mode 100644 index 00000000..631e8d75 --- /dev/null +++ b/internal/diff/client/crossplane/definition_client.go @@ -0,0 +1,295 @@ +package crossplane + +import ( + "context" + "sync" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" +) + +// CompositeResourceDefinitionKind is the kind for Composite Resource Definitions. +const CompositeResourceDefinitionKind = "CompositeResourceDefinition" + +// DefinitionClient handles Crossplane definitions (XRDs). +// +//nolint:interfacebloat // The 6 methods are cohesively about XRD lookup; splitting just to satisfy the linter would create surface without value. +type DefinitionClient interface { + core.Initializable + + // GetXRDs gets all XRDs in the cluster + GetXRDs(ctx context.Context) ([]*un.Unstructured, error) + + // GetXRDForClaim finds the XRD that defines the given claim type + GetXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) + + // GetXRDForXR finds the XRD that defines the given XR type + GetXRDForXR(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) + + // IsClaimResource checks if the given resource is a claim type + IsClaimResource(ctx context.Context, resource *un.Unstructured) bool + + // GetCompositeSchema returns the composite.Schema (Legacy or Modern) that + // applies to the given XR or claim GVK. The scope is read from the XRD's + // spec.scope field (NOT the XRD's own apiVersion, which the apiserver may + // rewrite during v1↔v2 conversion). spec.scope == "LegacyCluster" maps to + // SchemaLegacy (canonical fields under spec.*); "Cluster"/"Namespaced" map + // to SchemaModern (canonical fields under spec.crossplane.*). Mirrors the + // rule the render binary uses (selectSchema in crossplane's + // internal/render/composite/render.go). + GetCompositeSchema(ctx context.Context, gvk schema.GroupVersionKind) (ucomposite.Schema, error) +} + +// DefaultDefinitionClient implements DefinitionClient. +type DefaultDefinitionClient struct { + resourceClient kubernetes.ResourceClient + logger logging.Logger + + // XRDs cache + gvks []schema.GroupVersionKind + xrds []*un.Unstructured + xrdsMutex sync.RWMutex + xrdsLoaded bool +} + +// NewDefinitionClient creates a new DefaultDefinitionClient. +func NewDefinitionClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) DefinitionClient { + return &DefaultDefinitionClient{ + resourceClient: resourceClient, + logger: logger, + xrds: []*un.Unstructured{}, + } +} + +// Initialize loads XRDs into the cache. +func (c *DefaultDefinitionClient) Initialize(ctx context.Context) error { + c.logger.Debug("Initializing definition client") + + gvks, err := c.resourceClient.GetGVKsForGroupKind(ctx, CrossplaneAPIExtGroup, CompositeResourceDefinitionKind) + if err != nil { + return errors.Wrap(err, "cannot get XRD GVKs") + } + + c.gvks = gvks + + // Load XRDs + _, err = c.GetXRDs(ctx) + if err != nil { + return errors.Wrap(err, "cannot load XRDs") + } + + c.logger.Debug("Definition client initialized", "xrdsCount", len(c.xrds)) + + return nil +} + +// GetXRDs gets all XRDs in the cluster. +func (c *DefaultDefinitionClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, error) { + // Check if XRDs are already loaded + c.xrdsMutex.RLock() + + if c.xrdsLoaded { + xrds := c.xrds + c.xrdsMutex.RUnlock() + c.logger.Debug("Using cached XRDs", "count", len(xrds)) + + return xrds, nil + } + + c.xrdsMutex.RUnlock() + + // Need to load XRDs + c.xrdsMutex.Lock() + defer c.xrdsMutex.Unlock() + + // Double-check now that we have the write lock + if c.xrdsLoaded { + c.logger.Debug("Using cached XRDs (after recheck)", "count", len(c.xrds)) + return c.xrds, nil + } + + c.logger.Debug("Fetching XRDs from cluster") + + xrds, err := listMatchingResources(ctx, c.resourceClient, c.gvks, "" /* XRDs are cluster scoped */) + // List all XRDs + if err != nil { + c.logger.Debug("Failed to list XRDs", "error", err) + return nil, errors.Wrap(err, "cannot list XRDs") + } + + // Cache the result + c.xrds = xrds + c.xrdsLoaded = true + + c.logger.Debug("Successfully retrieved and cached XRDs", "count", len(xrds)) + + return xrds, nil +} + +// GetXRDForClaim finds the XRD that defines the given claim type. +func (c *DefaultDefinitionClient) GetXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + c.logger.Debug("Looking for XRD that defines claim", + "gvk", gvk.String()) + + // Get all XRDs + xrds, err := c.GetXRDs(ctx) + if err != nil { + return nil, errors.Wrap(err, "cannot get XRDs") + } + + // Loop through XRDs to find one that defines this GVK as a claim + for _, xrd := range xrds { + claimGroup, found, _ := un.NestedString(xrd.Object, "spec", "group") + + // Skip if group doesn't match + if !found || claimGroup != gvk.Group { + continue + } + + // Check claim kind + claimNames, found, _ := un.NestedMap(xrd.Object, "spec", "claimNames") + if !found || claimNames == nil { + continue + } + + claimKind, found, _ := un.NestedString(claimNames, "kind") + if !found || claimKind != gvk.Kind { + continue + } + + c.logger.Debug("Found matching XRD for claim type", + "gvk", gvk.String(), + "xrd", xrd.GetName()) + + return xrd, nil + } + + return nil, errors.Errorf("no XRD found that defines claim type %s", gvk.String()) +} + +// GetXRDForXR finds the XRD that defines the given XR type. +func (c *DefaultDefinitionClient) GetXRDForXR(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + c.logger.Debug("Looking for XRD that defines XR", + "gvk", gvk.String()) + + // Get all XRDs + xrds, err := c.GetXRDs(ctx) + if err != nil { + return nil, errors.Wrap(err, "cannot get XRDs") + } + + // Loop through XRDs to find one that defines this GVK as an XR + for _, xrd := range xrds { + xrGroup, found, _ := un.NestedString(xrd.Object, "spec", "group") + + // Skip if group doesn't match + if !found || xrGroup != gvk.Group { + continue + } + + // Check XR kind + xrKind, found, _ := un.NestedString(xrd.Object, "spec", "names", "kind") + if !found || xrKind != gvk.Kind { + continue + } + + // Check version matches + versions, found, _ := un.NestedSlice(xrd.Object, "spec", "versions") + if !found { + continue + } + + versionMatches := false + + for _, v := range versions { + version, ok := v.(map[string]any) + if !ok { + continue + } + + name, found, _ := un.NestedString(version, "name") + if found && name == gvk.Version { + versionMatches = true + break + } + } + + if !versionMatches { + continue + } + + c.logger.Debug("Found matching XRD for XR type", + "gvk", gvk.String(), + "xrd", xrd.GetName()) + + return xrd, nil + } + + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) +} + +// IsClaimResource checks if the given resource is a claim type by attempting +// to find an XRD that defines it as a claim. +func (c *DefaultDefinitionClient) IsClaimResource(ctx context.Context, resource *un.Unstructured) bool { + gvk := resource.GroupVersionKind() + + // Try to find an XRD that defines this resource type as a claim + _, err := c.GetXRDForClaim(ctx, gvk) + if err != nil { + c.logger.Debug("Resource is not a claim type", "gvk", gvk.String(), "error", err) + return false + } + + c.logger.Debug("Resource is a claim type", "gvk", gvk.String()) + + return true +} + +// GetCompositeSchema returns the composite.Schema (Legacy or Modern) for the +// given XR or claim GVK by looking up the XRD and reading its spec.scope. +// LegacyCluster → SchemaLegacy (canonical fields under spec.*); Cluster or +// Namespaced → SchemaModern (canonical fields under spec.crossplane.*). Tries +// the XR path first; falls back to the claim path so the helper works for both. +// See the interface doc for why scope, not apiVersion, drives the decision. +func (c *DefaultDefinitionClient) GetCompositeSchema(ctx context.Context, gvk schema.GroupVersionKind) (ucomposite.Schema, error) { + xrd, err := c.GetXRDForXR(ctx, gvk) + if err != nil { + // Not an XR GVK; try the claim path. + var claimErr error + + xrd, claimErr = c.GetXRDForClaim(ctx, gvk) + if claimErr != nil { + return ucomposite.SchemaModern, errors.Wrapf(err, "no XRD found for %s (also tried claim: %v)", gvk.String(), claimErr) + } + } + + return SchemaFromXRD(xrd), nil +} + +// SchemaFromXRD picks the composite.Schema (Legacy or Modern) for the given +// XRD by reading its spec.scope. Use this when you've already fetched the XRD +// for another reason (e.g. forwarding it to the render binary) and want to +// avoid the redundant cache lookup GetCompositeSchema would do. +// +// The rule mirrors the render binary's own selectSchema (see +// crossplane/crossplane internal/render/composite/render.go): use spec.scope +// rather than the XRD's apiVersion. The apiserver round-trips XRDs through +// v1↔v2 conversion (a v1 XRD POSTed by the user can come back from a v2 list +// as kind v2), so apiVersion is unreliable. spec.scope is preserved verbatim: +// v1 XRDs default to "LegacyCluster" and stay there; v2 XRDs declare +// "Cluster" or "Namespaced" explicitly. +func SchemaFromXRD(xrd *un.Unstructured) ucomposite.Schema { + scope, _, _ := un.NestedString(xrd.Object, "spec", "scope") + if scope == "" || scope == "LegacyCluster" { + return ucomposite.SchemaLegacy + } + + return ucomposite.SchemaModern +} diff --git a/internal/diff/client/crossplane/definition_client_test.go b/internal/diff/client/crossplane/definition_client_test.go new file mode 100644 index 00000000..88195b2a --- /dev/null +++ b/internal/diff/client/crossplane/definition_client_test.go @@ -0,0 +1,960 @@ +package crossplane + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ DefinitionClient = (*tu.MockDefinitionClient)(nil) + +var ( + XRDv1GVK = schema.GroupVersionKind{Group: CrossplaneAPIExtGroup, Version: "v1", Kind: CompositeResourceDefinitionKind} + XRDv2GVK = schema.GroupVersionKind{Group: CrossplaneAPIExtGroup, Version: "v2", Kind: CompositeResourceDefinitionKind} +) + +func TestDefaultDefinitionClient_GetXRDs(t *testing.T) { + ctx := t.Context() + + // Create test XRDs + xrd1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd1"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XR1", + "plural": "xr1s", + "singular": "xr1", + }). + Build() + + xrd2 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd2"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XR2", + "plural": "xr2s", + "singular": "xr2", + }). + Build() + + type fields struct { + xrds []*un.Unstructured + xrdsLoaded bool + gvks []schema.GroupVersionKind + } + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + fields fields + want []*un.Unstructured + wantErr bool + errSubstring string + }{ + "NoXRDsFound": { + reason: "Should return empty slice when no XRDs exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == CompositeResourceDefinitionKind { + return []*un.Unstructured{}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + fields: fields{ + xrds: nil, + xrdsLoaded: false, + }, + want: []*un.Unstructured{}, + wantErr: false, + }, + // TODO: test for v1 and v2 xrds + "XRDsExist": { + reason: "Should return all XRDs when they exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == CompositeResourceDefinitionKind && gvk.Version == "v1" { + return []*un.Unstructured{xrd1, xrd2}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + fields: fields{ + xrds: nil, + xrdsLoaded: false, + gvks: []schema.GroupVersionKind{XRDv1GVK}, + }, + want: []*un.Unstructured{xrd1, xrd2}, + wantErr: false, + }, + "ListError": { + reason: "Should propagate errors from the Kubernetes API", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + fields: fields{ + xrds: nil, + xrdsLoaded: false, + gvks: []schema.GroupVersionKind{XRDv1GVK}, + }, + want: nil, + wantErr: true, + errSubstring: "cannot list XRDs", + }, + "UsesCache": { + reason: "Should use cached XRDs when already loaded", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + // This should never be called since cache is used + WithListResources(func(context.Context, schema.GroupVersionKind, string) ([]*un.Unstructured, error) { + t.Errorf("ListResources should not be called when cache is available") + return nil, errors.New("should not be called") + }). + Build(), + fields: fields{ + xrds: []*un.Unstructured{xrd1, xrd2}, + xrdsLoaded: true, + }, + want: []*un.Unstructured{xrd1, xrd2}, + wantErr: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultDefinitionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + xrds: tt.fields.xrds, + xrdsLoaded: tt.fields.xrdsLoaded, + gvks: tt.fields.gvks, + } + + got, err := c.GetXRDs(ctx) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nGetXRDs(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nGetXRDs(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetXRDs(): unexpected error: %v", tt.reason, err) + return + } + + // Verify cache state + if !c.xrdsLoaded { + t.Errorf("\n%s\nGetXRDs(): cache not marked as loaded after call", tt.reason) + } + + // Compare XRD count + if diff := cmp.Diff(len(tt.want), len(got)); diff != "" { + t.Errorf("\n%s\nGetXRDs(): -want count, +got count:\n%s", tt.reason, diff) + } + + // Check if all expected XRDs are present by name + wantNames := make(map[string]bool) + gotNames := make(map[string]bool) + + for _, xrd := range tt.want { + wantNames[xrd.GetName()] = true + } + + for _, xrd := range got { + gotNames[xrd.GetName()] = true + } + + // Check missing XRDs + for name := range wantNames { + if !gotNames[name] { + t.Errorf("\n%s\nGetXRDs(): missing expected XRD with name %s", tt.reason, name) + } + } + + // Check unexpected XRDs + for name := range gotNames { + if !wantNames[name] { + t.Errorf("\n%s\nGetXRDs(): unexpected XRD with name %s", tt.reason, name) + } + } + }) + } +} + +func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { + ctx := t.Context() + + // Create test XRDs + xrdWithClaimKind := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-with-claim"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + "plural": "xexampleresources", + "singular": "xexampleresource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "ExampleClaim", + "plural": "exampleclaims", + "singular": "exampleclaim", + }). + Build() + + xrdWithoutClaimKind := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-without-claim"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XOtherResource", + "plural": "xotherresources", + "singular": "xotherresource", + }). + // No claimNames field + Build() + + type args struct { + gvk schema.GroupVersionKind + } + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + cachedXRDs []*un.Unstructured + discoveredXRDGVKs []schema.GroupVersionKind + args args + want *un.Unstructured + wantErr bool + errSubstring string + }{ + "MatchingClaimFound": { + reason: "Should return the XRD that defines the claim kind", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdWithClaimKind, xrdWithoutClaimKind}, + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ExampleClaim", + }, + }, + want: xrdWithClaimKind, + wantErr: false, + }, + "NoMatchingClaim": { + reason: "Should return error when no XRD defines the claim kind", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdWithoutClaimKind}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ExampleClaim", + }, + }, + want: nil, + wantErr: true, + errSubstring: "no XRD found that defines claim type", + }, + "GetXRDsError": { + reason: "Should propagate error from GetXRDs", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + cachedXRDs: nil, // Force GetXRDs to be called + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "ExampleClaim", + }, + }, + want: nil, + wantErr: true, + errSubstring: "cannot get XRDs", + }, + "DifferentGroup": { + reason: "Should not match XRD with different group", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdWithClaimKind}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "different.org", // Different group + Version: "v1", + Kind: "ExampleClaim", // Same kind + }, + }, + want: nil, + wantErr: true, + errSubstring: "no XRD found that defines claim type", + }, + "DifferentKind": { + reason: "Should not match XRD with different claim kind", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdWithClaimKind}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", // Same group + Version: "v1", + Kind: "DifferentClaim", // Different kind + }, + }, + want: nil, + wantErr: true, + errSubstring: "no XRD found that defines claim type", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultDefinitionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + xrds: tt.cachedXRDs, + xrdsLoaded: tt.cachedXRDs != nil, // Only mark as loaded if we have cached XRDs + gvks: tt.discoveredXRDGVKs, + } + + got, err := c.GetXRDForClaim(ctx, tt.args.gvk) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nGetXRDForClaim(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nGetXRDForClaim(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetXRDForClaim(): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.want.GetName(), got.GetName()); diff != "" { + t.Errorf("\n%s\nGetXRDForClaim(): -want name, +got name:\n%s", tt.reason, diff) + } + + // Verify it's the right XRD by checking the claim kind + claimNames, found, _ := un.NestedMap(got.Object, "spec", "claimNames") + if !found { + t.Errorf("\n%s\nGetXRDForClaim(): returned XRD missing spec.claimNames", tt.reason) + return + } + + claimKind, found, _ := un.NestedString(claimNames, "kind") + if !found || claimKind != tt.args.gvk.Kind { + t.Errorf("\n%s\nGetXRDForClaim(): returned XRD has wrong claim kind, want %s, got %s", + tt.reason, tt.args.gvk.Kind, claimKind) + } + }) + } +} + +func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { + ctx := t.Context() + + // Create test XRDs + xrdForXR1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-for-xr1"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XR1", + "plural": "xr1s", + "singular": "xr1", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1", + "served": true, + "storage": true, + }, + map[string]any{ + "name": "v2", + "served": true, + "storage": false, + }, + }). + Build() + + xrdForXR2 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-for-xr2"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XR2", + "plural": "xr2s", + "singular": "xr2", + }). + WithSpecField("versions", []any{ + map[string]any{ + "name": "v1alpha1", + "served": true, + "storage": true, + }, + }). + Build() + + type args struct { + gvk schema.GroupVersionKind + } + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + cachedXRDs []*un.Unstructured + discoveredXRDGVKs []schema.GroupVersionKind + args args + want *un.Unstructured + wantErr bool + errSubstring string + }{ + "MatchingXRFound": { + reason: "Should return the XRD that defines the XR kind", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdForXR1, xrdForXR2}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "XR1", + }, + }, + want: xrdForXR1, + wantErr: false, + }, + "MatchingXRWithDifferentVersion": { + reason: "Should return the XRD that defines the XR kind with matching version", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdForXR1, xrdForXR2}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v2", // Using v2 version + Kind: "XR1", + }, + }, + want: xrdForXR1, + wantErr: false, + }, + "NoMatchingXR": { + reason: "Should return error when no XRD defines the XR kind", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdForXR1, xrdForXR2}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "NonExistentXR", // No XRD defines this + }, + }, + want: nil, + wantErr: true, + errSubstring: "no XRD found that defines XR type", + }, + "GetXRDsError": { + reason: "Should propagate error from GetXRDs", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + cachedXRDs: nil, // Force GetXRDs to be called + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "XR1", + }, + }, + want: nil, + wantErr: true, + errSubstring: "cannot get XRDs", + }, + "DifferentGroup": { + reason: "Should not match XRD with different group", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdForXR1}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "different.org", // Different group + Version: "v1", + Kind: "XR1", // Same kind + }, + }, + want: nil, + wantErr: true, + errSubstring: "no XRD found that defines XR type", + }, + "VersionNotFound": { + reason: "Should not match XRD if version doesn't exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdForXR1}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v3", // Version doesn't exist in XRD + Kind: "XR1", + }, + }, + want: nil, + wantErr: true, + errSubstring: "no XRD found that defines XR type", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultDefinitionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + xrds: tt.cachedXRDs, + xrdsLoaded: tt.cachedXRDs != nil, // Only mark as loaded if we have cached XRDs + gvks: tt.discoveredXRDGVKs, + } + + got, err := c.GetXRDForXR(ctx, tt.args.gvk) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nGetXRDForXR(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nGetXRDForXR(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetXRDForXR(): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.want.GetName(), got.GetName()); diff != "" { + t.Errorf("\n%s\nGetXRDForXR(): -want name, +got name:\n%s", tt.reason, diff) + } + + // Verify it's the right XRD by checking the XR kind + xrKind, found, _ := un.NestedString(got.Object, "spec", "names", "kind") + if !found || xrKind != tt.args.gvk.Kind { + t.Errorf("\n%s\nGetXRDForXR(): returned XRD has wrong XR kind, want %s, got %s", + tt.reason, tt.args.gvk.Kind, xrKind) + } + }) + } +} + +func TestDefaultDefinitionClient_Initialize(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + wantErr bool + }{ + "SuccessfulInitialization": { + reason: "Should successfully initialize the client", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{XRDv1GVK}). + WithEmptyListResources(). + Build(), + wantErr: false, + }, + "GetXRDsError": { + reason: "Should return error when getting XRDs fails", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultDefinitionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + } + + err := c.Initialize(ctx) + + if tt.wantErr && err == nil { + t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) + } else if !tt.wantErr && err != nil { + t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + } + + // If we succeeded, verify that XRDs are now loaded + if !tt.wantErr { + if !c.xrdsLoaded { + t.Errorf("\n%s\nInitialize(): XRDs not marked as loaded after successful initialization", tt.reason) + } + } + }) + } +} + +func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + cachedXRDs []*un.Unstructured + resource *un.Unstructured + expected bool + }{ + "ResourceIsClaim": { + reason: "Should return true when resource is a claim type", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{ + // Create a mock XRD that defines this as a claim + tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "testclaims.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XTestResource", + "plural": "xtestresources", + "singular": "xtestresource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "TestClaim", + "plural": "testclaims", + "singular": "testclaim", + }). + Build(), + }, + resource: tu.NewResource("example.org/v1", "TestClaim", "test-claim"). + InNamespace("default"). + Build(), + expected: true, + }, + "ResourceIsNotClaim": { + reason: "Should return false when resource is not a claim type", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{ + // Create a mock XRD that defines this as an XR (no claimNames) + tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "testxrs.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "TestXR", + "plural": "testxrs", + "singular": "testxr", + }). + // No claimNames field + Build(), + }, + resource: tu.NewResource("example.org/v1", "TestXR", "test-xr"). + InNamespace("default"). + Build(), + expected: false, + }, + "GetXRDForClaimError": { + reason: "Should return false when GetXRDForClaim fails", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + cachedXRDs: nil, // Force GetXRDs to fail + resource: tu.NewResource("example.org/v1", "TestResource", "test-resource"). + Build(), + expected: false, // Should return false on error + }, + "NoMatchingXRD": { + reason: "Should return false when no matching XRD exists", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{ + // Create an XRD that doesn't match our resource + tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "otherclaims.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XOtherResource", + "plural": "xotherresources", + "singular": "xotherresource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "OtherClaim", + "plural": "otherclaims", + "singular": "otherclaim", + }). + Build(), + }, + resource: tu.NewResource("example.org/v1", "TestClaim", "test-claim"). + Build(), + expected: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultDefinitionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + xrds: tt.cachedXRDs, + xrdsLoaded: tt.cachedXRDs != nil, // Only mark as loaded if we have cached XRDs + } + + // Call the function under test + result := c.IsClaimResource(ctx, tt.resource) + + // Check the result + if result != tt.expected { + t.Errorf("\n%s\nIsClaimResource() = %v, want %v", tt.reason, result, tt.expected) + } + }) + } +} + +func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { + ctx := t.Context() + + // v1 XRD: defines an XR (no claimNames here for simplicity). + xrdV1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-v1"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XLegacyResource", + "plural": "xlegacyresources", + "singular": "xlegacyresource", + }). + WithSpecField("versions", []any{ + map[string]any{"name": "v1alpha1"}, + }). + Build() + + // v2 XRD: declares a non-legacy scope so the scope-based detector + // returns SchemaModern. (A v2 XRD without scope, or with + // scope=LegacyCluster, would correctly resolve as SchemaLegacy — see + // the V2XRD_LegacyScope_LegacySchema case below.) + xrdV2 := tu.NewResource("apiextensions.crossplane.io/v2", CompositeResourceDefinitionKind, "xrd-v2"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XModernResource", + "plural": "xmodernresources", + "singular": "xmodernresource", + }). + WithSpecField("scope", "Cluster"). + WithSpecField("versions", []any{ + map[string]any{"name": "v1alpha1"}, + }). + Build() + + // v2 XRD whose scope is "LegacyCluster". This shape can occur in real + // clusters when a user-posted v1 XRD round-trips through the apiserver's + // conversion to v2 storage form: the converted v2 object preserves + // scope="LegacyCluster" verbatim. Our scope-based detector should treat + // this as Legacy regardless of the apiVersion stamp. + xrdV2LegacyScope := tu.NewResource("apiextensions.crossplane.io/v2", CompositeResourceDefinitionKind, "xrd-v2-legacy-scope"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XLegacyScopedResource", + "plural": "xlegacyscopedresources", + "singular": "xlegacyscopedresource", + }). + WithSpecField("scope", "LegacyCluster"). + WithSpecField("versions", []any{ + map[string]any{"name": "v1alpha1"}, + }). + Build() + + // v1 XRD that also publishes a claim. Used to verify the helper resolves + // via the claim path when given a claim GVK (the XR/claim distinction is + // a Crossplane CompositeResourceDefinition feature, not a Go type — the + // claim kind here is just the user-supplied "ClaimedResource" below). + xrdV1WithClaim := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-v1-with-claim"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XClaimedResource", + "plural": "xclaimedresources", + "singular": "xclaimedresource", + }). + WithSpecField("claimNames", map[string]any{ + "kind": "ClaimedResource", + "plural": "claimedresources", + "singular": "claimedresource", + }). + WithSpecField("versions", []any{ + map[string]any{"name": "v1alpha1"}, + }). + Build() + + type args struct { + gvk schema.GroupVersionKind + } + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + cachedXRDs []*un.Unstructured + discoveredXRDGVKs []schema.GroupVersionKind + args args + want ucomposite.Schema + wantErr bool + errSubstring string + }{ + "V1XRD_LegacySchema": { + reason: "Should return SchemaLegacy for an XR defined by a v1 XRD", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdV1}, + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "XLegacyResource", + }, + }, + want: ucomposite.SchemaLegacy, + }, + "V2XRD_ModernSchema": { + reason: "Should return SchemaModern for a v2 XRD with non-legacy scope", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdV2}, + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv2GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "XModernResource", + }, + }, + want: ucomposite.SchemaModern, + }, + "V2XRD_LegacyScope_LegacySchema": { + reason: "Should return SchemaLegacy when a v2-form XRD's scope is LegacyCluster (e.g. v1 XRD round-tripped through conversion)", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdV2LegacyScope}, + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv2GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "XLegacyScopedResource", + }, + }, + want: ucomposite.SchemaLegacy, + }, + "ClaimGVK_ResolvesViaClaimPath": { + reason: "Should return the schema of the XRD that publishes a claim, given the claim GVK", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdV1WithClaim}, + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "ClaimedResource", // claim kind, not XR kind + }, + }, + want: ucomposite.SchemaLegacy, + }, + "NoXRDFound_Error": { + reason: "Should return an error when no XRD defines the GVK", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + cachedXRDs: []*un.Unstructured{xrdV2}, // only v2 XRD known + discoveredXRDGVKs: []schema.GroupVersionKind{XRDv2GVK}, + args: args{ + gvk: schema.GroupVersionKind{ + Group: "example.org", + Version: "v1alpha1", + Kind: "Unknown", + }, + }, + wantErr: true, + errSubstring: "no XRD", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultDefinitionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + xrds: tt.cachedXRDs, + xrdsLoaded: tt.cachedXRDs != nil, + gvks: tt.discoveredXRDGVKs, + } + + got, err := c.GetCompositeSchema(ctx, tt.args.gvk) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nGetCompositeSchema(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nGetCompositeSchema(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCompositeSchema(): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("\n%s\nGetCompositeSchema(): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} diff --git a/internal/diff/client/crossplane/environment_client.go b/internal/diff/client/crossplane/environment_client.go new file mode 100644 index 00000000..98abfc23 --- /dev/null +++ b/internal/diff/client/crossplane/environment_client.go @@ -0,0 +1,92 @@ +package crossplane + +import ( + "context" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" +) + +// EnvironmentClient handles environment configurations. +type EnvironmentClient interface { + core.Initializable + + // GetEnvironmentConfigs gets all environment configurations + GetEnvironmentConfigs(ctx context.Context) ([]*un.Unstructured, error) + + // GetEnvironmentConfig gets a specific environment config by name + GetEnvironmentConfig(ctx context.Context, name string) (*un.Unstructured, error) +} + +// DefaultEnvironmentClient implements EnvironmentClient. +type DefaultEnvironmentClient struct { + resourceClient kubernetes.ResourceClient + logger logging.Logger + + // Cache of environment configs + envConfigs map[string]*un.Unstructured + gvks []schema.GroupVersionKind +} + +// NewEnvironmentClient creates a new DefaultEnvironmentClient. +func NewEnvironmentClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) EnvironmentClient { + return &DefaultEnvironmentClient{ + resourceClient: resourceClient, + logger: logger, + envConfigs: make(map[string]*un.Unstructured), + } +} + +// Initialize loads environment configs into the cache. +func (c *DefaultEnvironmentClient) Initialize(ctx context.Context) error { + c.logger.Debug("Initializing environment client") + + gvks, err := c.resourceClient.GetGVKsForGroupKind(ctx, CrossplaneAPIExtGroup, "EnvironmentConfig") + if err != nil { + return errors.Wrap(err, "cannot get EnvironmentConfig GVKs") + } + + c.gvks = gvks + + // List environment configs to populate the cache + configs, err := c.GetEnvironmentConfigs(ctx) + if err != nil { + return errors.Wrap(err, "cannot list environment configs") + } + + // Store in cache + for _, config := range configs { + c.envConfigs[cacheKey("", config.GetName())] = config + } + + c.logger.Debug("Environment client initialized", "envConfigsCount", len(c.envConfigs)) + + return nil +} + +// GetEnvironmentConfigs gets all environment configurations. +func (c *DefaultEnvironmentClient) GetEnvironmentConfigs(ctx context.Context) ([]*un.Unstructured, error) { + c.logger.Debug("Getting environment configs") + + envConfigs, err := listMatchingResources(ctx, c.resourceClient, c.gvks, "" /* ECs are cluster scoped */) + if err != nil { + return nil, err + } + + c.logger.Debug("Environment configs retrieved", "count", len(envConfigs)) + + return envConfigs, nil +} + +// GetEnvironmentConfig gets a specific environment config by name. +func (c *DefaultEnvironmentClient) GetEnvironmentConfig(ctx context.Context, name string) (*un.Unstructured, error) { + c.logger.Debug("Getting environment config", "name", name) + + return getFirstMatchingResource(ctx, c.resourceClient, c.gvks, name, "" /* ECs are cluster scoped */, c.envConfigs) +} diff --git a/internal/diff/client/crossplane/environment_client_test.go b/internal/diff/client/crossplane/environment_client_test.go new file mode 100644 index 00000000..89940251 --- /dev/null +++ b/internal/diff/client/crossplane/environment_client_test.go @@ -0,0 +1,390 @@ +package crossplane + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ EnvironmentClient = (*tu.MockEnvironmentClient)(nil) + +var EnvConfigV1alpha1GVK = schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1alpha1", + Kind: "EnvironmentConfig", +} + +var EnvConfigV1beta1GVK = schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1beta1", + Kind: "EnvironmentConfig", +} + +func TestDefaultEnvironmentClient_GetEnvironmentConfigs(t *testing.T) { + ctx := t.Context() + + // Create test environment configurations + envConfig1 := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "env-config-1"). + WithSpecField("data", map[string]any{ + "key1": "value1", + }). + Build() + + envConfig2 := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "env-config-2"). + WithSpecField("data", map[string]any{ + "key2": "value2", + }). + Build() + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + want []*un.Unstructured + wantErr bool + errSubstring string + }{ + "NoConfigs": { + reason: "Should return empty slice when no environment configs exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == "apiextensions.crossplane.io" && + gvk.Version == "v1alpha1" && + gvk.Kind == "EnvironmentConfig" { + return []*un.Unstructured{}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + want: []*un.Unstructured{}, + wantErr: false, + }, + "ConfigsExist": { + reason: "Should return all environment configs when they exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == "apiextensions.crossplane.io" && + gvk.Version == "v1alpha1" && + gvk.Kind == "EnvironmentConfig" { + return []*un.Unstructured{envConfig1, envConfig2}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + want: []*un.Unstructured{envConfig1, envConfig2}, + wantErr: false, + }, + "ListError": { + reason: "Should propagate errors from the Kubernetes API", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + want: nil, + wantErr: true, + errSubstring: "cannot list items for GVK apiextensions.crossplane.io/v1alpha1, Kind=EnvironmentConfig", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultEnvironmentClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + envConfigs: make(map[string]*un.Unstructured), + // ECs have alpha1 and beta1 versions, TODO: test both + gvks: []schema.GroupVersionKind{{Group: CrossplaneAPIExtGroup, Version: "v1alpha1", Kind: "EnvironmentConfig"}}, + } + + got, err := c.GetEnvironmentConfigs(ctx) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nGetEnvironmentConfigs(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nGetEnvironmentConfigs(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetEnvironmentConfigs(): unexpected error: %v", tt.reason, err) + return + } + + // Compare the counts + if diff := cmp.Diff(len(tt.want), len(got)); diff != "" { + t.Errorf("\n%s\nGetEnvironmentConfigs(): -want count, +got count:\n%s", tt.reason, diff) + } + + // Create maps of configs by name for comparison + wantConfigs := make(map[string]*un.Unstructured) + gotConfigs := make(map[string]*un.Unstructured) + + for _, cfg := range tt.want { + wantConfigs[cfg.GetName()] = cfg + } + + for _, cfg := range got { + gotConfigs[cfg.GetName()] = cfg + } + + // Check for missing configs + for name, wantCfg := range wantConfigs { + if _, ok := gotConfigs[name]; !ok { + t.Errorf("\n%s\nGetEnvironmentConfigs(): missing config with name %s", tt.reason, name) + } else { + // Check data field for configs that exist in both lists + wantData, _, _ := un.NestedMap(wantCfg.Object, "spec", "data") + + gotData, _, _ := un.NestedMap(gotConfigs[name].Object, "spec", "data") + if diff := cmp.Diff(wantData, gotData); diff != "" { + t.Errorf("\n%s\nGetEnvironmentConfigs(): config %s data mismatch -want, +got:\n%s", tt.reason, name, diff) + } + } + } + + // Check for unexpected configs + for name := range gotConfigs { + if _, ok := wantConfigs[name]; !ok { + t.Errorf("\n%s\nGetEnvironmentConfigs(): unexpected config with name %s", tt.reason, name) + } + } + }) + } +} + +func TestDefaultEnvironmentClient_GetEnvironmentConfig(t *testing.T) { + ctx := t.Context() + + // Create test environment configuration + envConfig := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "test-env-config"). + WithSpecField("data", map[string]any{ + "key": "value", + }). + Build() + + type args struct { + name string + } + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + cachedConfig map[string]*un.Unstructured + args args + want *un.Unstructured + wantErr bool + errSubstring string + }{ + "CachedConfig": { + reason: "Should return environment config from cache when available", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + // GetResource should not be called when cache is used + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, _ string) (*un.Unstructured, error) { + t.Error("GetResource should not be called when config is in cache") + return nil, nil + }). + Build(), + cachedConfig: map[string]*un.Unstructured{ + "/cached-config": envConfig, + }, + args: args{ + name: "cached-config", + }, + want: envConfig, + wantErr: false, + }, + "FetchFromCluster": { + reason: "Should fetch environment config from cluster when not in cache", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Group == "apiextensions.crossplane.io" && + gvk.Version == "v1alpha1" && + gvk.Kind == "EnvironmentConfig" && + name == "test-env-config" { + return envConfig, nil + } + + return nil, errors.New("unexpected resource request") + }). + Build(), + cachedConfig: map[string]*un.Unstructured{}, + args: args{ + name: "test-env-config", + }, + want: envConfig, + wantErr: false, + }, + "NotFound": { + reason: "Should return error when environment config doesn't exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourceNotFound(). + Build(), + cachedConfig: map[string]*un.Unstructured{}, + args: args{ + name: "nonexistent-config", + }, + want: nil, + wantErr: true, + errSubstring: "cannot get apiextensions.crossplane.io/v1alpha1, Kind=EnvironmentConfig nonexistent-config", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultEnvironmentClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + envConfigs: tt.cachedConfig, + // ECs have alpha1 and beta1 versions, TODO: test both + gvks: []schema.GroupVersionKind{{Group: CrossplaneAPIExtGroup, Version: "v1alpha1", Kind: "EnvironmentConfig"}}, + } + + got, err := c.GetEnvironmentConfig(ctx, tt.args.name) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nGetEnvironmentConfig(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nGetEnvironmentConfig(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetEnvironmentConfig(): unexpected error: %v", tt.reason, err) + return + } + + // Check that the returned config has the right name + if got.GetName() != tt.want.GetName() { + t.Errorf("\n%s\nGetEnvironmentConfig(): want name %s, got name %s", tt.reason, tt.want.GetName(), got.GetName()) + } + + // Check that the data is correct + wantData, _, _ := un.NestedMap(tt.want.Object, "spec", "data") + + gotData, _, _ := un.NestedMap(got.Object, "spec", "data") + if diff := cmp.Diff(wantData, gotData); diff != "" { + t.Errorf("\n%s\nGetEnvironmentConfig(): config data mismatch -want, +got:\n%s", tt.reason, diff) + } + + // Verify the config was added to the cache + if _, ok := c.envConfigs["/"+tt.args.name]; !ok { + t.Errorf("\n%s\nGetEnvironmentConfig(): config not added to cache after fetch", tt.reason) + } + }) + } +} + +func TestDefaultEnvironmentClient_Initialize(t *testing.T) { + ctx := t.Context() + + // Create test environment configurations + envConfig1 := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "env-config-1").Build() + envConfig2 := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "env-config-2").Build() + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + wantErr bool + wantCached map[string]bool + }{ + "SuccessfulInitialization": { + reason: "Should successfully initialize and cache environment configs", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == "apiextensions.crossplane.io" && + gvk.Version == "v1alpha1" && + gvk.Kind == "EnvironmentConfig" { + return []*un.Unstructured{envConfig1, envConfig2}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + wantErr: false, + wantCached: map[string]bool{ + "env-config-1": true, + "env-config-2": true, + }, + }, + "NoConfigs": { + reason: "Should successfully initialize with empty cache when no configs exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). + WithEmptyListResources(). + Build(), + wantErr: false, + wantCached: map[string]bool{}, + }, + "ListError": { + reason: "Should return error when listing environment configs fails", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultEnvironmentClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + envConfigs: make(map[string]*un.Unstructured), + } + + err := c.Initialize(ctx) + + if tt.wantErr && err == nil { + t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) + return + } else if !tt.wantErr && err != nil { + t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + return + } + + // If no error expected, check the cache state + if !tt.wantErr { + for name := range tt.wantCached { + if _, ok := c.envConfigs["/"+name]; !ok { + t.Errorf("\n%s\nInitialize(): expected config %s to be cached, but it's not", tt.reason, name) + } + } + + // Check we don't have extra configs + if len(c.envConfigs) != len(tt.wantCached) { + t.Errorf("\n%s\nInitialize(): expected %d cached configs, got %d", tt.reason, len(tt.wantCached), len(c.envConfigs)) + } + } + }) + } +} diff --git a/internal/diff/client/crossplane/function_client.go b/internal/diff/client/crossplane/function_client.go new file mode 100644 index 00000000..e26beee5 --- /dev/null +++ b/internal/diff/client/crossplane/function_client.go @@ -0,0 +1,162 @@ +package crossplane + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" +) + +// FunctionClient handles operations related to Functions. +type FunctionClient interface { + core.Initializable + + // GetFunctionsFromPipeline gets functions used in a composition pipeline + GetFunctionsFromPipeline(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) + + // ListFunctions lists all functions in the cluster + ListFunctions(ctx context.Context) ([]pkgv1.Function, error) +} + +// DefaultFunctionClient implements FunctionClient. +type DefaultFunctionClient struct { + resourceClient kubernetes.ResourceClient + logger logging.Logger + + // Cache of functions + functions map[string]pkgv1.Function + gvks []schema.GroupVersionKind +} + +// NewFunctionClient creates a new DefaultFunctionClient. +func NewFunctionClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) FunctionClient { + return &DefaultFunctionClient{ + resourceClient: resourceClient, + logger: logger, + functions: make(map[string]pkgv1.Function), + } +} + +// Initialize loads functions into the cache. +func (c *DefaultFunctionClient) Initialize(ctx context.Context) error { + c.logger.Debug("Initializing function client") + + gvks, err := c.resourceClient.GetGVKsForGroupKind(ctx, CrossplanePkgGroup, FunctionKind) + if err != nil { + return errors.Wrap(err, "cannot get Function GVKs") + } + + c.gvks = gvks + + // List functions to populate the cache + fns, err := c.ListFunctions(ctx) + if err != nil { + return errors.Wrap(err, "cannot list functions") + } + + // Store in cache + for _, fn := range fns { + c.functions[fn.GetName()] = fn + } + + c.logger.Debug("Function client initialized", "functionsCount", len(c.functions)) + + return nil +} + +// ListFunctions lists all functions in the cluster. +func (c *DefaultFunctionClient) ListFunctions(ctx context.Context) ([]pkgv1.Function, error) { + c.logger.Debug("Listing functions from cluster") + + // TODO: we don't actually use our cached GVKs here -- but there's only one version of Function + // and this part is strongly typed which will make a second version hard to handle + + // Define the function GVK + gvk := schema.GroupVersionKind{ + Group: CrossplanePkgGroup, + Version: "v1", + Kind: FunctionKind, + } + + // Get all functions using the resource client + unFns, err := c.resourceClient.ListResources(ctx, gvk, "") + if err != nil { + c.logger.Debug("Failed to list functions", "error", err) + return nil, errors.Wrap(err, "cannot list functions from cluster") + } + + // Convert unstructured to typed + functions := make([]pkgv1.Function, 0, len(unFns)) + for _, obj := range unFns { + fn := pkgv1.Function{} + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, &fn) + if err != nil { + c.logger.Debug("Failed to convert function from unstructured", + "name", obj.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot convert unstructured to Function") + } + + functions = append(functions, fn) + } + + c.logger.Debug("Successfully retrieved functions", "count", len(functions)) + + return functions, nil +} + +// GetFunctionsFromPipeline gets functions used in a composition pipeline. +func (c *DefaultFunctionClient) GetFunctionsFromPipeline(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + c.logger.Debug("Getting functions from pipeline", "composition_name", comp.GetName()) + + if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { + c.logger.Debug("Composition is not in pipeline mode", + "composition_name", comp.GetName(), + "mode", func() string { + // TODO: there was nil handling here that became invalid; make sure everything works as expected + return string(comp.Spec.Mode) + }()) + + return nil, fmt.Errorf("unsupported composition Mode '%s'; supported types are [%s]", comp.Spec.Mode, apiextensionsv1.CompositionModePipeline) + // TODO: we used to check for nil, and if nil we'd say "no mode found"; is it valid to have no mode? + } + + functions := make([]pkgv1.Function, 0, len(comp.Spec.Pipeline)) + c.logger.Debug("Processing pipeline steps", "steps_count", len(comp.Spec.Pipeline)) + + for _, step := range comp.Spec.Pipeline { + fn, ok := c.functions[step.FunctionRef.Name] + if !ok { + c.logger.Debug("Function not found", + "step", step.Step, + "function_name", step.FunctionRef.Name) + + return nil, errors.Errorf("function %q referenced in pipeline step %q not found", step.FunctionRef.Name, step.Step) + } + + c.logger.Debug("Found function for step", + "step", step.Step, + "function_name", fn.GetName()) + // A copy, so a caller that modifies what it gets (a function's annotations, its package) + // does not modify this cache, which concurrent callers share. + functions = append(functions, *fn.DeepCopy()) + } + + c.logger.Debug("Retrieved functions from pipeline", + "functions_count", len(functions), + "composition_name", comp.GetName()) + + return functions, nil +} diff --git a/internal/diff/client/crossplane/function_client_test.go b/internal/diff/client/crossplane/function_client_test.go new file mode 100644 index 00000000..ce612206 --- /dev/null +++ b/internal/diff/client/crossplane/function_client_test.go @@ -0,0 +1,621 @@ +package crossplane + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ FunctionClient = (*tu.MockFunctionClient)(nil) + +// Fix for the FunctionClient tests. +func TestDefaultFunctionClient_GetFunctionsFromPipeline(t *testing.T) { + pipelineMode := apiextensionsv1.CompositionModePipeline + nonPipelineMode := apiextensionsv1.CompositionMode("NonPipeline") + + type fields struct { + functions map[string]pkgv1.Function + } + + type args struct { + comp *apiextensionsv1.Composition + } + + type want struct { + functions []pkgv1.Function + err error + } + + tests := map[string]struct { + reason string + fields fields + mockResource *tu.MockResourceClient + args args + want want + }{ + "NonPipelineMode": { + reason: "Should throw an error when composition is not in pipeline mode", + fields: fields{ + functions: map[string]pkgv1.Function{}, + }, + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + args: args{ + comp: &apiextensionsv1.Composition{ + Spec: apiextensionsv1.CompositionSpec{ + Mode: nonPipelineMode, + }, + }, + }, + want: want{ + err: errors.New("unsupported composition Mode 'NonPipeline'; supported types are [Pipeline]"), + }, + }, + // "NoModeSpecified": { // illegal state? + // reason: "Should throw an error when composition mode is not specified", + // fields: fields{ + // functions: map[string]pkgv1.Function{}, + // }, + // mockResource: tu.NewMockResourceClient(). + // WithSuccessfulInitialize(). + // Build(), + // args: args{ + // comp: &apiextensionsv1.Composition{ + // Spec: apiextensionsv1.CompositionSpec{ + // Mode: nil, + // }, + // }, + // }, + // want: want{ + // err: errors.New("unsupported Composition; no Mode found"), + // }, + // }, + "EmptyPipeline": { + reason: "Should return empty slice for empty pipeline", + fields: fields{ + functions: map[string]pkgv1.Function{}, + }, + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + args: args{ + comp: &apiextensionsv1.Composition{ + Spec: apiextensionsv1.CompositionSpec{ + Mode: pipelineMode, + Pipeline: []apiextensionsv1.PipelineStep{}, + }, + }, + }, + want: want{ + functions: []pkgv1.Function{}, + }, + }, + "MissingFunction": { + reason: "Should return error when a function is missing", + fields: fields{ + functions: map[string]pkgv1.Function{ + "function-a": { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-a", + }, + }, + }, + }, + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + args: args{ + comp: &apiextensionsv1.Composition{ + Spec: apiextensionsv1.CompositionSpec{ + Mode: pipelineMode, + Pipeline: []apiextensionsv1.PipelineStep{ + { + Step: "step-a", + FunctionRef: apiextensionsv1.FunctionReference{Name: "function-a"}, + }, + { + Step: "step-b", + FunctionRef: apiextensionsv1.FunctionReference{Name: "function-b"}, + }, + }, + }, + }, + }, + want: want{ + err: errors.Errorf("function %q referenced in pipeline step %q not found", "function-b", "step-b"), + }, + }, + "AllFunctionsFound": { + reason: "Should return all functions referenced in the pipeline", + fields: fields{ + functions: map[string]pkgv1.Function{ + "function-a": { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-a", + }, + }, + "function-b": { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-b", + }, + }, + }, + }, + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + args: args{ + // Use Composition builder to create a composition with pipeline steps + comp: tu.NewComposition("test-comp"). + WithPipelineMode(). + WithPipelineStep("step-a", "function-a", nil). + WithPipelineStep("step-b", "function-b", nil). + Build(), + }, + want: want{ + functions: []pkgv1.Function{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-a", + }, + }, + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-b", + }, + }, + }, + }, + }, + "DuplicateFunctionRefs": { + reason: "Should handle pipeline steps that reference the same function", + fields: fields{ + functions: map[string]pkgv1.Function{ + "function-a": { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-a", + }, + }, + }, + }, + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + Build(), + args: args{ + // Use Composition builder for a composition with duplicate function references + comp: tu.NewComposition("test-comp"). + WithPipelineMode(). + WithPipelineStep("step-a", "function-a", nil). + WithPipelineStep("step-b", "function-a", nil). + Build(), + }, + want: want{ + functions: []pkgv1.Function{ + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-a", + }, + }, + { + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-a", + }, + }, + }, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultFunctionClient{ + resourceClient: tt.mockResource, + functions: tt.fields.functions, + logger: tu.TestLogger(t, false), + } + + got, err := c.GetFunctionsFromPipeline(tt.args.comp) + + if tt.want.err != nil { + if err == nil { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): expected error but got none", tt.reason) + return + } + + if diff := cmp.Diff(tt.want.err.Error(), err.Error()); diff != "" { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): -want error, +got error:\n%s", tt.reason, diff) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): unexpected error: %v", tt.reason, err) + return + } + + if tt.want.functions == nil { + if got != nil { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): expected nil functions, got %v", tt.reason, got) + } + + return + } + + if diff := cmp.Diff(len(tt.want.functions), len(got)); diff != "" { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): -want function count, +got function count:\n%s", tt.reason, diff) + } + + // Check each function matches what we expect + for i, wantFn := range tt.want.functions { + if i >= len(got) { + break + } + + if diff := cmp.Diff(wantFn.GetName(), got[i].GetName()); diff != "" { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): -want function name, +got function name at index %d:\n%s", tt.reason, i, diff) + } + } + }) + } +} + +func TestDefaultFunctionClient_ListFunctions(t *testing.T) { + ctx := t.Context() + + // Create test functions + fn1 := pkgv1.Function{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-1", + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "registry.upbound.io/crossplane/function-patch:v0.1.0", + }, + }, + } + + fn2 := pkgv1.Function{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-2", + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "registry.upbound.io/crossplane/function-go-templating:v0.1.0", + }, + }, + } + + // Convert to unstructured for testing + u1 := &un.Unstructured{} + obj1, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&fn1) + u1.SetUnstructuredContent(obj1) + u1.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "pkg.crossplane.io", + Version: "v1", + Kind: "Function", + }) + + u2 := &un.Unstructured{} + obj2, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&fn2) + u2.SetUnstructuredContent(obj2) + u2.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "pkg.crossplane.io", + Version: "v1", + Kind: "Function", + }) + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + want []pkgv1.Function + wantErr bool + errSubstring string + }{ + "SuccessfulList": { + reason: "Should return functions when list succeeds", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == "pkg.crossplane.io" && gvk.Kind == "Function" { + return []*un.Unstructured{u1, u2}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + want: []pkgv1.Function{fn1, fn2}, + wantErr: false, + }, + "EmptyList": { + reason: "Should return empty list when no functions exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithEmptyListResources(). + Build(), + want: []pkgv1.Function{}, + wantErr: false, + }, + "ListError": { + reason: "Should return error when list fails", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + want: nil, + wantErr: true, + errSubstring: "cannot list functions from cluster", + }, + "ConversionError": { + reason: "Should return error when conversion fails", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + // Create an invalid unstructured that will fail conversion + invalid := &un.Unstructured{} + invalid.SetGroupVersionKind(gvk) + invalid.SetName("invalid") + // Put invalid data to force conversion failure + invalid.Object["spec"] = 123 // This will cause conversion to fail since spec should be a map + + return []*un.Unstructured{invalid}, nil + }). + Build(), + want: nil, + wantErr: true, + errSubstring: "cannot convert unstructured to Function", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultFunctionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + functions: make(map[string]pkgv1.Function), + } + + got, err := c.ListFunctions(ctx) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nListFunctions(): expected error but got none", tt.reason) + return + } + + if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { + t.Errorf("\n%s\nListFunctions(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nListFunctions(): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(len(tt.want), len(got)); diff != "" { + t.Errorf("\n%s\nListFunctions(): -want count, +got count:\n%s", tt.reason, diff) + } + + // Verify functions are returned correctly + if len(got) > 0 { + // Create maps of function names for comparison + wantFns := make(map[string]bool) + gotFns := make(map[string]bool) + + for _, fn := range tt.want { + wantFns[fn.GetName()] = true + } + + for _, fn := range got { + gotFns[fn.GetName()] = true + } + + // Check for missing functions + for name := range wantFns { + if !gotFns[name] { + t.Errorf("\n%s\nListFunctions(): missing expected function with name %s", tt.reason, name) + } + } + + // Check for unexpected functions + for name := range gotFns { + if !wantFns[name] { + t.Errorf("\n%s\nListFunctions(): unexpected function with name %s", tt.reason, name) + } + } + } + }) + } +} + +func TestDefaultFunctionClient_Initialize(t *testing.T) { + ctx := t.Context() + + // Create test functions + fn1 := pkgv1.Function{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-1", + }, + } + + fn2 := pkgv1.Function{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-2", + }, + } + + // Convert to unstructured for testing + u1 := &un.Unstructured{} + obj1, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&fn1) + u1.SetUnstructuredContent(obj1) + u1.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "pkg.crossplane.io", + Version: "v1", + Kind: "Function", + }) + + u2 := &un.Unstructured{} + obj2, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&fn2) + u2.SetUnstructuredContent(obj2) + u2.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "pkg.crossplane.io", + Version: "v1", + Kind: "Function", + }) + + tests := map[string]struct { + reason string + mockResource tu.MockResourceClient + wantErr bool + wantCached map[string]bool + }{ + "SuccessfulInitialization": { + reason: "Should successfully initialize and cache functions", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk.Group == "pkg.crossplane.io" && gvk.Kind == "Function" { + return []*un.Unstructured{u1, u2}, nil + } + + return nil, errors.New("unexpected GVK") + }). + Build(), + wantErr: false, + wantCached: map[string]bool{ + "function-1": true, + "function-2": true, + }, + }, + "NoFunctions": { + reason: "Should successfully initialize with empty cache when no functions exist", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). + WithEmptyListResources(). + Build(), + wantErr: false, + wantCached: map[string]bool{}, + }, + "ResourceClientInitFailed": { + reason: "Should return error when resource client initialization fails", + mockResource: *tu.NewMockResourceClient(). + WithInitialize(func(_ context.Context) error { + return errors.New("init error") + }). + Build(), + wantErr: true, + }, + "ListFunctionsFailed": { + reason: "Should return error when listing functions fails", + mockResource: *tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithListResourcesFailure("list error"). + Build(), + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultFunctionClient{ + resourceClient: &tt.mockResource, + logger: tu.TestLogger(t, false), + functions: make(map[string]pkgv1.Function), + } + + err := c.Initialize(ctx) + + if tt.wantErr { + if err == nil { + t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + return + } + + // If successful, check the cache state + for name := range tt.wantCached { + if _, ok := c.functions[name]; !ok { + t.Errorf("\n%s\nInitialize(): expected function %s to be cached, but it's not", tt.reason, name) + } + } + + // Check we don't have extra functions + if len(c.functions) != len(tt.wantCached) { + t.Errorf("\n%s\nInitialize(): expected %d cached functions, got %d", tt.reason, len(tt.wantCached), len(c.functions)) + } + }) + } +} diff --git a/internal/diff/client/crossplane/resource_tree_client.go b/internal/diff/client/crossplane/resource_tree_client.go new file mode 100644 index 00000000..fef08001 --- /dev/null +++ b/internal/diff/client/crossplane/resource_tree_client.go @@ -0,0 +1,75 @@ +package crossplane + +import ( + "context" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xrm" + "github.com/crossplane/cli/v2/internal/diff/client/core" +) + +// ResourceTreeClient handles resource tree operations. +type ResourceTreeClient interface { + core.Initializable + + // GetResourceTree gets the resource tree for a root resource + GetResourceTree(ctx context.Context, root *un.Unstructured) (*resource.Resource, error) +} + +// DefaultResourceTreeClient implements ResourceTreeClient. +type DefaultResourceTreeClient struct { + treeClient *xrm.Client + logger logging.Logger +} + +// NewResourceTreeClient creates a new DefaultResourceTreeClient. +func NewResourceTreeClient(treeClient *xrm.Client, logger logging.Logger) ResourceTreeClient { + return &DefaultResourceTreeClient{ + treeClient: treeClient, + logger: logger, + } +} + +// Initialize initializes the resource tree client. +func (c *DefaultResourceTreeClient) Initialize(_ context.Context) error { + c.logger.Debug("Initializing resource tree client") + // No initialization needed currently + return nil +} + +// GetResourceTree gets the resource tree for a root resource. +func (c *DefaultResourceTreeClient) GetResourceTree(ctx context.Context, root *un.Unstructured) (*resource.Resource, error) { + c.logger.Debug("Getting resource tree", + "resource_kind", root.GetKind(), + "resource_name", root.GetName(), + "resource_uid", root.GetUID()) + + // Convert to resource.Resource for the XRM client + res := &resource.Resource{ + Unstructured: *root, + } + + tree, err := c.treeClient.GetResourceTree(ctx, res) + if err != nil { + c.logger.Debug("Failed to get resource tree", + "resource_kind", root.GetKind(), + "resource_name", root.GetName(), + "error", err) + + return nil, errors.Wrap(err, "failed to get resource tree") + } + + // Count children for logging + childCount := len(tree.Children) + c.logger.Debug("Retrieved resource tree", + "resource_kind", root.GetKind(), + "resource_name", root.GetName(), + "child_count", childCount) + + return tree, nil +} diff --git a/internal/diff/client/crossplane/resource_tree_client_test.go b/internal/diff/client/crossplane/resource_tree_client_test.go new file mode 100644 index 00000000..d2be76f6 --- /dev/null +++ b/internal/diff/client/crossplane/resource_tree_client_test.go @@ -0,0 +1,6 @@ +package crossplane + +// This file intentionally left blank. The ResourceTreeClient is a thin shim around the xrm.Client struct. We +// went to construct an interface around the xrm.Client in order to test this class, but it turned out to be the +// same interface that this one provides. No real logic in ResourceTreeClient except for converting to unstructured +// and logging. diff --git a/internal/diff/client/crossplane/revision_selector.go b/internal/diff/client/crossplane/revision_selector.go new file mode 100644 index 00000000..8d3fdcad --- /dev/null +++ b/internal/diff/client/crossplane/revision_selector.go @@ -0,0 +1,170 @@ +package crossplane + +import ( + "fmt" + "maps" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// XRRevisionLabelSelector returns the effective label selector Crossplane would use to select a +// CompositionRevision for the given XR/Claim, as a labels.Selector ready to evaluate against a +// revision's (or composition's) labels. +// +// Crossplane only consults compositionRevisionSelector under the Automatic update policy (see +// internal/controller/apiextensions/composite/api.go): under Manual policy the revision is pinned +// via compositionRevisionRef, so the selector does not restrict the match. Accordingly this returns +// labels.Everything() when the policy is non-Automatic, or when no compositionRevisionSelector is +// present. Otherwise it returns the parsed selector, honoring both matchLabels and matchExpressions. +// +// Both the v2 path (spec.crossplane.compositionRevisionSelector) and the legacy v1 path +// (spec.compositionRevisionSelector) are read, v2 preferred. +func XRRevisionLabelSelector(xr *un.Unstructured) (labels.Selector, error) { + selector, _, err := xrRevisionSelector(xr) + return selector, err +} + +// xrRevisionSelector parses the XR's compositionRevisionSelector exactly once, returning both the +// compiled labels.Selector (for matching) and the raw *metav1.LabelSelector (for introspection in +// mismatch messages). The raw selector is nil when there is no effective restriction (non-Automatic +// policy or absent selector), in which case the compiled selector is labels.Everything(). +func xrRevisionSelector(xr *un.Unstructured) (labels.Selector, *metav1.LabelSelector, error) { + // Selector only governs revision selection under Automatic policy. + policy, err := XRUpdatePolicy(xr.Object, xr.GetAPIVersion()) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot read compositionUpdatePolicy") + } + + if policy != updatePolicyAutomatic { + return labels.Everything(), nil, nil + } + + selectorMap, found, err := nestedCrossplaneMap(xr.Object, xr.GetAPIVersion(), "compositionRevisionSelector") + if err != nil { + return nil, nil, errors.Wrap(err, "cannot read compositionRevisionSelector") + } + + if !found { + // No selector => no restriction. + return labels.Everything(), nil, nil + } + + sel, err := labelSelectorFromUnstructuredMap(selectorMap) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot parse compositionRevisionSelector") + } + + selector, err := metav1.LabelSelectorAsSelector(sel) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot convert compositionRevisionSelector to selector") + } + + return selector, sel, nil +} + +// XRRevisionSelectorMatch reports whether the XR's compositionRevisionSelector matches the given set +// of labels (the labels of a Composition or CompositionRevision), and — on a mismatch — a +// human-readable detail explaining it, for surfacing in diff output so users can fine-tune their +// selector. The selector is parsed exactly once here, so any parse/read failure is returned as the +// single authoritative error (no best-effort duplicate parsing elsewhere). +// +// See XRRevisionLabelSelector for the Automatic-only semantics; a non-Automatic policy or an absent +// selector always matches (and yields an empty detail). +// +// The detail displays the composition's own labels (compLabels — the tunable menu a user can edit) +// plus any additional label the selector explicitly references (e.g. a Crossplane-stamped +// crossplane.io/composition-name the user chose to select on). Labels that are neither user-set nor +// selector-referenced (the implied composition-name/-hash in the common case) are omitted so the +// hint shows only load-bearing, actionable labels. +func XRRevisionSelectorMatch(xr *un.Unstructured, targetLabels, compLabels map[string]string) (matches bool, detail string, err error) { + selector, sel, err := xrRevisionSelector(xr) + if err != nil { + return false, "", err + } + + if selector.Matches(labels.Set(targetLabels)) { + return true, "", nil + } + + // sel is nil when the policy is non-Automatic or no selector is present; in those cases the + // selector is labels.Everything(), which matches everything, so we never reach here with sel==nil. + return false, describeSelectorMismatch(sel, targetLabels, compLabels), nil +} + +// describeSelectorMismatch renders the "compositionRevisionSelector X does not match composition +// labels Y" hint. The displayed label set is the union of the user's composition labels and any +// label the selector references (load-bearing labels), so stamped-but-unselected labels stay out of +// the message. Rendering uses the k8s-canonical selector/label String() forms. +func describeSelectorMismatch(sel *metav1.LabelSelector, targetLabels, compLabels map[string]string) string { + display := labels.Set{} + maps.Copy(display, compLabels) + + // Surface any label the selector keys on, even if it isn't one of the user's composition labels + // (e.g. a selector that explicitly matches crossplane.io/composition-name). These are load-bearing + // for the match, so the user needs to see their actual values to fine-tune. + for _, req := range selectorReferencedKeys(sel) { + if v, ok := targetLabels[req]; ok { + display[req] = v + } + } + + selector, err := metav1.LabelSelectorAsSelector(sel) + if err != nil { + // Unreachable in practice: sel already parsed cleanly in xrRevisionSelector before we got here. + return "compositionRevisionSelector could not be rendered" + } + + return fmt.Sprintf("compositionRevisionSelector %s does not match composition labels {%s}", + selector.String(), display.String()) +} + +// selectorReferencedKeys returns the label keys a LabelSelector constrains, via both matchLabels and +// matchExpressions. +func selectorReferencedKeys(sel *metav1.LabelSelector) []string { + keys := make([]string, 0, len(sel.MatchLabels)+len(sel.MatchExpressions)) + for k := range sel.MatchLabels { + keys = append(keys, k) + } + + for _, req := range sel.MatchExpressions { + keys = append(keys, req.Key) + } + + return keys +} + +// XRUpdatePolicy reads the compositionUpdatePolicy from an XR/Claim spec object, checking the v2 +// path (spec.crossplane.compositionUpdatePolicy) then the v1 path (spec.compositionUpdatePolicy), +// selected from apiVersion. Defaults to "Automatic" when unset, matching Crossplane's default +// behavior. A non-nil error means the field is present but malformed (not a string); callers that +// prioritize accuracy propagate it rather than silently defaulting. +// +// This is the single shared reader for compositionUpdatePolicy across the codebase. +func XRUpdatePolicy(obj map[string]any, apiVersion string) (string, error) { + policy, found, err := nestedCrossplaneString(obj, apiVersion, "compositionUpdatePolicy") + if err != nil { + return "", err + } + + if found && policy != "" { + return policy, nil + } + + return updatePolicyAutomatic, nil +} + +// labelSelectorFromUnstructuredMap converts an unstructured compositionRevisionSelector map (as +// stored on an XR spec) into a typed metav1.LabelSelector via runtime conversion. +func labelSelectorFromUnstructuredMap(m map[string]any) (*metav1.LabelSelector, error) { + sel := &metav1.LabelSelector{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(m, sel); err != nil { + return nil, err + } + + return sel, nil +} diff --git a/internal/diff/client/crossplane/revision_selector_test.go b/internal/diff/client/crossplane/revision_selector_test.go new file mode 100644 index 00000000..bddc3505 --- /dev/null +++ b/internal/diff/client/crossplane/revision_selector_test.go @@ -0,0 +1,257 @@ +package crossplane + +import ( + "strings" + "testing" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +// TestXRRevisionSelectorMatch_Matches verifies the boolean match decision made by +// XRRevisionSelectorMatch for an XR's compositionRevisionSelector against a target label set. +// +// Crossplane only consults compositionRevisionSelector under the Automatic update policy +// (internal/controller/apiextensions/composite/api.go). Under Manual policy the revision is +// pinned via compositionRevisionRef, so the selector must NOT restrict the match here. +func TestXRRevisionSelectorMatch_Matches(t *testing.T) { + tests := map[string]struct { + xr *un.Unstructured + targetLabels map[string]string + want bool + wantErr bool + }{ + // No selector => matches (no restriction), regardless of policy. + "NoSelector_Automatic_Matches": { + xr: tu.NewResource("example.org/v1", "XResource", "no-selector"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + want: true, + }, + "NoSelector_NoPolicy_Matches": { + xr: tu.NewResource("example.org/v1", "XResource", "no-selector-no-policy"). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + want: true, + }, + // Automatic + matchLabels subset of composition labels => matches. + "Automatic_MatchLabelsSubset_Matches": { + xr: tu.NewResource("example.org/v1", "XResource", "match"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + map[string]string{"version": "0.0.2"}, + nil, + ). + Build(), + targetLabels: map[string]string{"version": "0.0.2", "extra": "ignored"}, + want: true, + }, + // Automatic + matchLabels not satisfied => does not match. + "Automatic_MatchLabelsMismatch_DoesNotMatch": { + xr: tu.NewResource("example.org/v1", "XResource", "mismatch"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + map[string]string{"version": "0.0.1"}, + nil, + ). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + want: false, + }, + // matchExpressions honored (In / NotIn / Exists / DoesNotExist). + "Automatic_MatchExpressionsIn_Matches": { + xr: tu.NewResource("example.org/v1", "XResource", "expr-in"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + nil, + []map[string]any{ + {"key": "channel", "operator": "In", "values": []any{"active", "beta"}}, + }, + ). + Build(), + targetLabels: map[string]string{"channel": "active"}, + want: true, + }, + "Automatic_MatchExpressionsNotIn_DoesNotMatch": { + xr: tu.NewResource("example.org/v1", "XResource", "expr-notin"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + nil, + []map[string]any{ + {"key": "channel", "operator": "NotIn", "values": []any{"preview"}}, + }, + ). + Build(), + targetLabels: map[string]string{"channel": "preview"}, + want: false, + }, + // The v1 path is read as well. + "Automatic_V1Path_MatchLabels_Matches": { + xr: tu.NewResource("example.org/v1", "XResource", "v1-path"). + WithNestedField("Automatic", "spec", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV1, + map[string]string{"version": "0.0.2"}, + nil, + ). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + want: true, + }, + // Manual policy => selector not applied by this predicate (returns matches=true + // so Manual XRs are governed only by the policy filter / --include-manual). + "Manual_MismatchingSelector_StillMatches": { + xr: tu.NewResource("example.org/v1", "XResource", "manual-mismatch"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + map[string]string{"version": "0.0.1"}, + nil, + ). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + want: true, + }, + // Empty selector (no matchLabels, no matchExpressions) => matches everything. + "Automatic_EmptySelector_Matches": { + xr: tu.NewResource("example.org/v1", "XResource", "empty-selector"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + map[string]string{}, + nil, + ). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + want: true, + }, + // Nil target labels with a matchLabels selector => cannot match. + "Automatic_MatchLabels_NilTargetLabels_DoesNotMatch": { + xr: tu.NewResource("example.org/v1", "XResource", "nil-target"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector( + CrossplaneAPIExtGroupV2, + map[string]string{"version": "0.0.2"}, + nil, + ). + Build(), + targetLabels: nil, + want: false, + }, + // A malformed selector (a scalar where an object is expected) must be a hard error, not + // silently treated as "no selector" (which would wrongly match everything). Accuracy first. + "Automatic_MalformedSelector_NotAnObject_Errors": { + xr: tu.NewResource("example.org/v1", "XResource", "malformed-selector"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithNestedField("not-an-object", "spec", "crossplane", "compositionRevisionSelector"). + Build(), + targetLabels: map[string]string{"version": "0.0.2"}, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // compLabels only affects the mismatch detail string, not the boolean; pass nil here. + got, _, err := XRRevisionSelectorMatch(tt.xr, tt.targetLabels, nil) + + if tt.wantErr { + if err == nil { + t.Fatalf("XRRevisionSelectorMatch() expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("XRRevisionSelectorMatch() unexpected error: %v", err) + } + + if got != tt.want { + t.Errorf("XRRevisionSelectorMatch() matches = %v, want %v", got, tt.want) + } + }) + } +} + +// TestXRRevisionSelectorMatch_Detail verifies the human-readable mismatch detail returned by +// XRRevisionSelectorMatch. The displayed "composition labels" are the union of the user's own +// composition labels (compLabels — the tunable menu) and any label the selector references, so a +// Crossplane-stamped label like crossplane.io/composition-name is shown only when it's load-bearing +// for the match. Matching itself uses the full targetLabels set. +func TestXRRevisionSelectorMatch_Detail(t *testing.T) { + // targetLabels mirror predictedRevisionLabels: the user's composition labels plus the stamped + // composition-name label. compLabels are the user's own composition labels. + targetLabels := map[string]string{ + "version": "0.0.2", + LabelCompositionName: "xnopresources.example.org", + } + compLabels := map[string]string{"version": "0.0.2"} + + tests := map[string]struct { + xr *un.Unstructured + wantMatch bool + wantContains []string + wantOmits []string + }{ + // A selector not referencing composition-name: the hint shows the user's composition labels + // (version=0.0.2) and the selector (version=0.0.1), but omits the stamped composition-name. + "MatchLabelsSelector_HidesStampedNameLabel": { + xr: tu.NewResource("example.org/v1", "XResource", "sel"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.1"}, nil). + Build(), + wantMatch: false, + wantContains: []string{"version=0.0.1", "version=0.0.2"}, + wantOmits: []string{LabelCompositionName}, + }, + // A selector that references composition-name is load-bearing, so it's surfaced with its value. + "SelectorReferencesNameLabel_ShowsIt": { + xr: tu.NewResource("example.org/v1", "XResource", "sel-name"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(CrossplaneAPIExtGroupV2, map[string]string{LabelCompositionName: "other"}, nil). + Build(), + wantMatch: false, + wantContains: []string{LabelCompositionName, "xnopresources.example.org"}, + }, + // No selector => matches => empty detail. + "NoSelector_Matches_EmptyDetail": { + xr: tu.NewResource("example.org/v1", "XResource", "no-sel"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + wantMatch: true, + wantOmits: []string{"compositionRevisionSelector"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + matches, detail, err := XRRevisionSelectorMatch(tt.xr, targetLabels, compLabels) + if err != nil { + t.Fatalf("XRRevisionSelectorMatch() unexpected error: %v", err) + } + + if matches != tt.wantMatch { + t.Errorf("XRRevisionSelectorMatch() matches = %v, want %v", matches, tt.wantMatch) + } + + for _, want := range tt.wantContains { + if !strings.Contains(detail, want) { + t.Errorf("expected detail to contain %q, got %q", want, detail) + } + } + + for _, omit := range tt.wantOmits { + if strings.Contains(detail, omit) { + t.Errorf("expected detail to omit %q, got %q", omit, detail) + } + } + }) + } +} From 49bca29f2da9707b1e7cb285c93014cdf0c178f3 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:54:39 -0400 Subject: [PATCH 009/126] feat(diff): port diffprocessor from crossplane-diff 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 Signed-off-by: Jonathan Ogilvie --- go.mod | 3 +- go.sum | 2 + internal/diff/diffprocessor/comp_processor.go | 1210 +++++ .../diff/diffprocessor/comp_processor_test.go | 2126 ++++++++ .../diff/diffprocessor/composite_resolver.go | 370 ++ .../diffprocessor/composite_resolver_test.go | 529 ++ internal/diff/diffprocessor/defaulter.go | 130 + internal/diff/diffprocessor/defaulter_test.go | 250 + .../diff/diffprocessor/diff_calculator.go | 844 ++++ .../diffprocessor/diff_calculator_test.go | 2470 ++++++++++ internal/diff/diffprocessor/diff_processor.go | 1444 ++++++ .../diff/diffprocessor/diff_processor_test.go | 4255 +++++++++++++++++ internal/diff/diffprocessor/errors.go | 380 ++ internal/diff/diffprocessor/errors_test.go | 871 ++++ .../diff/diffprocessor/function_provider.go | 285 ++ .../diffprocessor/function_provider_test.go | 505 ++ .../diff/diffprocessor/input_validator.go | 522 ++ .../diffprocessor/input_validator_test.go | 600 +++ internal/diff/diffprocessor/preserve.go | 42 + internal/diff/diffprocessor/preserve_test.go | 167 + .../diff/diffprocessor/processor_config.go | 567 +++ .../diffprocessor/processor_config_test.go | 378 ++ internal/diff/diffprocessor/render_engine.go | 493 ++ .../diff/diffprocessor/render_engine_test.go | 1007 ++++ internal/diff/diffprocessor/render_version.go | 176 + .../diff/diffprocessor/render_version_test.go | 305 ++ .../diffprocessor/requirements_provider.go | 337 ++ .../requirements_provider_test.go | 476 ++ .../diff/diffprocessor/resource_manager.go | 756 +++ .../diffprocessor/resource_manager_test.go | 1523 ++++++ .../diff/diffprocessor/schema_validator.go | 479 ++ .../diffprocessor/schema_validator_test.go | 1056 ++++ internal/diff/diffprocessor/warnings.go | 133 + internal/diff/diffprocessor/warnings_test.go | 198 + nix/vendor-hashes.nix | 2 +- 35 files changed, 24889 insertions(+), 2 deletions(-) create mode 100644 internal/diff/diffprocessor/comp_processor.go create mode 100644 internal/diff/diffprocessor/comp_processor_test.go create mode 100644 internal/diff/diffprocessor/composite_resolver.go create mode 100644 internal/diff/diffprocessor/composite_resolver_test.go create mode 100644 internal/diff/diffprocessor/defaulter.go create mode 100644 internal/diff/diffprocessor/defaulter_test.go create mode 100644 internal/diff/diffprocessor/diff_calculator.go create mode 100644 internal/diff/diffprocessor/diff_calculator_test.go create mode 100644 internal/diff/diffprocessor/diff_processor.go create mode 100644 internal/diff/diffprocessor/diff_processor_test.go create mode 100644 internal/diff/diffprocessor/errors.go create mode 100644 internal/diff/diffprocessor/errors_test.go create mode 100644 internal/diff/diffprocessor/function_provider.go create mode 100644 internal/diff/diffprocessor/function_provider_test.go create mode 100644 internal/diff/diffprocessor/input_validator.go create mode 100644 internal/diff/diffprocessor/input_validator_test.go create mode 100644 internal/diff/diffprocessor/preserve.go create mode 100644 internal/diff/diffprocessor/preserve_test.go create mode 100644 internal/diff/diffprocessor/processor_config.go create mode 100644 internal/diff/diffprocessor/processor_config_test.go create mode 100644 internal/diff/diffprocessor/render_engine.go create mode 100644 internal/diff/diffprocessor/render_engine_test.go create mode 100644 internal/diff/diffprocessor/render_version.go create mode 100644 internal/diff/diffprocessor/render_version_test.go create mode 100644 internal/diff/diffprocessor/requirements_provider.go create mode 100644 internal/diff/diffprocessor/requirements_provider_test.go create mode 100644 internal/diff/diffprocessor/resource_manager.go create mode 100644 internal/diff/diffprocessor/resource_manager_test.go create mode 100644 internal/diff/diffprocessor/schema_validator.go create mode 100644 internal/diff/diffprocessor/schema_validator_test.go create mode 100644 internal/diff/diffprocessor/warnings.go create mode 100644 internal/diff/diffprocessor/warnings_test.go diff --git a/go.mod b/go.mod index b3c530a1..56454780 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 dario.cat/mergo v1.0.2 + github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/kong v1.16.1 github.com/charmbracelet/x/ansi v0.11.8 @@ -42,6 +43,7 @@ require ( github.com/willabides/kongplete v0.4.0 golang.org/x/mod v0.40.0 golang.org/x/sync v0.22.0 + golang.org/x/sys v0.48.0 golang.org/x/text v0.41.0 golang.org/x/tools v0.49.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260803160001-6ac0973c030d @@ -313,7 +315,6 @@ require ( golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.48.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/time v0.15.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 236321ea..d2001e5f 100644 --- a/go.sum +++ b/go.sum @@ -85,6 +85,8 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go new file mode 100644 index 00000000..8c4f70a4 --- /dev/null +++ b/internal/diff/diffprocessor/comp_processor.go @@ -0,0 +1,1210 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + "fmt" + "maps" + "os" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + "github.com/crossplane/cli/v2/internal/diff/ref" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +// XRDiffResult captures the result of processing a single XR against a composition. +type XRDiffResult struct { + // Diffs contains the downstream resource diffs for this XR (keyed by resource ID). + // Empty map means no changes detected. + Diffs map[string]*dt.ResourceDiff + // Error contains any error that occurred while processing this XR. + // nil means processing was successful. + Error error +} + +// HasChanges returns true if this XR has downstream resource changes. +func (r *XRDiffResult) HasChanges() bool { + // Count only non-equal diffs as "having changes". + // The diffs map may contain DiffTypeEqual entries (e.g., XR stored for removal detection). + for _, diff := range r.Diffs { + if diff.DiffType != dt.DiffTypeEqual { + return true + } + } + + return false +} + +// HasError returns true if this XR encountered a processing error. +func (r *XRDiffResult) HasError() bool { + return r.Error != nil +} + +// CompDiffProcessor defines the interface for composition diffing. +type CompDiffProcessor interface { + // DiffComposition processes composition changes and shows impact on existing XRs. + // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. + // When `resources` is non-empty, impact analysis is restricted to the named composites: + // each ref is resolved against every supplied composition's (XR GVK, claim GVK) pair via a + // preflight pass. If any ref is relevant to no supplied composition, the call fails before + // rendering any diffs (CLI input error). When `resources` is empty, behavior is unchanged. + DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (bool, error) + Initialize(ctx context.Context) error + // Cleaner releases any resources held by the processor (e.g., Docker containers). + Cleaner +} + +// DefaultCompDiffProcessor implements CompDiffProcessor. +type DefaultCompDiffProcessor struct { + compositionClient xp.CompositionClient + config ProcessorConfig + xrProc DiffProcessor + compDiffRenderer renderer.CompDiffRenderer +} + +// NewCompDiffProcessor creates a new DefaultCompDiffProcessor. +func NewCompDiffProcessor(xrProc DiffProcessor, compositionClient xp.CompositionClient, opts ...ProcessorOption) CompDiffProcessor { + // Create default configuration + config := ProcessorConfig{ + Colorize: true, + Compact: false, + MaxNestedDepth: DefaultMaxNestedDepth, + MaxRenderIterations: DefaultMaxRenderIterations, + Stderr: os.Stderr, + Logger: logging.NewNopLogger(), + } + + // Apply all provided options + for _, option := range opts { + option(&config) + } + + if config.Advisories == nil { + config.Advisories = NewAdvisoryCollector(config.Stderr) + } + + // NOTE: We intentionally do NOT default config.RenderFunc here. + // + // WithRenderFunc is operation-scoped: the same opts slice flows into + // NewDiffProcessor for our embedded xrProc, so a caller-supplied renderer is + // already honored at the layer that actually drives rendering. Comp's copy of + // config.RenderFunc just rides along unused, which is fine. + // + // What we must NOT do is *default* one here. NewEngineRenderFn allocates a + // Docker bridge network and reserves function-runtime addresses on first use, + // and DefaultCompDiffProcessor.Cleanup delegates to xrProc.Cleanup — it has no + // hook for tearing down a comp-side engineRenderFn. Defaulting one would leak + // the network for the lifetime of the process. The xr processor handles its + // own default + cleanup in NewDiffProcessor, so comp piggybacking on + // xrProc.Render is the correct (and only) path. + + // Set default factories if not provided + config.SetDefaultFactories() + + // Create diff renderer first (needed by DefaultCompDiffRenderer for human-readable output) + diffOpts := config.GetDiffOptions() + diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) + + // Create comp diff renderer using factory + compDiffRenderer := config.Factories.CompDiffRenderer(config.Logger, diffRenderer, diffOpts) + + return &DefaultCompDiffProcessor{ + compositionClient: compositionClient, + config: config, + xrProc: xrProc, + compDiffRenderer: compDiffRenderer, + } +} + +// Initialize loads required resources. +func (p *DefaultCompDiffProcessor) Initialize(ctx context.Context) error { + p.config.Logger.Debug("Initializing composition diff processor") + + // Initialize the injected XR processor + if err := p.xrProc.Initialize(ctx); err != nil { + return errors.Wrap(err, "cannot initialize XR diff processor") + } + + p.config.Logger.Debug("Composition diff processor initialized") + + return nil +} + +// Cleanup releases any resources held by the processor. +// Delegates to the underlying XR processor for cleanup. +func (p *DefaultCompDiffProcessor) Cleanup(ctx context.Context) error { + return p.xrProc.Cleanup(ctx) +} + +// DiffComposition processes composition changes and shows impact on existing XRs. +// Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. +func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (bool, error) { + p.config.Logger.Debug("Processing composition diff", + "compositionCount", len(compositions), + "namespace", namespace, + "resourceCount", len(resources)) + + if len(compositions) == 0 { + return false, errors.New("no compositions provided") + } + + // When --resource is set, run a preflight pass that resolves every ref against every supplied + // composition. If any ref is relevant to no supplied composition, fail loudly BEFORE rendering + // any diffs (this is a CLI input error, not a downstream processing failure). + preflightMatches, err := p.preflightResourceRefs(ctx, compositions, resources) + if err != nil { + return false, err + } + + output := &renderer.CompDiffOutput{ + Compositions: make([]renderer.CompositionDiff, 0, len(compositions)), + Errors: []dt.OutputError{}, + } + + var compositionErrors int + + hasDiffs := false + + // Process each composition, filtering out non-Composition objects + for _, comp := range compositions { + // Skip non-Composition objects (e.g., GoTemplate objects extracted from pipeline steps) + if comp.GetKind() != "Composition" { + p.config.Logger.Debug("Skipping non-Composition object", "kind", comp.GetKind(), "apiVersion", comp.GetAPIVersion()) + continue + } + + compositionID := comp.GetName() // Use actual name from unstructured + p.config.Logger.Debug("Processing composition", "name", compositionID) + + // Resolve the affected XR set up-front. In --resource mode the preflight already produced it; + // in default-discovery mode we query the cluster (best-effort: net-new compositions yield empty). + // surfaceFiltered controls whether Manual-policy XRs go into ImpactAnalysis as XRStatusFilteredByPolicy + // entries (true in --resource mode so users see what was matched-but-skipped) vs. only being counted + // in the summary (default-discovery mode). + var ( + affectedXRs []*un.Unstructured + surfaceFiltered bool + ) + + switch { + case len(resources) > 0: + affectedXRs = preflightMatches[compositionID] + surfaceFiltered = true + default: + // Default-discovery only needs comp.GetName() — pass the unstructured directly. + // FindComposites converts to typed internally only in refs mode (which we're not in here). + discovered, findErr := p.compositionClient.FindComposites(ctx, comp, dtypes.FindCompositesOptions{Namespace: namespace}) + + switch { + case findErr != nil: + // Net-new composition (won't exist in cluster) → graceful empty result, same as before. + p.config.Logger.Debug("Cannot find composites using composition (likely net-new composition)", + "composition", compositionID, "error", findErr) + + affectedXRs = nil + default: + affectedXRs = discovered + } + } + + // Process this single composition and build the result + compResult, err := p.processSingleComposition(ctx, comp, affectedXRs, surfaceFiltered) + if err != nil { + p.config.Logger.Debug("Failed to process composition", "composition", compositionID, "error", err) + + compositionErrors++ + + // Include failed composition with error instead of skipping + output.Compositions = append(output.Compositions, renderer.CompositionDiff{ + Name: compositionID, + Error: new(NewOutputError(fmt.Sprintf("Composition/%s", compositionID), err)), + AffectedResources: renderer.AffectedResourcesSummary{ + Total: 0, + WithChanges: 0, + Unchanged: 0, + WithErrors: 0, + }, + ImpactAnalysis: []renderer.XRImpact{}, + }) + } else { + if compResult.HasChanges() { + hasDiffs = true + } + + output.Compositions = append(output.Compositions, *compResult) + } + } + + // Collect XR errors with their resource IDs for top-level errors + for _, comp := range output.Compositions { + for _, impact := range comp.ImpactAnalysis { + // The impact carries the OutputError built when it was created (see buildImpactAnalysis), so the + // per-XR entry and this list hold the same error. + if impact.Status == renderer.XRStatusError && impact.Error != nil { + output.Errors = append(output.Errors, *impact.Error) + } + } + } + + // Release resources before draining advisories, so one raised during teardown (leftover function + // containers) is collected rather than arriving after the renderer has already read the slice. + CleanupDetached(ctx, p, p.config.Logger) + + // Attach any advisories raised during the run. They have already reached stderr when raised; this + // carries them into structured output. + output.Warnings = p.config.Advisories.Advisories() + + // Counted before the interruption entry is added: it is not an XR failure. + totalXRErrors := len(output.Errors) + output.Errors = withInterruption(ctx, output.Errors) + + // Always render output (even if all compositions failed) to ensure valid structured output + // The renderer will include errors in the structured output and write them to stderr + if err := p.compDiffRenderer.RenderCompDiff(output); err != nil { + return hasDiffs, errors.Wrap(err, "failed to render composition diff") + } + + // Check for XR processing errors after rendering (so users see the output first). + // Return an error so CI/CD pipelines get a non-zero exit code when impact analysis failed. + if totalXRErrors > 0 { + return hasDiffs, errors.Errorf("impact analysis failed for %d XR(s)", totalXRErrors) + } + + // Return error if all compositions failed + if compositionErrors > 0 && compositionErrors == len(output.Compositions) { + return hasDiffs, errors.New("failed to process all compositions") + } + + return hasDiffs, nil +} + +// preflightResourceRefs resolves user --resource refs against every supplied composition before +// any rendering happens. Returns the per-composition matched set keyed by composition name. +// If any ref is relevant to no supplied composition, it returns an error naming the unmatched +// refs (CLI input error). When `refs` is empty, returns (nil, nil) and the caller falls back to +// default-discovery mode. +func (p *DefaultCompDiffProcessor) preflightResourceRefs(ctx context.Context, compositions []*un.Unstructured, refs []k8stypes.NamespacedName) (map[string][]*un.Unstructured, error) { + if len(refs) == 0 { + return nil, nil + } + + perComp := make(map[string][]*un.Unstructured, len(compositions)) + matchedAtLeastOnce := make(map[string]bool, len(refs)) + + for _, comp := range compositions { + if comp.GetKind() != "Composition" { + continue + } + + // FindComposites takes the unstructured composition; it converts to typed internally + // in refs mode (resolveCompositeTypes needs spec.compositeTypeRef). + matched, err := p.compositionClient.FindComposites(ctx, comp, dtypes.FindCompositesOptions{Refs: refs}) + if err != nil { + return nil, errors.Wrapf(err, "preflight: cannot resolve --resource refs for composition %s", comp.GetName()) + } + + perComp[comp.GetName()] = matched + + // A ref is matched globally if any composition's matched-set contains a composite whose + // (namespace, name) equals the ref. + for _, m := range matched { + for _, ref := range refs { + if m.GetName() == ref.Name && m.GetNamespace() == ref.Namespace { + matchedAtLeastOnce[ref.String()] = true + } + } + } + } + + var globallyUnmatched []k8stypes.NamespacedName + + for _, ref := range refs { + if !matchedAtLeastOnce[ref.String()] { + globallyUnmatched = append(globallyUnmatched, ref) + } + } + + if len(globallyUnmatched) > 0 { + names := make([]string, 0, len(globallyUnmatched)) + for _, r := range globallyUnmatched { + names = append(names, ref.Format(r)) + } + + return nil, errors.Errorf("--resource ref(s) not relevant to any supplied composition: %v (resource not found, or it doesn't reference one of the supplied compositions)", names) + } + + return perComp, nil +} + +// processSingleComposition processes a single composition and builds the result. +// `affectedXRs` is the pre-resolved set of XRs to evaluate (caller decides via DiffComposition's +// switch whether this comes from the --resource preflight or default-discovery via FindComposites). +// When `surfaceFiltered` is true, XRs dropped by update-policy filtering are surfaced in +// ImpactAnalysis with XRStatusFilteredByPolicy so users see what was matched-but-skipped. +func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, newComp *un.Unstructured, affectedXRs []*un.Unstructured, surfaceFiltered bool) (*renderer.CompositionDiff, error) { + result := &renderer.CompositionDiff{ + Name: newComp.GetName(), + ImpactAnalysis: []renderer.XRImpact{}, + AffectedResources: renderer.AffectedResourcesSummary{ + Total: 0, + WithChanges: 0, + Unchanged: 0, + WithErrors: 0, + FilteredByPolicy: 0, + }, + } + + // First, calculate the composition diff itself + comparison, err := p.calculateCompositionDiff(ctx, newComp) + if err != nil { + return nil, errors.Wrap(err, "cannot calculate composition diff") + } + + result.CompositionDiff = comparison.diff + result.MaskedChangesOnly = comparison.diff == nil && comparison.changed() + + // Predict the identity of the revision this composition would produce. Needed twice below: to + // evaluate compositionRevisionSelectors against the label set the revision would carry, and to seed + // the composites so a template observing the revision name renders the value it would really get. + pred, err := predictRevision(newComp) + if err != nil { + return nil, err + } + + // Report what applying this composition does to CompositionRevisions regardless of whether the + // composites are evaluated below. This is the mutative consequence of the apply, and it is + // independent of whether anything renders differently — so it is reported at every --analyze-on + // setting, and carried as a typed per-composition field rather than a warning, because a CI + // consumer may want to gate on it. + result.RevisionImpact = renderer.RevisionImpact{ + ChangeScope: string(comparison.scope), + CreatesRevision: comparison.changed(), + } + + if comparison.revisionNamePredictable { + result.RevisionImpact.PredictedRevisionName = pred.name + } + + // Partition XRs by whether they would adopt the diffed composition's resulting revision. This is + // local (no renders), and every path below needs it: the composites that would adopt the new + // revision are exactly the ones that would re-point at it, and the ones that would not are a + // consequence of applying this composition in their own right. The same classification decides, in + // cliCompositionRevision, which composites are rendered with the new revision's name: a Manual + // composite kept by --include-manual stays pinned, so it never is. + keptXRs, droppedXRs, repointingXRs, err := p.partitionXRsByUpdatePolicy(affectedXRs, newComp, pred) + if err != nil { + return nil, err + } + + if comparison.changed() { + result.RevisionImpact.RepointedComposites = len(repointingXRs) + } + + counts := countFilterReasons(droppedXRs) + + p.config.Logger.Debug("Filtered XRs by deletion state, update policy, and revision selector", + "composition", newComp.GetName(), + "originalCount", len(affectedXRs), + "keptCount", len(keptXRs), + "repointingCount", len(repointingXRs), + "droppedCount", len(droppedXRs), + "filteredByPolicy", counts.byPolicy, + "filteredBySelector", counts.bySelector, + "filteredByDeletion", counts.byDeletion, + "includeManual", p.config.IncludeManual) + + // In --resource mode (surfaceFiltered=true), surface filtered composites in the impact + // analysis as XRStatusFiltered (with their reason) so users see what was matched-but-skipped. + // In default-discovery mode, preserve the existing summary-only behavior. + if surfaceFiltered { + for _, d := range droppedXRs { + result.ImpactAnalysis = append(result.ImpactAnalysis, renderer.XRImpact{ + ObjectReference: corev1.ObjectReference{ + APIVersion: d.xr.GetAPIVersion(), + Kind: d.xr.GetKind(), + Name: d.xr.GetName(), + Namespace: d.xr.GetNamespace(), + }, + Status: renderer.XRStatusFiltered, + FilterReason: d.reason, + FilterDetail: d.detail, + }) + } + } + + // Which composites were discovered, and which of them would not adopt the resulting revision, is + // settled above without rendering anything. So it is recorded before the skip below, which + // suppresses only the per-XR renders: --analyze-on is a cost knob, and a flag may change how much + // work is done but never whether a real consequence is reported. Reporting a total of 0 here while + // RevisionImpact.RepointedComposites said otherwise was issue #478. + result.AffectedResources.Total = len(affectedXRs) + counts.applyTo(&result.AffectedResources) + + // Skip the per-XR work — one function render per XR, the dominant cost of comp — when the change + // is smaller than the user asked to analyse. + // + // At the default (any-change) this skips only a composition identical in everything Crossplane + // hashes: no new CompositionRevision is created, so no XR adopts anything it hasn't already, and + // any downstream delta computed here would be caused by something else — drift, convergence lag, + // or a modeling artifact of this tool — which we cannot tell apart, so reporting it as this + // composition's "impact" would attribute cluster state to a change that does not exist. + // See issues #453 and #472. + if !comparison.scope.triggersAnalysis(p.config.AnalyzeOn) { + p.config.Logger.Debug("Skipping impact analysis", + "composition", newComp.GetName(), + "changeScope", string(comparison.scope), + "analyzeOn", string(p.config.AnalyzeOn), + "affectedXRCount", len(affectedXRs)) + + result.ImpactAnalysisSkipped = true + + return result, nil + } + + p.config.Logger.Debug("Processing affected XRs", "composition", newComp.GetName(), "count", len(affectedXRs), "surfaceFiltered", surfaceFiltered) + + if len(keptXRs) == 0 { + // All XRs were filtered; the summary recorded above is already complete. + return result, nil + } + + // Render the composites with the revision they would actually be pointing at. Without this the + // render sees each composite's *existing* compositionRevisionRef, so a composition template reading + // the revision name produces the stale one and a real change goes unreported. See issue #474. + // + // When the name is not predictable we render with the refs the composites have — the pre-#474 + // behaviour — and say so, rather than guess one. Rendering with a name we invented would manufacture + // a downstream diff on a converged cluster for exactly the compositions this feature exists to serve. + revisionName := "" + + switch { + case comparison.revisionNamePredictable: + revisionName = pred.name + case comparison.changed(): + p.config.Advisories.Raise(dt.OutputWarning{ + Message: "Could not predict the name of the CompositionRevision this composition would create, because it differs from the cluster's only by the annotation a client-side `kubectl apply` writes — whose post-apply value depends on how you apply, not on the file. Composites were rendered with their existing compositionRevisionRef, so if any of your composition templates read the revision name, a resulting change was not detected. Apply with `--server-side` (or via Argo/Flux) to make it predictable.", + Context: map[string]string{"composition": newComp.GetName()}, + }) + } + + // Process kept XRs and collect diffs to determine which ones have changes + p.config.Logger.Debug("Processing XRs to collect diff information", "count", len(keptXRs)) + + xrResults := p.collectXRDiffs(ctx, keptXRs, newComp, revisionName, predictedRevisionLabels(newComp, pred)) + + // Build impact analysis and counts from results for the kept set, then merge in any + // already-appended filtered entries. + keptImpacts, keptSummary := p.buildImpactAnalysis(keptXRs, xrResults) + result.ImpactAnalysis = append(result.ImpactAnalysis, keptImpacts...) + // keptSummary.Total counts only kept; widen to include filtered so totals stay consistent. + keptSummary.Total = len(affectedXRs) + counts.applyTo(&keptSummary) + result.AffectedResources = keptSummary + + return result, nil +} + +// collectXRDiffs processes XRs and collects their diffs, returning results for each XR. +// +// revisionName is the name of the CompositionRevision applying newComp would leave its composites on, +// or empty when that is not predictable; revisionLabels is the label set that revision would carry. See +// cliCompositionRevision for which composites are rendered with that name. +func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un.Unstructured, newComp *un.Unstructured, revisionName string, revisionLabels map[string]string) map[string]*XRDiffResult { + // Convert the CLI composition to typed once for reuse + cliComp := &apiextensionsv1.Composition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(newComp.Object, cliComp); err != nil { + // If we can't convert, return an error result for all XRs + results := make(map[string]*XRDiffResult) + + for _, xr := range xrs { + resourceID := dt.MakeDiffKeyFromResource(xr) + results[resourceID] = &XRDiffResult{ + Diffs: make(map[string]*dt.ResourceDiff), + Error: errors.Wrap(err, "cannot convert CLI composition to typed"), + } + } + + return results + } + + // Extract the target GVK from the CLI composition's compositeTypeRef + cliCompTargetAPIVersion := cliComp.Spec.CompositeTypeRef.APIVersion + cliCompTargetKind := cliComp.Spec.CompositeTypeRef.Kind + + p.config.Logger.Debug("CLI composition targets", + "apiVersion", cliCompTargetAPIVersion, + "kind", cliCompTargetKind) + + // Build a set of root-level resource keys (apiVersion/kind/namespace/name) for quick lookup. + // Root-level resources are XRs and Claims supplied as `affectedXRs` to processSingleComposition + // (resolved by DiffComposition via either preflight or FindComposites default-discovery). + // These should always use the CLI composition. Namespace is included in the key to avoid + // collisions between resources with the same name in different namespaces. + rootResourceKeys := make(map[string]bool) + + for _, xr := range xrs { + key := dt.MakeDiffKeyFromResource(xr) + rootResourceKeys[key] = true + } + + // Composition provider that returns CLI composition for: + // 1. Root-level resources (XRs and Claims that use this composition) + // 2. XRs whose type matches the CLI composition's compositeTypeRef + // + // For nested XRs with different types, looks up from the cluster. + // + // The provider is handed the effective composite (see CompositeResolver), so a root claim reaches it + // as its backing XR, whose key is not in rootResourceKeys: Check 2 matches it by type instead. + compositionProvider := func(ctx context.Context, res *un.Unstructured) (dtypes.ResolvedComposition, error) { + resGVK := res.GroupVersionKind() + resAPIVersion := resGVK.GroupVersion().String() + resKind := resGVK.Kind + resourceID := fmt.Sprintf("%s/%s", res.GetKind(), res.GetName()) + + // Check 1: Is this a root-level resource (XR or Claim supplied as affectedXRs to this composition)? + // Root-level resources always use the CLI composition, even claims whose GVK differs from the XR type. + key := dt.MakeDiffKeyFromResource(res) + if rootResourceKeys[key] { + p.config.Logger.Debug("Resource is root-level (uses this composition), using CLI composition", + "resource", resourceID, + "composition", cliComp.GetName()) + + return p.cliCompositionRevision(res, cliComp, revisionName, revisionLabels) + } + + // Check 2: Does this resource's type match the CLI composition's target type? + // This handles XRs encountered during rendering that match the composition's type. + if resAPIVersion == cliCompTargetAPIVersion && resKind == cliCompTargetKind { + p.config.Logger.Debug("Resource matches CLI composition type, using CLI composition", + "resource", resourceID, + "composition", cliComp.GetName()) + + return p.cliCompositionRevision(res, cliComp, revisionName, revisionLabels) + } + + // This is a nested XR with a different type - look up its composition from the cluster + p.config.Logger.Debug("Resource does not match CLI composition type, looking up from cluster", + "resource", resourceID, + "resourceAPIVersion", resAPIVersion, + "resourceKind", resKind, + "cliCompTargetAPIVersion", cliCompTargetAPIVersion, + "cliCompTargetKind", cliCompTargetKind) + + return p.compositionClient.FindMatchingComposition(ctx, res) + } + + results := make(map[string]*XRDiffResult) + + for _, xr := range xrs { + resourceID := dt.MakeDiffKeyFromResource(xr) + + diffs, err := p.xrProc.DiffSingleResource(ctx, xr, compositionProvider) + if err != nil { + p.config.Logger.Debug("Failed to process resource", "resource", resourceID, "error", err) + + // Store the error in the result + results[resourceID] = &XRDiffResult{ + Diffs: make(map[string]*dt.ResourceDiff), + Error: errors.Wrapf(err, "unable to process resource %s", resourceID), + } + } else { + // Store successful result with diffs + results[resourceID] = &XRDiffResult{ + Diffs: diffs, + Error: nil, + } + } + } + + return results +} + +// cliCompositionRevision resolves a composite that is rendered against cliComp, the diffed composition. +// Its revision is the one applying that composition leaves it on, named revisionName, for a composite +// that would re-point at it as classifyXR decides; revisionLabels is the label set that revision would +// carry, which a compositionRevisionSelector is matched against. For any other composite the revision +// is left unknown, so it keeps the ref it has: one that is Manual (#479), being deleted, or whose +// compositionRevisionSelector rejects that revision. So does every composite when revisionName is +// empty, i.e. not predictable (#474). +func (p *DefaultCompDiffProcessor) cliCompositionRevision(res *un.Unstructured, cliComp *apiextensionsv1.Composition, revisionName string, revisionLabels map[string]string) (dtypes.ResolvedComposition, error) { + resolved := dtypes.ResolvedComposition{Composition: cliComp} + if revisionName == "" { + return resolved, nil + } + + disposition, err := p.classifyXR(res, revisionLabels, cliComp.GetLabels()) + if err != nil { + return dtypes.ResolvedComposition{}, err + } + + if disposition.filtered == nil && !disposition.pinnedToRevision { + resolved.RevisionName = revisionName + } + + return resolved, nil +} + +// compositionComparison is the result of comparing a proposed composition against its in-cluster +// version. +// +// The two fields come apart whenever a mask hides the only difference: `diff` is what the user is +// shown (nil when the compositions are equal after masking), while `changed` records whether the +// composition differs at all. Impact analysis gates on `changed`, never on `diff`. +// +// Both kinds of mask can hide a real difference, so neither may gate the analysis: +// - the user's --ignore-paths, which could cover something load-bearing for rendering (anything +// under spec.pipeline[].input, say); +// - the renderer's display-only suppressions, which cover fields Crossplane nonetheless hashes +// into a composition's identity, and so into whether a new CompositionRevision is created. +// +// `scope` is therefore computed with every mask lifted. See issue #453. +// +// `revisionNamePredictable` is separate from both, and answers a third question: can we say what the +// resulting CompositionRevision will be *called*? See the field comment. +type compositionComparison struct { + diff *dt.ResourceDiff + scope ChangeScope + + // revisionNamePredictable reports whether the resulting revision's name can be derived from the + // composition as supplied. It is false in exactly one situation: the composition differs from the + // cluster's *only* by kubectl's last-applied-configuration annotation. + // + // The name is "-" and Composition.Hash() covers annotations, so the name + // depends on that annotation's post-apply value — which a client-side `kubectl apply` derives from + // the file being applied (see kubectl's GetModifiedConfiguration), not from what is in the file's + // own annotations. So the value the cluster will end up holding is a function of the user's apply + // mode, and this tool cannot know it: under server-side apply the annotation is left alone, under + // client-side apply it is rewritten. Predicting from the file as supplied would be a guess. + // + // Note what is NOT claimed when this is false. The delta is real, so the composition may well + // produce a revision and CreatesRevision stays true; a stale annotation genuinely would be + // rewritten, minting one. What is unknowable is the identity, so we decline to seed it and say so + // rather than seed a name we invented — which, for a template reading the name, would manufacture + // a downstream diff on a converged cluster. See issue #474. + // + // The exception is a live annotation that already describes the file: applying it then writes + // nothing, so the composition is reported unchanged rather than unpredictable. See + // lastAppliedDescribes and issue #500. + revisionNamePredictable bool +} + +// changed reports whether the composition differs at all in the fields Crossplane hashes. +func (c compositionComparison) changed() bool { + return c.scope != ChangeScopeNone +} + +// calculateCompositionDiff calculates the diff between the cluster composition and the file composition. +func (p *DefaultCompDiffProcessor) calculateCompositionDiff(ctx context.Context, newComp *un.Unstructured) (compositionComparison, error) { + p.config.Logger.Debug("Calculating composition diff", "composition", newComp.GetName()) + + var originalCompUnstructured *un.Unstructured + + // Get the original composition from the cluster + originalComp, err := p.compositionClient.GetComposition(ctx, newComp.GetName()) + if err != nil { + p.config.Logger.Debug("Original composition not found in cluster, treating as new composition", + "composition", newComp.GetName(), "error", err) + // originalCompUnstructured remains nil for new compositions + } else { + p.config.Logger.Debug("Retrieved original composition from cluster", "name", originalComp.GetName(), "composition", originalComp) + + // Convert original composition to unstructured for comparison + unstructuredObj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(originalComp) + if err != nil { + return compositionComparison{}, errors.Wrap(err, "cannot convert original composition to unstructured") + } + + originalCompUnstructured = &un.Unstructured{Object: unstructuredObj} + } + + newCompUnstructured := newComp + + // Clean up managed fields and other cluster metadata before diff calculation + cleanupClusterMetadata := func(obj *un.Unstructured) { + if obj == nil { + return + } + + obj.SetManagedFields(nil) + obj.SetResourceVersion("") + obj.SetUID("") + obj.SetGeneration(0) + obj.SetCreationTimestamp(metav1.Time{}) + } + + cleanupClusterMetadata(originalCompUnstructured) + cleanupClusterMetadata(newCompUnstructured) + + // Calculate the composition diff directly without dry-run apply + // (compositions are static YAML documents that don't need server-side processing) + diffOptions := renderer.DefaultDiffOptions() + diffOptions.UseColors = p.config.Colorize + diffOptions.Compact = p.config.Compact + diffOptions.IgnorePaths = p.config.IgnorePaths + + compDiff, err := renderer.GenerateDiffWithOptions(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) + if err != nil { + return compositionComparison{}, errors.Wrap(err, "cannot calculate composition diff") + } + + p.config.Logger.Debug("Calculated composition diff", + "composition", newComp.GetName(), + "hasChanges", compDiff.DiffType != dt.DiffTypeEqual, + "isNewComposition", originalCompUnstructured == nil) + + if compDiff.DiffType != dt.DiffTypeEqual { + // A displayable difference means the compositions differ in something other than the annotation + // (which is never displayed), so the hash difference is driven by real content and the resulting + // revision's name follows from the composition as supplied. + return compositionComparison{ + diff: compDiff, + scope: compositionChangeScope(originalCompUnstructured, newCompUnstructured, true), + revisionNamePredictable: true, + }, nil + } + + // Nothing to display. That is not the same as unchanged: both the user's --ignore-paths and the + // renderer's display-only suppressions could be hiding a real difference. Re-compare with every + // mask lifted (ForVerdict) to find out. + // + // The masks are display preferences; whether the composition changed is a fact about the object. + // Crossplane's own answer to that question is Composition.Hash(), which covers labels, + // annotations and spec — so a metadata-only difference genuinely produces a new + // CompositionRevision that composites re-point to, and that a template can observe through the + // XR's compositionRevisionRef. This comparison mirrors that scope. It is local (no API calls). + diffOptions.IgnorePaths = nil + diffOptions.ForVerdict = true + + unmasked, err := renderer.GenerateDiffWithOptions(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) + if err != nil { + return compositionComparison{}, errors.Wrap(err, "cannot calculate composition diff with masks lifted") + } + + anyChange := unmasked.DiffType != dt.DiffTypeEqual + scope := compositionChangeScope(originalCompUnstructured, newCompUnstructured, anyChange) + + // One more local comparison, to find out whether the difference just detected is *only* kubectl's + // last-applied-configuration annotation: same mask-lifted view, but with that one path ignored. If + // the objects become equal, it was the sole delta, and the resulting revision's name is not + // predictable — see compositionComparison.revisionNamePredictable. Ordering matters: the annotation + // must stay in the comparison that decides `scope` (a real difference, and Crossplane hashes it) and + // be excluded only from this narrower question about identity. + revisionNamePredictable := true + + if anyChange { + diffOptions.IgnorePaths = []string{renderer.PathLastAppliedConfiguration} + + withoutLastApplied, err := renderer.GenerateDiffWithOptions(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) + if err != nil { + return compositionComparison{}, errors.Wrap(err, "cannot calculate composition diff excluding last-applied-configuration") + } + + revisionNamePredictable = withoutLastApplied.DiffType != dt.DiffTypeEqual + + // The annotation is the sole delta. If the cluster's copy of it already describes this exact + // file, applying the file is a no-op under either apply mode, so it is not a change at all. See + // lastAppliedDescribes. + if !revisionNamePredictable && lastAppliedDescribes(originalCompUnstructured, newCompUnstructured) { + scope = ChangeScopeNone + revisionNamePredictable = true + } + } + + p.config.Logger.Debug("No displayable changes in composition", + "composition", newComp.GetName(), + "changeScope", string(scope), + "revisionNamePredictable", revisionNamePredictable) + + return compositionComparison{diff: nil, scope: scope, revisionNamePredictable: revisionNamePredictable}, nil +} + +// lastAppliedDescribes reports whether the cluster object's kubectl last-applied-configuration is +// semantically the proposed file, in which case applying the file writes nothing (issue #500). +// +// A client-side `kubectl apply` sets that annotation to the file minus the annotation, JSON-encoded +// (kubectl's GetModifiedConfiguration), so the value is a function of the file alone: if the live value +// already equals it, the three-way patch is empty and kubectl issues no request. Server-side apply leaves +// an annotation the file does not mention untouched. Either way nothing changes, so no +// CompositionRevision is created. Equal maps encode to equal JSON, so semantic equality suffices. +// +// Only a file that does not carry the annotation itself qualifies: one that does (say, a fetched object +// checked into git) would have it written verbatim under server-side apply, so its outcome again depends +// on the apply mode. kubectl keeps the emptied annotations map in what it encodes, so an empty +// annotations map is treated as absent on both sides. Neither object is mutated. +func lastAppliedDescribes(original, proposed *un.Unstructured) bool { + if original == nil { + return false + } + + if _, ok := proposed.GetAnnotations()[corev1.LastAppliedConfigAnnotation]; ok { + return false + } + + live, ok := original.GetAnnotations()[corev1.LastAppliedConfigAnnotation] + if !ok { + return false + } + + lastApplied := &un.Unstructured{} + if err := lastApplied.UnmarshalJSON([]byte(live)); err != nil { + // Not something kubectl wrote, so we cannot say what applying would do; keep the difference. + return false + } + + return equality.Semantic.DeepEqual(withoutLastApplied(lastApplied).Object, withoutLastApplied(proposed).Object) +} + +// withoutLastApplied returns a copy of obj without kubectl's last-applied-configuration annotation, +// dropping the annotations map entirely if that leaves it empty. +func withoutLastApplied(obj *un.Unstructured) *un.Unstructured { + out := obj.DeepCopy() + + annotations := out.GetAnnotations() + delete(annotations, corev1.LastAppliedConfigAnnotation) + + if len(annotations) == 0 { + un.RemoveNestedField(out.Object, "metadata", "annotations") + } else { + out.SetAnnotations(annotations) + } + + return out +} + +// compositionChangeScope classifies how much of a composition differs from its in-cluster version, +// in the terms Crossplane uses to decide whether a new CompositionRevision is needed. `anyChange` +// says whether the two differ at all with every display mask lifted; this function only has to +// decide whether the difference reaches the spec. +// +// A nil original means the composition does not exist in the cluster yet, which is a spec change by +// construction — there is no prior spec. +func compositionChangeScope(original, proposed *un.Unstructured, anyChange bool) ChangeScope { + if original == nil { + return ChangeScopeSpec + } + + if !anyChange { + return ChangeScopeNone + } + + // NestedMap deep-copies, so the missing-spec case (nil) compares equal on both sides rather than + // tripping on one being an empty map and the other nil. + originalSpec, _, _ := un.NestedMap(original.Object, "spec") + proposedSpec, _, _ := un.NestedMap(proposed.Object, "spec") + + if !equality.Semantic.DeepEqual(originalSpec, proposedSpec) { + return ChangeScopeSpec + } + + return ChangeScopeMetadata +} + +// predictedRevision is the identity of the CompositionRevision that applying a composition would +// produce. Both fields derive from Crossplane's own Composition.Hash(), so neither is guessed. +type predictedRevision struct { + // name is what the revision would be called: "-". + name string + // hash is the value of the crossplane.io/composition-hash label, i.e. the hash truncated to the + // 63-character limit on label values. + hash string +} + +// revisionIdentity derives a revision's name and hash label from a composition's name and hash. +// +// This is the one and only thing this tool mirrors from upstream rather than calling: the derivation +// lives in NewCompositionRevision, in an internal/ package we cannot import, while the hash itself +// comes from the exported Composition.Hash(). The two truncations are different and both deliberate — +// 63 characters for the label value (Kubernetes' limit), 7 for the name suffix — and the >= guards +// are upstream's, kept verbatim so the degenerate hash Composition.Hash() returns on a marshal error +// ("unknown", shorter than either bound) produces the same result here as it would in the cluster. +func revisionIdentity(compName, hash string) predictedRevision { + if len(hash) >= 63 { + hash = hash[0:63] + } + + nameSuffix := hash + if len(nameSuffix) >= 7 { + nameSuffix = nameSuffix[0:7] + } + + return predictedRevision{ + name: fmt.Sprintf("%s-%s", compName, nameSuffix), + hash: hash, + } +} + +// predictRevision computes the identity of the CompositionRevision applying newComp would produce, by +// calling Crossplane's own exported Composition.Hash() on the typed composition. newComp is not +// mutated. +func predictRevision(newComp *un.Unstructured) (predictedRevision, error) { + comp := &apiextensionsv1.Composition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(newComp.Object, comp); err != nil { + return predictedRevision{}, errors.Wrapf(err, "cannot convert composition %q to typed to predict its revision", newComp.GetName()) + } + + return revisionIdentity(comp.GetName(), comp.Hash()), nil +} + +// predictedRevisionLabels returns the label set the CompositionRevision resulting from this +// composition would carry, for evaluating an XR's compositionRevisionSelector. Crossplane stamps +// every revision with the composition's own metadata.labels plus crossplane.io/composition-name and +// crossplane.io/composition-hash (and appends them before matching a selector), so we mirror that +// here — see NewCompositionRevision. Omitting the hash label used to drop an XR whose selector keys on +// it as a selector mismatch, with a detail message that pointed the user at their own labels instead. +// +// The hash label is stamped even when compositionComparison.revisionNamePredictable is false, i.e. when +// the eventual hash depends on the user's apply mode. That is deliberate, and is why this takes the +// prediction rather than consulting predictability: withholding it would put us back to failing every +// selector that merely requires the label to Exist, which is the common shape and is now always answered +// correctly. A selector matching an exact hash *value* is the only case the caveat reaches, and there the +// best available prediction beats a guaranteed mismatch. newComp is not mutated. +func predictedRevisionLabels(newComp *un.Unstructured, pred predictedRevision) map[string]string { + compLabels := newComp.GetLabels() + + labels := make(map[string]string, len(compLabels)+2) + maps.Copy(labels, compLabels) + + labels[xp.LabelCompositionName] = newComp.GetName() + labels[xp.LabelCompositionHash] = pred.hash + + return labels +} + +// filteredXR pairs a dropped XR with the reason it was excluded from impact analysis and an +// optional human-readable detail (e.g. which selector failed to match which labels). +type filteredXR struct { + xr *un.Unstructured + reason renderer.FilterReason + detail string +} + +// partitionXRsByUpdatePolicy splits XRs into a kept set and a dropped set, based on whether each XR +// would adopt the CompositionRevision resulting from newComp. See classifyXR for the per-XR rules, +// which also cover exclusions unrelated to update policy (e.g. XRs being deleted). A malformed +// compositionRevisionSelector is a hard error (accuracy over guessing). +// +// `repointing` is the set of kept XRs (keyed by dt.MakeDiffKeyFromResource) that would genuinely +// re-point at the resulting revision, which is not the same as the kept set: --include-manual keeps a Manual-policy XR for analysis even though it +// stays pinned to whatever compositionRevisionRef already names. See classifyXR and issue #479. +func (p *DefaultCompDiffProcessor) partitionXRsByUpdatePolicy(xrs []*un.Unstructured, newComp *un.Unstructured, pred predictedRevision) (kept []*un.Unstructured, dropped []filteredXR, repointing map[string]bool, err error) { + // The selector is matched against the label set the new revision would carry (composition labels + // plus the stamped crossplane.io/composition-name and crossplane.io/composition-hash), while + // mismatch messages display the user's own composition labels; see predictedRevisionLabels and + // xp.XRRevisionSelectorMatch. + targetLabels := predictedRevisionLabels(newComp, pred) + repointing = make(map[string]bool, len(xrs)) + compLabels := newComp.GetLabels() + + for _, xr := range xrs { + disposition, classifyErr := p.classifyXR(xr, targetLabels, compLabels) + if classifyErr != nil { + return nil, nil, nil, classifyErr + } + + if disposition.filtered != nil { + dropped = append(dropped, *disposition.filtered) + continue + } + + kept = append(kept, xr) + + if !disposition.pinnedToRevision { + repointing[dt.MakeDiffKeyFromResource(xr)] = true + } + } + + return kept, dropped, repointing, nil +} + +// xrDisposition is what classifyXR decided about one XR. +type xrDisposition struct { + // filtered is non-nil when the XR is dropped from impact analysis, carrying the reason. + filtered *filteredXR + + // pinnedToRevision marks a *kept* XR that nonetheless would not adopt the CompositionRevision this + // composition would produce, because a Manual compositionUpdatePolicy pins it to whatever + // compositionRevisionRef already names. Only reachable under --include-manual, which keeps such an + // XR so its would-be rendered output can be inspected — without changing the fact that it does not + // re-point. Counting it as re-pointing was issue #479. + pinnedToRevision bool +} + +// classifyXR decides whether a single XR should be kept for impact analysis or dropped (and why), +// based on whether it would adopt the CompositionRevision resulting from the diffed composition. +// targetLabels is the predicted revision label set (used for selector matching); compLabels is the +// composition's own metadata.labels (used for the user-facing mismatch detail). +// +// Rules: +// - Being deleted (metadata.deletionTimestamp set): dropped (reason deleting). Checked first, +// and NOT overridden by IncludeManual, because deletion supersedes the policy rules entirely. +// - Manual compositionUpdatePolicy: dropped (reason manual_policy) — pinned via +// compositionRevisionRef — unless IncludeManual is set, in which case it is kept but marked +// pinnedToRevision: opting into analysing it does not make it adopt the new revision. +// - Automatic policy with a compositionRevisionSelector that does not match: dropped (reason +// revision_selector_mismatch). NOT overridden by IncludeManual, since the XR genuinely would +// not select the resulting revision. +// - Automatic policy with no selector, or a matching selector: kept, and re-points. +func (p *DefaultCompDiffProcessor) classifyXR(xr *un.Unstructured, targetLabels, compLabels map[string]string) (xrDisposition, error) { + // An XR with a deletionTimestamp is on Crossplane's teardown path: the composite reconciler + // deletes its composed resources instead of composing them. It will never adopt the revision + // this composition would produce, and rendering it yields no composed resources to diff — so + // including it produces a meaningless (or outright failing) impact analysis. See issue #452. + // + // The timestamp is normalized to UTC/RFC3339 for display: the accessor returns a metav1.Time whose + // default rendering is machine-local, which would make the surfaced detail differ between runs on + // differently-configured machines. + if deletedAt := xr.GetDeletionTimestamp(); deletedAt != nil { + stamp := deletedAt.UTC().Format(time.RFC3339) + + p.config.Logger.Debug("Excluding XR that is being deleted", + "xr", xr.GetName(), + "kind", xr.GetKind(), + "deletionTimestamp", stamp) + + return xrDisposition{filtered: &filteredXR{ + xr: xr, + reason: renderer.FilterReasonDeleting, + detail: fmt.Sprintf("deletionTimestamp: %s", stamp), + }}, nil + } + + policy, err := xp.XRUpdatePolicy(xr.Object, xr.GetAPIVersion()) + if err != nil { + return xrDisposition{}, errors.Wrapf(err, "cannot read compositionUpdatePolicy for XR %q", xr.GetName()) + } + + p.config.Logger.Debug("Classifying XR", + "xr", xr.GetName(), + "kind", xr.GetKind(), + "policy", policy) + + // Manual policy: pinned to a specific revision; excluded unless the user opts in. Opting in keeps + // it for analysis but does not unpin it, so it is marked as not re-pointing. + if policy == compositionUpdatePolicyManual { + if p.config.IncludeManual { + return xrDisposition{pinnedToRevision: true}, nil + } + + return xrDisposition{filtered: &filteredXR{xr: xr, reason: renderer.FilterReasonManualPolicy}}, nil + } + + // Automatic (or default) policy: honor compositionRevisionSelector. An XR whose selector does not + // match the predicted revision labels would not select the resulting revision, so it is excluded — + // regardless of IncludeManual. + matches, detail, err := xp.XRRevisionSelectorMatch(xr, targetLabels, compLabels) + if err != nil { + return xrDisposition{}, errors.Wrapf(err, "cannot evaluate compositionRevisionSelector for XR %q", xr.GetName()) + } + + if !matches { + return xrDisposition{filtered: &filteredXR{xr: xr, reason: renderer.FilterReasonRevisionSelectorMismatch, detail: detail}}, nil + } + + return xrDisposition{}, nil +} + +// filterCounts tallies dropped XRs by filter reason. A struct rather than a growing list of +// positional return values, so adding a reason cannot silently transpose a caller's arguments. +type filterCounts struct { + byPolicy int + bySelector int + byDeletion int +} + +// countFilterReasons tallies dropped XRs by filter reason for the affected-resources summary. +func countFilterReasons(dropped []filteredXR) filterCounts { + var counts filterCounts + + for _, d := range dropped { + switch d.reason { + case renderer.FilterReasonManualPolicy: + counts.byPolicy++ + case renderer.FilterReasonRevisionSelectorMismatch: + counts.bySelector++ + case renderer.FilterReasonDeleting: + counts.byDeletion++ + } + } + + return counts +} + +// applyTo writes the per-reason tallies onto a summary. Centralized so every code path that builds +// an AffectedResourcesSummary reports the same set of reasons. +func (c filterCounts) applyTo(summary *renderer.AffectedResourcesSummary) { + summary.FilteredByPolicy = c.byPolicy + summary.FilteredBySelector = c.bySelector + summary.FilteredByDeletion = c.byDeletion +} + +// buildImpactAnalysis builds the impact analysis and summary from XR results. +func (p *DefaultCompDiffProcessor) buildImpactAnalysis(xrs []*un.Unstructured, results map[string]*XRDiffResult) ([]renderer.XRImpact, renderer.AffectedResourcesSummary) { + impacts := make([]renderer.XRImpact, 0, len(xrs)) + summary := renderer.AffectedResourcesSummary{ + Total: len(xrs), + } + + for _, xr := range xrs { + resourceID := dt.MakeDiffKeyFromResource(xr) + result := results[resourceID] + + impact := renderer.XRImpact{ + ObjectReference: corev1.ObjectReference{ + APIVersion: xr.GetAPIVersion(), + Kind: xr.GetKind(), + Name: xr.GetName(), + Namespace: xr.GetNamespace(), + }, + } + + switch { + case result != nil && result.HasError(): + impact.Status = renderer.XRStatusError + // NewOutputError surfaces typed validation failures via OutputError.ValidationFailures + // when result.Error wraps a SchemaValidationError carrying a structured Result. + impact.Error = new(NewOutputError(fmt.Sprintf("%s/%s", impact.Kind, impact.Name), result.Error)) + summary.WithErrors++ + case result != nil && result.HasChanges(): + impact.Status = renderer.XRStatusChanged + impact.Diffs = result.Diffs + summary.WithChanges++ + default: + impact.Status = renderer.XRStatusUnchanged + summary.Unchanged++ + } + + impacts = append(impacts, impact) + } + + return impacts, summary +} diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go new file mode 100644 index 00000000..3bbdf0dd --- /dev/null +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -0,0 +1,2126 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/diff/types" +) + +func TestDefaultCompDiffProcessor_findResourcesUsingComposition(t *testing.T) { + ctx := t.Context() + + // Create test XR data + xr1 := tu.NewResource("example.org/v1", "XResource", "xr-1"). + WithNamespace("default"). + Build() + xr2 := tu.NewResource("example.org/v1", "XResource", "xr-2"). + WithNamespace("custom-ns"). + Build() + + tests := map[string]struct { + compositionName string + namespace string + setupMocks func() xp.Clients + want []*un.Unstructured + wantErr bool + }{ + "SuccessfulFind": { + compositionName: "test-composition", + namespace: "default", + setupMocks: func() xp.Clients { + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithResourcesForComposition("test-composition", "default", []*un.Unstructured{xr1}). + Build(), + } + }, + want: []*un.Unstructured{xr1}, + wantErr: false, + }, + "DifferentNamespace": { + compositionName: "test-composition", + namespace: "custom-ns", + setupMocks: func() xp.Clients { + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithResourcesForComposition("test-composition", "custom-ns", []*un.Unstructured{xr2}). + Build(), + } + }, + want: []*un.Unstructured{xr2}, + wantErr: false, + }, + "ClientError": { + compositionName: "test-composition", + namespace: "default", + setupMocks: func() xp.Clients { + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithFindResourcesError("client error"). + Build(), + } + }, + want: nil, + wantErr: true, + }, + "NoXRsFound": { + compositionName: "test-composition", + namespace: "default", + setupMocks: func() xp.Clients { + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithResourcesForComposition("test-composition", "default", []*un.Unstructured{}). + Build(), + } + }, + want: []*un.Unstructured{}, + wantErr: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create processor + mocks := tt.setupMocks() + processor := &DefaultCompDiffProcessor{ + compositionClient: mocks.Composition, + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + }, + } + + comp := &un.Unstructured{} + comp.SetName(tt.compositionName) + got, err := processor.compositionClient.FindComposites(ctx, comp, types.FindCompositesOptions{Namespace: tt.namespace}) + + if (err != nil) != tt.wantErr { + t.Errorf("findResourcesUsingComposition() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("findResourcesUsingComposition() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { + ctx := t.Context() + + // Create test composition + testComp := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + Build() + + // Create test XR + testXR := tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNamespace("default"). + Build() + + // changedComp is the input composition for cases that need impact analysis to actually run: it + // carries a label testComp (the cluster copy) lacks, so the composition compares as changed. + changedComp := func() *un.Unstructured { + return tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured() + } + + // unchangedComp is byte-identical to testComp, so the composition compares as equal. + unchangedComp := func() *un.Unstructured { + return tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + BuildAsUnstructured() + } + + singleXRMocks := func() xp.Clients { + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(testComp). + WithResourcesForComposition("test-composition", "default", []*un.Unstructured{testXR}). + Build(), + Definition: tu.NewMockDefinitionClient().Build(), + Environment: tu.NewMockEnvironmentClient().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + } + + // malformedComp's spec.pipeline is a string, so the composition cannot be converted to typed to + // predict its revision and fails to process. The loader rejects such a file before the processor sees + // it, so this failure is reachable only here, not from an integration test. + malformedComp := func() *un.Unstructured { + return tu.NewResource("apiextensions.crossplane.io/v1", "Composition", "test-composition"). + WithNestedField(map[string]any{"apiVersion": "example.org/v1", "kind": "XResource"}, "spec", "compositeTypeRef"). + WithNestedField("Pipeline", "spec", "mode"). + WithNestedField("not-a-list-of-steps", "spec", "pipeline"). + Build() + } + + tests := map[string]struct { + compositions []*un.Unstructured + namespace string + analyzeOn AnalyzeOn + outputFormat renderer.OutputFormat + outputVersion renderer.OutputVersion + setupMocks func() xp.Clients + // verifyOutput runs whether or not DiffComposition returns an error, since a failed composition + // is still rendered. + verifyOutput func(t *testing.T, output string) + wantErr bool + }{ + // In v2 a composition that fails to process carries its error as a structured errors[] entry, + // and the top-level errors[] lists it too. + "FailedCompositionReportsStructuredErrorsV2": { + namespace: "default", + compositions: []*un.Unstructured{malformedComp()}, + outputFormat: renderer.OutputFormatJSON, + outputVersion: renderer.OutputVersionV2, + setupMocks: singleXRMocks, + verifyOutput: func(t *testing.T, output string) { + t.Helper() + tu.AssertStructuredCompDiff(t, output, tu.ExpectCompDiff(). + WithOutputVersion("v2"). + WithError("Composition/test-composition"). + WithComposition("test-composition"). + WithCompositionErrorContaining("cannot convert composition")) + }, + wantErr: true, + }, + "SuccessfulDiff": { + namespace: "default", + compositions: []*un.Unstructured{changedComp()}, + setupMocks: singleXRMocks, + verifyOutput: func(t *testing.T, output string) { + t.Helper() + // Should contain composition changes section + if !strings.Contains(output, "=== Composition Changes ===") { + t.Errorf("Expected output to contain composition changes section") + } + // Should contain affected XRs section + if !strings.Contains(output, "=== Affected Composite Resources ===") { + t.Errorf("Expected output to contain affected XRs section") + } + }, + wantErr: false, + }, + // Issue #453: any revision an unchanged composition produced would carry the same spec, so its + // XRs are not evaluated at all and the two impact sections are replaced by an explicit skip note. + "UnchangedCompositionSkipsImpactAnalysis": { + namespace: "default", + compositions: []*un.Unstructured{unchangedComp()}, + setupMocks: singleXRMocks, + verifyOutput: func(t *testing.T, output string) { + t.Helper() + + if !strings.Contains(output, "No changes detected in composition test-composition") { + t.Errorf("Expected output to report the composition as unchanged, got:\n%s", output) + } + + if !strings.Contains(output, "Impact analysis skipped") { + t.Errorf("Expected output to contain the impact-analysis skip note, got:\n%s", output) + } + + if !strings.Contains(output, "--analyze-on=always") { + t.Errorf("Expected the skip note to name the flag that opts back in, got:\n%s", output) + } + + // The distinction the two skip messages must preserve: an identical composition creates + // no revision, so this message may claim nothing could change. The metadata-only skip + // may not. + if !strings.Contains(output, "creates no new CompositionRevision") { + t.Errorf("Expected the identical-composition skip note to say no revision is created, got:\n%s", output) + } + + if strings.Contains(output, "=== Affected Composite Resources ===") { + t.Errorf("Expected no affected-resources section for a skipped composition, got:\n%s", output) + } + + if strings.Contains(output, "=== Impact Analysis ===") { + t.Errorf("Expected no impact-analysis section for a skipped composition, got:\n%s", output) + } + }, + wantErr: false, + }, + // --analyze-unchanged opts back into the analysis (the pre-edit convergence-baseline workflow). + "UnchangedCompositionWithAnalyzeUnchanged": { + namespace: "default", + compositions: []*un.Unstructured{unchangedComp()}, + analyzeOn: AnalyzeOnAlways, + setupMocks: singleXRMocks, + verifyOutput: func(t *testing.T, output string) { + t.Helper() + + if !strings.Contains(output, "No changes detected in composition test-composition") { + t.Errorf("Expected output to report the composition as unchanged, got:\n%s", output) + } + + if strings.Contains(output, "Impact analysis skipped") { + t.Errorf("Expected no skip note with --analyze-unchanged, got:\n%s", output) + } + + if !strings.Contains(output, "=== Affected Composite Resources ===") { + t.Errorf("Expected the affected-resources section with --analyze-unchanged, got:\n%s", output) + } + }, + wantErr: false, + }, + // Issue #478: at --analyze-on=spec-change a metadata-only change skips the per-XR renders, but + // the composites ruled out by the local filter pass are still reported — and their count must + // agree with the repointedComposites the same run reports, rather than the run claiming zero + // affected composites beside one that would re-point. + "AnalyzeOnSpecChangeStillReportsFilteredComposites": { + namespace: "default", + compositions: []*un.Unstructured{changedComp()}, + analyzeOn: AnalyzeOnSpecChange, + setupMocks: func() xp.Clients { + deletingXR := tu.NewResource("example.org/v1", "XResource", "deleting-xr"). + WithNamespace("default"). + WithDeletionTimestamp("2026-09-07T11:25:03Z"). + Build() + + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(testComp). + WithResourcesForComposition("test-composition", "default", []*un.Unstructured{testXR, deletingXR}). + Build(), + Definition: tu.NewMockDefinitionClient().Build(), + Environment: tu.NewMockEnvironmentClient().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + }, + verifyOutput: func(t *testing.T, output string) { + t.Helper() + + for _, want := range []string{ + "Impact analysis skipped", + // The metadata-only skip note may not claim nothing could change, and the composite it + // names as re-pointing is the one the filter pass kept. + "creates a new CompositionRevision that 1 composite would adopt", + "1 of 2 XR(s) using composition test-composition would not adopt this revision", + "1 being deleted", + } { + if !strings.Contains(output, want) { + t.Errorf("Expected %q in output, got:\n%s", want, output) + } + } + + // The kept composite went unevaluated, so no changed/unchanged verdict may be printed. + if strings.Contains(output, "=== Affected Composite Resources ===") { + t.Errorf("Expected no affected-resources section for a skipped composition, got:\n%s", output) + } + }, + wantErr: false, + }, + "NoCompositions": { + namespace: "default", + compositions: []*un.Unstructured{}, + setupMocks: func() xp.Clients { + return xp.Clients{} + }, + wantErr: true, + }, + // Aggregation across compositions: one section per input, each with its own affected XRs. + // Both inputs must genuinely differ from their cluster copies (hence the labels) and must have + // affected XRs — otherwise the skip added for issue #453 short-circuits before the per-XR work, + // and the case would silently stop covering aggregation while still rendering two headers. + // The end-to-end variant is TestCompDiffIntegration/MultipleChangedCompositionsBothReportImpact. + "MultipleCompositions": { + namespace: "default", + compositions: []*un.Unstructured{ + tu.NewComposition("test-composition-1"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured(), + tu.NewComposition("test-composition-2"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured(), + }, + setupMocks: func() xp.Clients { + // Cluster copies carry no labels, so both inputs compare as changed. + testComp1 := tu.NewComposition("test-composition-1"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + Build() + + testComp2 := tu.NewComposition("test-composition-2"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + Build() + + xr1 := tu.NewResource("example.org/v1", "XResource", "xr-one").WithNamespace("default").Build() + xr2 := tu.NewResource("example.org/v1", "XResource", "xr-two").WithNamespace("default").Build() + + return xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetches([]*apiextensionsv1.Composition{testComp1, testComp2}). + WithResourcesForComposition("test-composition-1", "default", []*un.Unstructured{xr1}). + WithResourcesForComposition("test-composition-2", "default", []*un.Unstructured{xr2}). + Build(), + Definition: tu.NewMockDefinitionClient().Build(), + Environment: tu.NewMockEnvironmentClient().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + }, + verifyOutput: func(t *testing.T, output string) { + t.Helper() + // Should contain composition changes sections for both compositions + compositionChangesSections := strings.Count(output, "=== Composition Changes ===") + if compositionChangesSections != 2 { + t.Errorf("Expected 2 composition changes sections, got %d", compositionChangesSections) + } + // Should contain separator between compositions + if !strings.Contains(output, strings.Repeat("=", 80)) { + t.Errorf("Expected output to contain composition separator") + } + + // Each composition's own affected XR must be evaluated and surfaced. Without this the + // case passes even when the aggregation loop only processes the first composition, since + // the section headers above are printed regardless. + for _, want := range []string{"XResource/xr-one", "XResource/xr-two"} { + if !strings.Contains(output, want) { + t.Errorf("Expected %s in the affected-resources output, got:\n%s", want, output) + } + } + + // Neither composition changed → skipped would be wrong here; both must be analyzed. + if strings.Contains(output, "Impact analysis skipped") { + t.Errorf("Both compositions changed, so neither should be skipped, got:\n%s", output) + } + }, + wantErr: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + xpClients := tt.setupMocks() + + // Create mock XR processor + mockXRProc := &tu.MockDiffProcessor{ + PerformDiffFn: func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { + return true, nil + }, + } + + // Create processor using constructor to ensure all fields are initialized + logger := tu.TestLogger(t, false) + + // Create stdout buffer first so it can be set in config + var stdout bytes.Buffer + + config := ProcessorConfig{ + Colorize: false, + Compact: false, + AnalyzeOn: tt.analyzeOn, + OutputFormat: tt.outputFormat, + OutputVersion: tt.outputVersion, + Logger: logger, + Advisories: NewAdvisoryCollector(nil), + Stdout: &stdout, // Set stdout in config so renderers can access it + Stderr: &bytes.Buffer{}, // Discard stderr for tests + RenderFunc: func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + }, nil + }, + } + config.SetDefaultFactories() + diffOpts := config.GetDiffOptions() + diffRenderer := config.Factories.DiffRenderer(logger, diffOpts) + compDiffRenderer := config.Factories.CompDiffRenderer(logger, diffRenderer, diffOpts) + + processor := &DefaultCompDiffProcessor{ + compositionClient: xpClients.Composition, + xrProc: mockXRProc, + config: config, + compDiffRenderer: compDiffRenderer, + } + + _, err := processor.DiffComposition(ctx, tt.compositions, tt.namespace, nil) + + if (err != nil) != tt.wantErr { + t.Errorf("DiffComposition() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.verifyOutput != nil { + tt.verifyOutput(t, stdout.String()) + } + }) + } +} + +// TestDefaultCompDiffProcessor_calculateCompositionDiff pins the split between the diff the user is +// shown and whether the composition changed at all (issue #453). +// +// The two come apart only under --ignore-paths. Gating impact analysis on the *displayed* diff would +// mean that masking a path which is load-bearing for rendering silently skips the analysis for a +// composition that genuinely changes the rendered output — so `changed` must ignore the mask. +func TestDefaultCompDiffProcessor_calculateCompositionDiff(t *testing.T) { + ctx := t.Context() + + compWithLabels := func(labels map[string]string) *un.Unstructured { + return tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(labels). + BuildAsUnstructured() + } + + // lastAppliedFor is the last-applied-configuration a client-side `kubectl apply` of c writes: c minus + // the annotation, JSON-encoded, keeping the emptied annotations map as kubectl does. + lastAppliedFor := func(c *un.Unstructured) string { + obj := c.DeepCopy() + annotations := obj.GetAnnotations() + delete(annotations, "kubectl.kubernetes.io/last-applied-configuration") + _ = un.SetNestedStringMap(obj.Object, annotations, "metadata", "annotations") + + b, err := json.Marshal(obj.Object) + if err != nil { + t.Fatalf("cannot encode last-applied-configuration: %v", err) + } + + return string(b) + } + + type want struct { + hasDiff bool + scope ChangeScope + // revisionNamePredictable is false only when the compositions differ by nothing but kubectl's + // last-applied-configuration, whose post-apply value depends on how the user applies rather than + // on the file — so the resulting revision's name cannot be derived. See issue #474. + revisionNamePredictable bool + } + + tests := map[string]struct { + input *un.Unstructured + // clusterAnnotations are annotations present only on the in-cluster copy, modelling what + // a deploy tool stamps on apply. + clusterAnnotations map[string]string + ignorePaths []string + want want + }{ + "Identical_NoIgnorePaths": { + input: compWithLabels(map[string]string{"version": "0.0.1"}), + want: want{hasDiff: false, scope: ChangeScopeNone, revisionNamePredictable: true}, + }, + "Different_NoIgnorePaths": { + input: compWithLabels(map[string]string{"version": "0.0.2"}), + want: want{hasDiff: true, scope: ChangeScopeMetadata, revisionNamePredictable: true}, + }, + "Identical_WithIgnorePaths": { + input: compWithLabels(map[string]string{"version": "0.0.1"}), + ignorePaths: []string{"metadata.labels[version]"}, + want: want{hasDiff: false, scope: ChangeScopeNone, revisionNamePredictable: true}, + }, + // The only difference is masked: nothing to show the user, but the composition DID change, so + // impact analysis must still run. + "DifferenceMaskedByIgnorePaths_StillChanged": { + input: compWithLabels(map[string]string{"version": "0.0.2"}), + ignorePaths: []string{"metadata.labels[version]"}, + want: want{hasDiff: false, scope: ChangeScopeMetadata, revisionNamePredictable: true}, + }, + // The cluster copy carries the annotation a client-side kubectl apply stamps; the file copy + // never does. It is suppressed from the rendered diff because showing a multi-KB serialization + // of the object is useless — but Crossplane's Composition.Hash() covers annotations, so + // applying this does create a new CompositionRevision that composites re-point to. A + // readability suppression must not decide that away: changed is true, and the composites get + // evaluated. + "LastAppliedConfigurationOnly_Changed": { + input: compWithLabels(map[string]string{"version": "0.0.1"}), + clusterAnnotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition"}`, + }, + want: want{hasDiff: false, scope: ChangeScopeMetadata, revisionNamePredictable: false}, + }, + // Same, with the user explicitly masking the annotation. --ignore-paths is a display + // preference, so it does not change the verdict either — same reason a load-bearing + // spec.pipeline[].input mask does not (see DifferenceMaskedByIgnorePaths_StillChanged). + "LastAppliedConfigurationExplicitlyMasked_StillChanged": { + input: compWithLabels(map[string]string{"version": "0.0.1"}), + clusterAnnotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition"}`, + }, + ignorePaths: []string{"metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]"}, + want: want{hasDiff: false, scope: ChangeScopeMetadata, revisionNamePredictable: false}, + }, + // Issue #500: the cluster's annotation is the one a client-side apply of this very file writes, so + // re-applying it produces an empty patch and kubectl writes nothing. No revision is created. + "LastAppliedConfigurationDescribesFile_Unchanged": { + input: compWithLabels(map[string]string{"version": "0.0.1"}), + clusterAnnotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": lastAppliedFor(compWithLabels(map[string]string{"version": "0.0.1"})), + }, + want: want{hasDiff: false, scope: ChangeScopeNone, revisionNamePredictable: true}, + }, + // A file that carries the annotation itself would have it written verbatim under server-side + // apply, so the outcome again depends on the apply mode, even though the cluster's value describes + // the file. + "LastAppliedConfigurationInFile_StillChanged": { + input: tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.1"}). + WithAnnotations(map[string]string{"kubectl.kubernetes.io/last-applied-configuration": `{}`}). + BuildAsUnstructured(), + clusterAnnotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": lastAppliedFor(compWithLabels(map[string]string{"version": "0.0.1"})), + }, + want: want{hasDiff: false, scope: ChangeScopeMetadata, revisionNamePredictable: false}, + }, + // A value that is not JSON was not written by kubectl, so what applying does is unknown. + "LastAppliedConfigurationUnparseable_StillChanged": { + input: compWithLabels(map[string]string{"version": "0.0.1"}), + clusterAnnotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": `not json`, + }, + want: want{hasDiff: false, scope: ChangeScopeMetadata, revisionNamePredictable: false}, + }, + // The discriminator for the guard: the cluster copy carries the kubectl annotation AND the + // compositions genuinely differ. The annotation is no longer the *sole* delta, so the hash + // difference is driven by real content and the name follows from the composition as supplied. + // (For a client-side-apply user the eventual suffix may still differ, since kubectl rewrites the + // annotation on apply — the change is detected either way, its predicted value is just imprecise.) + "LastAppliedConfigurationPlusRealEdit_StillPredictable": { + input: compWithLabels(map[string]string{"version": "0.0.2"}), + clusterAnnotations: map[string]string{ + "kubectl.kubernetes.io/last-applied-configuration": `{"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition"}`, + }, + want: want{hasDiff: true, scope: ChangeScopeMetadata, revisionNamePredictable: true}, + }, + // A spec difference, which --analyze-on=spec-change is the only setting to distinguish. Every + // other case in this table changes metadata only. + "SpecDifference_ScopeIsSpec": { + input: tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.1"}). + WithPipelineStep("step-a", "function-a", nil). + BuildAsUnstructured(), + want: want{hasDiff: true, scope: ChangeScopeSpec, revisionNamePredictable: true}, + }, + // A spec difference masked from display is still a spec change: --ignore-paths must not be able + // to downgrade the scope, or --analyze-on=spec-change would skip a composition that genuinely + // renders differently. + "SpecDifferenceMasked_ScopeStillSpec": { + input: tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.1"}). + WithPipelineStep("step-a", "function-a", nil). + BuildAsUnstructured(), + ignorePaths: []string{"spec.pipeline"}, + want: want{hasDiff: false, scope: ChangeScopeSpec, revisionNamePredictable: true}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + clusterComp := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.1"}). + WithAnnotations(tt.clusterAnnotations). + Build() + + processor := &DefaultCompDiffProcessor{ + compositionClient: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(clusterComp). + Build(), + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + IgnorePaths: tt.ignorePaths, + }, + } + + got, err := processor.calculateCompositionDiff(ctx, tt.input) + if err != nil { + t.Fatalf("calculateCompositionDiff() unexpected error: %v", err) + } + + if diff := gcmp.Diff(tt.want, want{hasDiff: got.diff != nil, scope: got.scope, revisionNamePredictable: got.revisionNamePredictable}, gcmp.AllowUnexported(want{})); diff != "" { + t.Errorf("calculateCompositionDiff() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestChangeScope_triggersAnalysis pins the --analyze-on decision table (issue #472). The values are +// ordered, so the matrix should be monotonic: anything spec-change analyses, any-change also +// analyses, and always analyses everything. +func TestChangeScope_triggersAnalysis(t *testing.T) { + type want struct { + specChange bool + anyChange bool + always bool + // unset is the zero-value AnalyzeOn, which callers constructing a ProcessorConfig directly + // will hit; it must behave as the CLI default rather than analysing nothing. + unset bool + } + + tests := map[string]struct { + scope ChangeScope + want want + }{ + "None": { + scope: ChangeScopeNone, + want: want{specChange: false, anyChange: false, always: true, unset: false}, + }, + // The case the whole flag exists for: a new CompositionRevision is created, but the user may + // not want to pay a render per composite to find out whether it propagates. + "Metadata": { + scope: ChangeScopeMetadata, + want: want{specChange: false, anyChange: true, always: true, unset: true}, + }, + "Spec": { + scope: ChangeScopeSpec, + want: want{specChange: true, anyChange: true, always: true, unset: true}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := want{ + specChange: tt.scope.triggersAnalysis(AnalyzeOnSpecChange), + anyChange: tt.scope.triggersAnalysis(AnalyzeOnAnyChange), + always: tt.scope.triggersAnalysis(AnalyzeOnAlways), + unset: tt.scope.triggersAnalysis(""), + } + + if diff := gcmp.Diff(tt.want, got, gcmp.AllowUnexported(want{})); diff != "" { + t.Errorf("triggersAnalysis() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { + // compLabels are the labels of the edited composition being diffed (which the resulting + // CompositionRevision would inherit). An Automatic XR's compositionRevisionSelector is evaluated + // against these to decide whether the XR would adopt the new revision. + type droppedWant struct { + name string + reason renderer.FilterReason + } + + tests := map[string]struct { + includeManual bool + compName string // defaults to "test-comp" when empty + compLabels map[string]string + xrs []*un.Unstructured + wantKept []string + wantDropped []droppedWant + // wantRepointing names which of wantKept would actually adopt the resulting revision, and so + // are counted in RepointedComposites and seeded with its predicted name. It equals wantKept + // except where --include-manual keeps a Manual-policy XR, which stays pinned by its + // compositionRevisionRef (issue #479). + wantRepointing []string + wantErr bool + }{ + // Manual side: --include-manual keeps Manual XRs... + "IncludeManualTrue_KeepsManualXRs": { + includeManual: true, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy").Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy").Build(), + }, + wantKept: []string{"manual-xr", "auto-xr"}, + wantRepointing: []string{"auto-xr"}, + wantDropped: nil, + // ...but keeping the Manual XR for analysis does not unpin it: only auto-xr re-points. + }, + // A Manual XR kept only by --include-manual re-points not at all, so a run whose entire kept set + // is Manual reports zero repointed composites even though it renders one composite. + "IncludeManualTrue_OnlyManualXR_NoneRepoint": { + includeManual: true, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-only").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy").Build(), + }, + wantKept: []string{"manual-only"}, + wantRepointing: nil, + wantDropped: nil, + }, + // Selector side: ...but --include-manual does NOT re-include selector-mismatched + // Automatic XRs — they would never adopt the new revision. + "IncludeManualTrue_StillDropsSelectorMismatchedAutomaticXRs": { + includeManual: true, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy").Build(), + tu.NewResource("example.org/v1", "XResource", "auto-mismatch").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.1"}, nil).Build(), + }, + wantKept: []string{"manual-xr"}, + wantRepointing: nil, + wantDropped: []droppedWant{{name: "auto-mismatch", reason: renderer.FilterReasonRevisionSelectorMismatch}}, + // The only kept XR is the pinned Manual one, so nothing re-points. + }, + "IncludeManualFalse_FiltersManualXRs": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy").Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy").Build(), + }, + wantKept: []string{"auto-xr"}, + wantRepointing: []string{"auto-xr"}, + wantDropped: []droppedWant{{name: "manual-xr", reason: renderer.FilterReasonManualPolicy}}, + }, + // Automatic XR whose selector does not match the composition labels is dropped. + "AutomaticSelectorMismatch_Dropped": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "selector-old").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.1"}, nil).Build(), + }, + wantKept: nil, + wantRepointing: nil, + wantDropped: []droppedWant{{name: "selector-old", reason: renderer.FilterReasonRevisionSelectorMismatch}}, + }, + // Automatic XR whose selector matches the composition labels is kept. + "AutomaticSelectorMatch_Kept": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "selector-new").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.2"}, nil).Build(), + }, + wantKept: []string{"selector-new"}, + wantRepointing: []string{"selector-new"}, + wantDropped: nil, + }, + // A selector that keys on crossplane.io/composition-name (via Exists) matches because the + // composition's predicted revision labels include that stamped label (added by + // predictedRevisionLabels from the composition's name). Guards that augmentation. + "AutomaticSelectorOnCompositionName_Kept": { + includeManual: false, + compName: "xnopresources.example.org", + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "name-selector").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, nil, []map[string]any{ + {"key": xp.LabelCompositionName, "operator": "Exists"}, + }).Build(), + }, + wantKept: []string{"name-selector"}, + wantRepointing: []string{"name-selector"}, + wantDropped: nil, + }, + // The hash label is stamped too, and is as predictable as the name — both derive from the + // exported Composition.Hash(). Before issue #474 it was omitted from the predicted label set on + // the stated grounds that it wasn't predictable, so an XR selecting on it was dropped as a + // selector mismatch and told its own labels didn't match. Guards that it is stamped. + "AutomaticSelectorOnCompositionHash_Kept": { + includeManual: false, + compName: "xnopresources.example.org", + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "hash-selector").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, nil, []map[string]any{ + {"key": xp.LabelCompositionHash, "operator": "Exists"}, + }).Build(), + }, + wantKept: []string{"hash-selector"}, + wantRepointing: []string{"hash-selector"}, + wantDropped: nil, + }, + // Automatic XR with no selector is kept (unchanged from prior behavior). + "AutomaticNoSelector_Kept": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "no-selector").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy").Build(), + tu.NewResource("example.org/v1", "XResource", "default-policy").WithNamespace("default").Build(), + }, + wantKept: []string{"no-selector", "default-policy"}, + wantRepointing: []string{"no-selector", "default-policy"}, + wantDropped: nil, + }, + // Manual XR with a matching selector is still dropped by policy (reason manual_policy), + // not rescued by the selector match. + "ManualWithMatchingSelector_DroppedByPolicy": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-match").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.2"}, nil).Build(), + }, + wantKept: nil, + wantRepointing: nil, + wantDropped: []droppedWant{{name: "manual-match", reason: renderer.FilterReasonManualPolicy}}, + }, + "EmptyList_ReturnsEmpty": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{}, + wantKept: nil, + wantRepointing: nil, + wantDropped: nil, + }, + // A deleting XR is on Crossplane's teardown path and will never adopt the resulting revision, + // so it is dropped regardless of policy or selector (issue #452). + "Deleting_Dropped": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "deleting-xr").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithDeletionTimestamp("2026-09-07T11:25:03Z").Build(), + tu.NewResource("example.org/v1", "XResource", "live-xr").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy").Build(), + }, + wantKept: []string{"live-xr"}, + wantRepointing: []string{"live-xr"}, + wantDropped: []droppedWant{{name: "deleting-xr", reason: renderer.FilterReasonDeleting}}, + }, + // Deletion is evaluated before the policy rules and is not rescued by --include-manual: a + // deleting Manual XR is reported as deleting, not as manual_policy. + "DeletingManualXR_DroppedAsDeletingEvenWithIncludeManual": { + includeManual: true, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "deleting-manual").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + WithDeletionTimestamp("2026-09-07T11:25:03Z").Build(), + }, + wantKept: nil, + wantRepointing: nil, + wantDropped: []droppedWant{{name: "deleting-manual", reason: renderer.FilterReasonDeleting}}, + }, + // A matching compositionRevisionSelector does not rescue a deleting XR either. + "DeletingWithMatchingSelector_Dropped": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "deleting-match").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.2"}, nil). + WithDeletionTimestamp("2026-09-07T11:25:03Z").Build(), + }, + wantKept: nil, + wantRepointing: nil, + wantDropped: []droppedWant{{name: "deleting-match", reason: renderer.FilterReasonDeleting}}, + }, + // An explicit null deletionTimestamp is how round-tripped Kubernetes YAML spells "unset"; + // it must not be mistaken for a deleting XR. + "NullDeletionTimestamp_Kept": { + includeManual: false, + compLabels: map[string]string{"version": "0.0.2"}, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "null-ts-xr").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithDeletionTimestamp(nil).Build(), + }, + wantKept: []string{"null-ts-xr"}, + wantRepointing: []string{"null-ts-xr"}, + wantDropped: nil, + }, + // Composition with no labels: an Automatic XR with a non-empty selector cannot match, so it + // is dropped as a selector mismatch. + "NoCompositionLabels_SelectorMismatch_Dropped": { + includeManual: false, + compLabels: nil, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "selector-xr").WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.2"}, nil).Build(), + }, + wantKept: nil, + wantRepointing: nil, + wantDropped: []droppedWant{{name: "selector-xr", reason: renderer.FilterReasonRevisionSelectorMismatch}}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := &DefaultCompDiffProcessor{ + config: ProcessorConfig{ + IncludeManual: tt.includeManual, + Logger: tu.TestLogger(t, false), + }, + } + + compName := tt.compName + if compName == "" { + compName = "test-comp" + } + + newComp := tu.NewComposition(compName). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithLabels(tt.compLabels). + BuildAsUnstructured() + + pred, err := predictRevision(newComp) + if err != nil { + t.Fatalf("predictRevision() unexpected error: %v", err) + } + + kept, dropped, repointing, err := processor.partitionXRsByUpdatePolicy(tt.xrs, newComp, pred) + + if tt.wantErr { + if err == nil { + t.Fatalf("partitionXRsByUpdatePolicy() expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("partitionXRsByUpdatePolicy() unexpected error: %v", err) + } + + gotKept := make([]string, len(kept)) + for i, xr := range kept { + gotKept[i] = xr.GetName() + } + + if diff := gcmp.Diff(tt.wantKept, gotKept, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("kept XR names mismatch (-want +got):\n%s", diff) + } + + gotDropped := make([]droppedWant, len(dropped)) + for i, d := range dropped { + gotDropped[i] = droppedWant{name: d.xr.GetName(), reason: d.reason} + } + + if diff := gcmp.Diff(tt.wantDropped, gotDropped, cmpopts.EquateEmpty(), gcmp.AllowUnexported(droppedWant{})); diff != "" { + t.Errorf("dropped XRs mismatch (-want +got):\n%s", diff) + } + + var gotRepointing []string + + for _, xr := range kept { + if repointing[dt.MakeDiffKeyFromResource(xr)] { + gotRepointing = append(gotRepointing, xr.GetName()) + } + } + + if diff := gcmp.Diff(tt.wantRepointing, gotRepointing, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("repointing XRs mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestDefaultCompDiffProcessor_collectXRDiffs_RevisionName pins which composites comp's composition +// provider resolves to the predicted revision: only the ones that would re-point at it, and none when the +// name is unpredictable. A Manual composite kept by --include-manual stays pinned, so rendering it with +// the new name would render it against a revision it never uses. A claim reaches the provider as its +// backing XR, which is what gets the name. The supplied composites are passed through unmodified: the +// name is applied to what render consumes, never to the cluster's objects. +func TestDefaultCompDiffProcessor_buildImpactAnalysis(t *testing.T) { + xr := tu.NewResource("example.org/v1", "XResource", "my-xr").WithNamespace("default").Build() + ref := corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "my-xr", Namespace: "default"} + + failures := []dt.ResourceValidationFailure{{ + APIVersion: "example.org/v1", + Kind: "Bucket", + Name: "my-bucket", + Status: "invalid", + Errors: []dt.FieldValidationError{{Type: "schema", Field: "spec.region", Message: "Required value"}}, + }} + validationErr := &SchemaValidationError{Message: "validation failed", Failures: failures} + + modified := &dt.ResourceDiff{DiffType: dt.DiffTypeModified} + + tests := map[string]struct { + result *XRDiffResult + wantImpact renderer.XRImpact + wantSummary renderer.AffectedResourcesSummary + }{ + // The impact's error is what both the per-XR entry and the top-level errors[] report, so it + // must keep the typed validation breakdown rather than flatten it to a message. + "ValidationErrorKeepsValidationFailures": { + result: &XRDiffResult{Error: validationErr}, + wantImpact: renderer.XRImpact{ + ObjectReference: ref, + Status: renderer.XRStatusError, + Error: &dt.OutputError{ + ResourceID: "XResource/my-xr", + Message: validationErr.Error(), + ValidationFailures: failures, + }, + }, + wantSummary: renderer.AffectedResourcesSummary{Total: 1, WithErrors: 1}, + }, + "PlainErrorCarriesOnlyItsMessage": { + result: &XRDiffResult{Error: errors.New("render failed")}, + wantImpact: renderer.XRImpact{ + ObjectReference: ref, + Status: renderer.XRStatusError, + Error: &dt.OutputError{ResourceID: "XResource/my-xr", Message: "render failed"}, + }, + wantSummary: renderer.AffectedResourcesSummary{Total: 1, WithErrors: 1}, + }, + "Changed": { + result: &XRDiffResult{Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": modified}}, + wantImpact: renderer.XRImpact{ + ObjectReference: ref, + Status: renderer.XRStatusChanged, + Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": modified}, + }, + wantSummary: renderer.AffectedResourcesSummary{Total: 1, WithChanges: 1}, + }, + "NoResultIsUnchanged": { + wantImpact: renderer.XRImpact{ObjectReference: ref, Status: renderer.XRStatusUnchanged}, + wantSummary: renderer.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + results := map[string]*XRDiffResult{} + if tt.result != nil { + results[dt.MakeDiffKeyFromResource(xr)] = tt.result + } + + impacts, summary := (&DefaultCompDiffProcessor{}).buildImpactAnalysis([]*un.Unstructured{xr}, results) + + if diff := gcmp.Diff([]renderer.XRImpact{tt.wantImpact}, impacts); diff != "" { + t.Errorf("buildImpactAnalysis() impacts mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.wantSummary, summary); diff != "" { + t.Errorf("buildImpactAnalysis() summary mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultCompDiffProcessor_collectXRDiffs_RevisionName(t *testing.T) { + const predicted = "comp-abc1234" + + auto := tu.NewResource("example.org/v1", "XResource", "auto-xr").WithNamespace("default").Build() + manual := tu.NewResource("example.org/v1", "XResource", "manual-xr").WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + WithNestedField(map[string]any{"name": "comp-0ld0001"}, "spec", "crossplane", "compositionRevisionRef").Build() + selective := tu.NewResource("example.org/v1", "XResource", "selective-xr").WithNamespace("default"). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"channel": "stable"}, nil).Build() + claim := tu.NewResource("example.org/v1", "Resource", "my-claim").WithNamespace("default").Build() + backing := tu.NewResource("example.org/v1", "XResource", "my-claim-x7k2p").Build() + + // The labels the predicted revision would carry; the selective composite's selector rejects them. + revisionLabels := map[string]string{xp.LabelCompositionName: "comp"} + + tests := map[string]struct { + reason string + // xr is the composite comp diffs. + xr *un.Unstructured + // rendered is what the processor hands the provider: xr's effective composite, which for a claim is + // its backing XR. xr when nil. + rendered *un.Unstructured + revisionName string + // wantRevision is the revision the provider resolves rendered to; empty for none. + wantRevision string + }{ + "RepointingCompositeGetsThePredictedName": { + reason: "A composite that would re-point at the new revision is rendered with its name.", + xr: auto, + revisionName: predicted, + wantRevision: predicted, + }, + "ClaimsBackingXRGetsThePredictedName": { + reason: "A claim is rendered as its backing XR, which is matched by type and gets the name.", + xr: claim, + rendered: backing, + revisionName: predicted, + wantRevision: predicted, + }, + "ManualCompositeKeepsItsRef": { + reason: "A Manual composite stays pinned, so no revision is resolved and it keeps the ref it has.", + xr: manual, + revisionName: predicted, + }, + "SelectorRejectingTheRevisionKeepsItsRef": { + reason: "An Automatic composite whose compositionRevisionSelector rejects the new revision does not adopt it.", + xr: selective, + revisionName: predicted, + }, + "UnpredictableNameIsNeverUsed": { + reason: "When the revision name cannot be predicted, the composite renders with the ref it has.", + xr: auto, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + rendered := tt.rendered + if rendered == nil { + rendered = tt.xr + } + + before := tt.xr.DeepCopy() + + var got types.ResolvedComposition + + processor := &DefaultCompDiffProcessor{ + xrProc: &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(ctx context.Context, _ *un.Unstructured, provider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + var err error + + got, err = provider(ctx, rendered) + + return map[string]*dt.ResourceDiff{}, err + }, + }, + config: ProcessorConfig{Logger: tu.TestLogger(t, false), IncludeManual: true}, + } + + comp := tu.NewComposition("comp").WithCompositeTypeRef("example.org/v1", "XResource").WithPipelineMode().BuildAsUnstructured() + results := processor.collectXRDiffs(t.Context(), []*un.Unstructured{tt.xr}, comp, tt.revisionName, revisionLabels) + + if err := results[dt.MakeDiffKeyFromResource(tt.xr)].Error; err != nil { + t.Fatalf("%s\ncollectXRDiffs(): unexpected error: %v", tt.reason, err) + } + + if diff := gcmp.Diff("comp", got.Composition.GetName()); diff != "" { + t.Errorf("%s\ncollectXRDiffs() provider composition (-want +got):\n%s", tt.reason, diff) + } + + if diff := gcmp.Diff(tt.wantRevision, got.RevisionName); diff != "" { + t.Errorf("%s\ncollectXRDiffs() provider revision (-want +got):\n%s", tt.reason, diff) + } + + if diff := gcmp.Diff(before, tt.xr); diff != "" { + t.Errorf("%s\ncollectXRDiffs() modified the supplied composite (-before +after):\n%s", tt.reason, diff) + } + }) + } +} + +// TestPredictedRevisionLabels verifies the label set used to evaluate an XR's +// compositionRevisionSelector mirrors what a real CompositionRevision would carry: the composition's +// own labels plus crossplane.io/composition-name and crossplane.io/composition-hash. The source +// composition must not be mutated. +// +// The hash is supplied rather than computed, so this pins what this function is responsible for — +// stamping both labels from its inputs — and leaves the derivation to TestRevisionIdentity. Asserting +// against a locally recomputed Hash() would only restate the implementation. +func TestPredictedRevisionLabels(t *testing.T) { + newComp := tu.NewComposition("xnopresources.example.org"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured() + + got := predictedRevisionLabels(newComp, predictedRevision{ + name: "xnopresources.example.org-deadbee", + hash: "deadbeef", + }) + + want := map[string]string{ + "version": "0.0.2", + xp.LabelCompositionName: "xnopresources.example.org", + xp.LabelCompositionHash: "deadbeef", + } + + if diff := gcmp.Diff(want, got); diff != "" { + t.Errorf("predictedRevisionLabels() mismatch (-want +got):\n%s", diff) + } + + // The source composition's own labels must be untouched (no stamped labels injected). + for _, stamped := range []string{xp.LabelCompositionName, xp.LabelCompositionHash} { + if _, injected := newComp.GetLabels()[stamped]; injected { + t.Errorf("predictedRevisionLabels mutated the source composition's labels (injected %s)", stamped) + } + } +} + +// TestRevisionIdentity pins the one derivation this tool mirrors from upstream rather than calling: +// NewCompositionRevision's "-" name and its hash[:63] label value. The two +// bounds differ, and upstream's guards are >= rather than >, so the short-hash cases matter — in +// particular "unknown", which is what Composition.Hash() returns on a marshal error and which must +// therefore round-trip identically here and in the cluster. +func TestRevisionIdentity(t *testing.T) { + // A full sha256 hex digest: 64 characters, one more than the label-value limit. + full := strings.Repeat("a", 63) + "b" + + tests := map[string]struct { + compName string + hash string + want predictedRevision + }{ + "FullDigestTruncatedToBothBounds": { + compName: "xbuckets.example.org", + hash: full, + want: predictedRevision{ + name: "xbuckets.example.org-aaaaaaa", + hash: strings.Repeat("a", 63), + }, + }, + "ExactlyLabelLimitIsUnchanged": { + compName: "xbuckets.example.org", + hash: strings.Repeat("c", 63), + want: predictedRevision{ + name: "xbuckets.example.org-ccccccc", + hash: strings.Repeat("c", 63), + }, + }, + "ExactlyNameSuffixLengthIsUnchanged": { + compName: "xbuckets.example.org", + hash: "abcdef0", + want: predictedRevision{name: "xbuckets.example.org-abcdef0", hash: "abcdef0"}, + }, + "ShorterThanNameSuffixIsUsedWhole": { + compName: "xbuckets.example.org", + hash: "abc", + want: predictedRevision{name: "xbuckets.example.org-abc", hash: "abc"}, + }, + "MarshalErrorSentinel": { + // Composition.Hash() returns "unknown" if it cannot marshal. Seven characters, so the >= + // guard truncates to the whole string and the name suffix is the sentinel itself. + compName: "xbuckets.example.org", + hash: "unknown", + want: predictedRevision{name: "xbuckets.example.org-unknown", hash: "unknown"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if diff := gcmp.Diff(tt.want, revisionIdentity(tt.compName, tt.hash), gcmp.AllowUnexported(predictedRevision{})); diff != "" { + t.Errorf("revisionIdentity() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestPredictRevision checks that prediction is wired to Crossplane's own Composition.Hash() over +// labels, annotations and spec — so any of the three changing changes the predicted revision, and a +// composition that differs in none of them predicts the same revision it already has. The hash values +// themselves are deliberately not asserted; that would just restate upstream's algorithm. +func TestPredictRevision(t *testing.T) { + base := func() *tu.CompositionBuilder { + return tu.NewComposition("xbuckets.example.org"). + WithCompositeTypeRef("example.org/v1", "XBucket") + } + + baseline, err := predictRevision(base().BuildAsUnstructured()) + if err != nil { + t.Fatalf("predictRevision() unexpected error: %v", err) + } + + if want := "xbuckets.example.org-"; !strings.HasPrefix(baseline.name, want) { + t.Errorf("predictRevision() name = %q, want prefix %q", baseline.name, want) + } + + same, err := predictRevision(base().BuildAsUnstructured()) + if err != nil { + t.Fatalf("predictRevision() unexpected error: %v", err) + } + + if same != baseline { + t.Errorf("predictRevision() is not deterministic: %+v vs %+v", baseline, same) + } + + // Labels and annotations are hashed as well as spec, which is the whole reason a metadata-only + // composition edit still mints a revision. + for name, differing := range map[string]*un.Unstructured{ + "Labels": base().WithLabels(map[string]string{"channel": "preview"}).BuildAsUnstructured(), + "Annotations": base().WithAnnotations(map[string]string{"example.org/note": "hi"}).BuildAsUnstructured(), + "Spec": base().WithPipelineMode().BuildAsUnstructured(), + } { + t.Run(name, func(t *testing.T) { + got, err := predictRevision(differing) + if err != nil { + t.Fatalf("predictRevision() unexpected error: %v", err) + } + + if got == baseline { + t.Errorf("predictRevision() unchanged by a difference in %s: %+v", name, got) + } + }) + } +} + +// TestXRUpdatePolicy exercises the shared xp.XRUpdatePolicy reader (v2/v1 path precedence and the +// Automatic default) through the diffprocessor package that depends on it. +func TestXRUpdatePolicy(t *testing.T) { + tests := map[string]struct { + xr *un.Unstructured + want string + }{ + "V2Path_Manual": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: "Manual", + }, + "V2Path_Automatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: "Automatic", + }, + "V1Path_Manual": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Manual", "spec", "compositionUpdatePolicy"). + Build(), + want: "Manual", + }, + "V1Path_Automatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Automatic", "spec", "compositionUpdatePolicy"). + Build(), + want: "Automatic", + }, + "NoPolicy_DefaultsToAutomatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr").Build(), + want: "Automatic", + }, + "EmptyPolicy_DefaultsToAutomatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("", "spec", "compositionUpdatePolicy"). + Build(), + want: "Automatic", + }, + "V2PathTakesPrecedenceOverV1Path": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Automatic", "spec", "compositionUpdatePolicy"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: "Manual", // v2 path value should be used + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := xp.XRUpdatePolicy(tt.xr.Object, tt.xr.GetAPIVersion()) + if err != nil { + t.Fatalf("XRUpdatePolicy() unexpected error: %v", err) + } + + if got != tt.want { + t.Errorf("XRUpdatePolicy() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestDefaultCompDiffProcessor_collectXRDiffs_NestedXRCompositionLookup verifies that +// when processing nested XRs, the composition provider correctly distinguishes between: +// - Root XR type (matching the CLI composition's compositeTypeRef): returns CLI composition +// - Nested XR types (different GVK): should look up from cluster, not return CLI composition +// +// This test documents a bug where the composition provider always returns the CLI composition +// regardless of the resource type, causing nested XRs to be rendered with the wrong composition. +func TestDefaultCompDiffProcessor_collectXRDiffs_NestedXRCompositionLookup(t *testing.T) { + ctx := t.Context() + + // Create the CLI composition targeting XParentResource + parentComposition := tu.NewComposition("parent-composition"). + WithCompositeTypeRef("parent.example.org/v1", "XParentResource"). + WithPipelineMode(). + Build() + + parentCompUnstructured := tu.NewComposition("parent-composition"). + WithCompositeTypeRef("parent.example.org/v1", "XParentResource"). + WithPipelineMode(). + BuildAsUnstructured() + + // Create a composition for the nested XR (different type) + childComposition := tu.NewComposition("child-composition"). + WithCompositeTypeRef("child.example.org/v1", "XChildResource"). + WithPipelineMode(). + Build() + + // Create the root XR (matches CLI composition type) + // Root XR is identified by GVK+name+namespace, which matches what's in the xrs slice + rootXR := tu.NewResource("parent.example.org/v1", "XParentResource", "test-parent"). + Build() + + // Create a nested XR (different type - does NOT match CLI composition) + // Nested XR has different GVK, so it should look up its composition from cluster + nestedXR := tu.NewResource("child.example.org/v1", "XChildResource", "test-child"). + Build() + + tests := map[string]struct { + description string + setupMocks func() (xp.CompositionClient, *tu.MockDiffProcessor, *[]string) + xrs []*un.Unstructured + cliComposition *un.Unstructured + wantRootCompName string // Expected composition name for root XR + wantNestedCompName string // Expected composition name for nested XR (if any) + }{ + "RootXR_ReceivesCLIComposition_NestedXR_ReceivesClusterComposition": { + description: "Verifies correct behavior: root XR gets CLI composition, nested XR gets its own composition from cluster", + setupMocks: func() (xp.CompositionClient, *tu.MockDiffProcessor, *[]string) { + // Track which compositions are returned for which resources + compositionRequests := make([]string, 0) + + mockXRProc := &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + // For the root XR, test what composition is returned + comp, err := compositionProvider(ctx, res) + if err != nil { + return nil, err + } + + compositionRequests = append(compositionRequests, fmt.Sprintf("%s/%s->%s", res.GetKind(), res.GetName(), comp.Composition.GetName())) + + // Simulate processing a nested XR by calling the provider with a different resource type + // This is what ProcessNestedXRs does when it encounters nested XRs + nestedComp, err := compositionProvider(ctx, nestedXR) + if err != nil { + return nil, err + } + + compositionRequests = append(compositionRequests, fmt.Sprintf("%s/%s->%s", nestedXR.GetKind(), nestedXR.GetName(), nestedComp.Composition.GetName())) + + return make(map[string]*dt.ResourceDiff), nil + }, + } + + mockCompClient := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(parentComposition). + WithResourcesForComposition("parent-composition", "", []*un.Unstructured{rootXR}). + // This will be called when looking up composition for nested XR + WithSuccessfulCompositionMatch(childComposition). + Build() + + return mockCompClient, mockXRProc, &compositionRequests + }, + xrs: []*un.Unstructured{rootXR}, + cliComposition: parentCompUnstructured, + // Correct behavior: root XR gets CLI composition, nested XR gets its own from cluster + wantRootCompName: "parent-composition", + wantNestedCompName: "child-composition", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockCompClient, mockXRProc, compositionRequests := tt.setupMocks() + + processor := &DefaultCompDiffProcessor{ + compositionClient: mockCompClient, + xrProc: mockXRProc, + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + }, + } + + _ = processor.collectXRDiffs(ctx, tt.xrs, tt.cliComposition, "", nil) + + // Verify the composition requests + if len(*compositionRequests) < 2 { + t.Fatalf("Expected at least 2 composition requests, got %d: %v", len(*compositionRequests), *compositionRequests) + } + + // Check root XR composition + rootRequest := (*compositionRequests)[0] + + expectedRoot := fmt.Sprintf("XParentResource/test-parent->%s", tt.wantRootCompName) + if rootRequest != expectedRoot { + t.Errorf("Root XR composition request mismatch: got %q, want %q", rootRequest, expectedRoot) + } + + // Check nested XR composition - nested XR should get its own composition from cluster + nestedRequest := (*compositionRequests)[1] + expectedNested := fmt.Sprintf("XChildResource/test-child->%s", tt.wantNestedCompName) + + if nestedRequest != expectedNested { + t.Errorf("Nested XR composition request mismatch: got %q, want %q", nestedRequest, expectedNested) + } + }) + } +} + +// TestDefaultCompDiffProcessor_DiffComposition_StderrErrorOutput verifies that when +// XR processing fails, detailed errors are written to stderr for human visibility. +// This tests the WithStderr option and the stderr error output path. +func TestDefaultCompDiffProcessor_DiffComposition_StderrErrorOutput(t *testing.T) { + ctx := t.Context() + + // Create test composition + testComp := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + Build() + + // Create test XRs - one will succeed, one will fail + successXR := tu.NewResource("example.org/v1", "XResource", "success-xr"). + WithNamespace("default"). + Build() + failXR := tu.NewResource("example.org/v1", "XResource", "fail-xr"). + WithNamespace("default"). + Build() + + // Setup mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(testComp). + WithResourcesForComposition("test-composition", "default", []*un.Unstructured{successXR, failXR}). + Build(), + Definition: tu.NewMockDefinitionClient().Build(), + Environment: tu.NewMockEnvironmentClient().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + // Create mock XR processor that fails for one XR + mockXRProc := &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(_ context.Context, res *un.Unstructured, _ types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + if res.GetName() == "fail-xr" { + return nil, fmt.Errorf("render pipeline failed: function timeout") + } + + return make(map[string]*dt.ResourceDiff), nil + }, + } + + // Create stderr buffer to capture error output + var stderrBuf bytes.Buffer + + // Create processor with custom stderr + logger := tu.TestLogger(t, false) + processor := NewCompDiffProcessor( + mockXRProc, + xpClients.Composition, + WithLogger(logger), + WithColorize(false), + WithCompact(false), + WithStderr(&stderrBuf), // Use WithStderr to inject test buffer + ) + + // Run the diff - should succeed but report XR errors. + // + // The input composition must differ from the cluster's (hence the label): impact analysis is + // skipped for an unchanged composition, which would make the XR failure under test unreachable. + _, err := processor.DiffComposition(ctx, []*un.Unstructured{ + tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured(), + }, "default", nil) + + // Should return error because one XR failed + if err == nil { + t.Error("Expected error due to XR processing failure, got nil") + } + + // Verify stderr contains the error details + stderrOutput := stderrBuf.String() + if !strings.Contains(stderrOutput, "ERROR:") { + t.Errorf("Expected stderr to contain 'ERROR:', got: %q", stderrOutput) + } + + if !strings.Contains(stderrOutput, "XResource/fail-xr") { + t.Errorf("Expected stderr to contain resource ID 'XResource/fail-xr', got: %q", stderrOutput) + } + + if !strings.Contains(stderrOutput, "render pipeline failed") { + t.Errorf("Expected stderr to contain error message 'render pipeline failed', got: %q", stderrOutput) + } +} + +// TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput is the comp-side twin +// of TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput: an advisory that can only +// be discovered while releasing resources must still be in warnings[], not just on stderr. comp reaches +// teardown via xrProc.Cleanup, so it needs its own guard — the xr-side test cannot fail for it. +func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput(t *testing.T) { + ctx := t.Context() + + const advisory = "Some containers could not be cleaned up" + + testComp := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + Build() + + testXR := tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNamespace("default"). + Build() + + compClient := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(testComp). + WithResourcesForComposition("test-composition", "default", []*un.Unstructured{testXR}). + Build() + + var stdout, stderr bytes.Buffer + + advisories := NewAdvisoryCollector(&stderr) + + mockXRProc := &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(context.Context, *un.Unstructured, types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + return map[string]*dt.ResourceDiff{}, nil + }, + // Stands in for the leftover-container advisory raised from the function provider's teardown, + // which reaches comp through DefaultCompDiffProcessor.Cleanup -> xrProc.Cleanup. + CleanupFn: func(context.Context) error { + advisories.Raise(dt.OutputWarning{Message: advisory, Context: map[string]string{"errors": "1"}}) + return nil + }, + } + + processor := NewCompDiffProcessor(mockXRProc, compClient, + WithLogger(tu.TestLogger(t, false)), + WithAdvisories(advisories), + WithColorize(false), + WithCompact(false), + WithOutputFormat(renderer.OutputFormatJSON), + WithStdout(&stdout), + WithStderr(&stderr), + ) + + // The input composition carries a label the cluster copy lacks, so impact analysis actually runs. + changed := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XResource"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured() + + if _, err := processor.DiffComposition(ctx, []*un.Unstructured{changed}, "default", nil); err != nil { + t.Fatalf("DiffComposition(): unexpected error: %v", err) + } + + if !strings.Contains(stderr.String(), advisory) { + t.Errorf("teardown advisory should reach stderr, got:\n%s", stderr.String()) + } + + var got struct { + Warnings []dt.OutputWarning `json:"warnings"` + } + + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("cannot parse structured output %q: %v", stdout.String(), err) + } + + want := []dt.OutputWarning{{Message: advisory, Context: map[string]string{"errors": "1"}}} + if diff := gcmp.Diff(want, got.Warnings); diff != "" { + t.Errorf("structured warnings[] mismatch (-want +got):\n%s", diff) + } +} + +// newCompProcessorForTest builds a DefaultCompDiffProcessor wrapping the given composition client +// for use by the --resource preflight tests below. +func newCompProcessorForTest(t *testing.T, compClient xp.CompositionClient, includeManual bool) (*DefaultCompDiffProcessor, *bytes.Buffer) { + t.Helper() + + logger := tu.TestLogger(t, false) + + stdout := &bytes.Buffer{} + stderr := &bytes.Buffer{} + + config := ProcessorConfig{ + IncludeManual: includeManual, + Logger: logger, + Advisories: NewAdvisoryCollector(nil), + Stdout: stdout, + Stderr: stderr, + RenderFunc: func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil + }, + } + config.SetDefaultFactories() + + diffOpts := config.GetDiffOptions() + diffRenderer := config.Factories.DiffRenderer(logger, diffOpts) + compRenderer := config.Factories.CompDiffRenderer(logger, diffRenderer, diffOpts) + + mockXR := &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(context.Context, *un.Unstructured, types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + return map[string]*dt.ResourceDiff{}, nil + }, + } + + return &DefaultCompDiffProcessor{ + compositionClient: compClient, + config: config, + xrProc: mockXR, + compDiffRenderer: compRenderer, + }, stdout +} + +// TestDefaultCompDiffProcessor_DiffComposition_ResourceMode covers the --resource code path: +// preflight, fail-fast on globally-unmatched refs, and surfacing of policy-filtered composites — +// including that a skipped impact analysis still reports them, since establishing them needs no +// render. +func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { + comp := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + BuildAsUnstructured() + + xr1 := tu.NewResource("example.org/v1", "XR", "xr-1"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + xr2 := tu.NewResource("example.org/v1", "XR", "xr-2"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + // Manual-policy XR (v2 path). + manualXR := tu.NewResource("example.org/v1", "XR", "manual-xr"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + WithSpecField("crossplane", map[string]any{"compositionUpdatePolicy": "Manual"}). + Build() + + // DispatchesToCorrectFindMode: DiffComposition routes to ref-lookup vs default-discovery + // based on whether `resources` is non-empty. Verification is implicit via mode-specific + // helpers — WithResourcesForComposition errors on refs-mode, WithCompositesByRef errors on + // default-discovery, so wrong-mode dispatch surfaces as a non-nil DiffComposition error. + t.Run("DispatchesToCorrectFindMode", func(t *testing.T) { + dispatchTests := map[string]struct { + resources []k8stypes.NamespacedName + namespace string + compositionClient xp.CompositionClient + }{ + "EmptyResources_DefaultDiscovery": { + resources: nil, + namespace: "ns", + compositionClient: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). + WithResourcesForComposition("test-comp", "ns", []*un.Unstructured{xr1}). + Build(), + }, + "WithResources_RefLookup": { + resources: []k8stypes.NamespacedName{{Namespace: "ns", Name: "xr-1"}, {Namespace: "ns", Name: "xr-2"}}, + namespace: "", + compositionClient: tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). + WithCompositesByRef(xr1, xr2). + Build(), + }, + } + + for name, tt := range dispatchTests { + t.Run(name, func(t *testing.T) { + proc, _ := newCompProcessorForTest(t, tt.compositionClient, false) + if _, err := proc.DiffComposition(t.Context(), []*un.Unstructured{comp}, tt.namespace, tt.resources); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } + }) + + t.Run("ResourceMode_GloballyUnmatched_FailsFastNoRender", func(t *testing.T) { + client := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). + WithNoMatchingComposites(). + Build() + + proc, stdout := newCompProcessorForTest(t, client, false) + + _, err := proc.DiffComposition(t.Context(), []*un.Unstructured{comp}, "", + []k8stypes.NamespacedName{{Namespace: "ns", Name: "ghost"}}) + if err == nil { + t.Fatal("expected error from globally-unmatched preflight, got nil") + } + + if !strings.Contains(err.Error(), "ns/ghost") { + t.Errorf("error message should name the unmatched ref, got: %v", err) + } + + if stdout.Len() != 0 { + t.Errorf("expected no output before fail-fast, got: %q", stdout.String()) + } + }) + + // SurfaceFilteredControlsImpactAnalysis: when surfaceFiltered=true (resource mode), + // Manual-policy XRs are surfaced as XRStatusFiltered impacts (reason manual_policy); when false + // (default-discovery), they're counted in the summary but absent from ImpactAnalysis. + t.Run("SurfaceFilteredControlsImpactAnalysis", func(t *testing.T) { + surfaceTests := map[string]struct { + surfaceFiltered bool + wantImpactCount int + wantStatus renderer.XRStatus // empty when wantImpactCount == 0 + wantReason renderer.FilterReason // empty when wantImpactCount == 0 + }{ + "ResourceMode_SurfacesFilteredXRs": { + surfaceFiltered: true, + wantImpactCount: 1, + wantStatus: renderer.XRStatusFiltered, + wantReason: renderer.FilterReasonManualPolicy, + }, + "DefaultDiscovery_OmitsFilteredFromImpactAnalysis": { + surfaceFiltered: false, + wantImpactCount: 0, + }, + } + + for name, tt := range surfaceTests { + t.Run(name, func(t *testing.T) { + client := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). + Build() + + proc, _ := newCompProcessorForTest(t, client, false /* IncludeManual */) + + got, err := proc.processSingleComposition(t.Context(), comp, []*un.Unstructured{manualXR}, tt.surfaceFiltered) + if err != nil { + t.Fatalf("processSingleComposition: %v", err) + } + + if len(got.ImpactAnalysis) != tt.wantImpactCount { + t.Fatalf("ImpactAnalysis: got %d entries, want %d (%+v)", len(got.ImpactAnalysis), tt.wantImpactCount, got.ImpactAnalysis) + } + + if tt.wantImpactCount > 0 && got.ImpactAnalysis[0].Status != tt.wantStatus { + t.Errorf("ImpactAnalysis[0].Status: got %q, want %q", got.ImpactAnalysis[0].Status, tt.wantStatus) + } + + if tt.wantImpactCount > 0 && got.ImpactAnalysis[0].FilterReason != tt.wantReason { + t.Errorf("ImpactAnalysis[0].FilterReason: got %q, want %q", got.ImpactAnalysis[0].FilterReason, tt.wantReason) + } + + if got.AffectedResources.FilteredByPolicy != 1 { + t.Errorf("FilteredByPolicy: got %d, want 1", got.AffectedResources.FilteredByPolicy) + } + + if got.AffectedResources.Total != 1 { + t.Errorf("Total: got %d, want 1", got.AffectedResources.Total) + } + }) + } + }) + + // SkipReportsFilterConsequences: a skipped impact analysis suppresses only the per-XR renders. What + // the local filter pass already determined — how many composites were discovered, which of them + // would not adopt the resulting revision, and how many would re-point — is reported either way, so + // a run can never report zero affected composites beside a non-zero repointedComposites (issue + // #478). The same table pins that a Manual composite kept by --include-manual does not count as + // re-pointing, whether or not the analysis runs (issue #479). + t.Run("SkipReportsFilterConsequences", func(t *testing.T) { + // A metadata-only edit: the same spec as the cluster copy plus one label. That is the only scope + // --analyze-on=spec-change skips while still creating a CompositionRevision, which is what makes + // the dropped-consequence bug observable. + metaOnlyComp := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured() + + clusterComp := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + Build() + + type want struct { + Skipped bool + Summary renderer.AffectedResourcesSummary + RevisionImpact renderer.RevisionImpact + // Impacts is every ImpactAnalysis entry as " ", in order — deliberately not + // narrowed to the filtered ones. Under a skip the kept composites were never evaluated, so + // ImpactAnalysis must hold only the filtered entries; a changed/unchanged/error entry here + // would be a verdict the tool never earned, and narrowing this view would hide exactly that. + Impacts []string + } + + cases := map[string]struct { + analyzeOn AnalyzeOn + includeManual bool + surfaceFiltered bool + want want + }{ + "Skipped_DefaultDiscovery_CountsFilteredComposites": { + analyzeOn: AnalyzeOnSpecChange, + want: want{ + Skipped: true, + Summary: renderer.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, + RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + }, + }, + "Skipped_ResourceMode_NamesFilteredComposites": { + analyzeOn: AnalyzeOnSpecChange, + surfaceFiltered: true, + want: want{ + Skipped: true, + Summary: renderer.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, + RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Impacts: []string{"filtered manual-xr"}, + }, + }, + // --include-manual keeps the Manual composite, so nothing is filtered — but it stays pinned by + // compositionRevisionRef, so only the Automatic composite re-points. + "Skipped_IncludeManual_ManualCompositeDoesNotRepoint": { + analyzeOn: AnalyzeOnSpecChange, + includeManual: true, + surfaceFiltered: true, + want: want{ + Skipped: true, + Summary: renderer.AffectedResourcesSummary{Total: 2}, + RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + }, + }, + // The same tally when the analysis does run: both composites are evaluated, one re-points. + // Evaluated, so unlike every skipped case they carry a verdict. + "Analysed_IncludeManual_ManualCompositeDoesNotRepoint": { + analyzeOn: AnalyzeOnAnyChange, + includeManual: true, + surfaceFiltered: true, + want: want{ + Summary: renderer.AffectedResourcesSummary{Total: 2, Unchanged: 2}, + RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Impacts: []string{"unchanged xr-1", "unchanged manual-xr"}, + }, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + client := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(clusterComp). + Build() + + proc, _ := newCompProcessorForTest(t, client, tt.includeManual) + proc.config.AnalyzeOn = tt.analyzeOn + + got, err := proc.processSingleComposition(t.Context(), metaOnlyComp, []*un.Unstructured{xr1, manualXR}, tt.surfaceFiltered) + if err != nil { + t.Fatalf("processSingleComposition: %v", err) + } + + impacts := make([]string, 0, len(got.ImpactAnalysis)) + + for _, impact := range got.ImpactAnalysis { + impacts = append(impacts, fmt.Sprintf("%s %s", impact.Status, impact.Name)) + } + + // The predicted name's derivation is pinned by TestRevisionIdentity; this matrix only + // needs it present, since the composition's name is predictable in every case. + pred, err := predictRevision(metaOnlyComp) + if err != nil { + t.Fatalf("predictRevision: %v", err) + } + + tt.want.RevisionImpact.PredictedRevisionName = pred.name + + gotWant := want{ + Skipped: got.ImpactAnalysisSkipped, + Summary: got.AffectedResources, + RevisionImpact: got.RevisionImpact, + Impacts: impacts, + } + + if diff := gcmp.Diff(tt.want, gotWant, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("processSingleComposition() mismatch (-want +got):\n%s", diff) + } + }) + } + }) + + // RevisionSelectorMismatchSurfacedWithReasonAndCounts: an Automatic XR whose + // compositionRevisionSelector does not match the diffed composition's labels is surfaced as + // filtered with reason revision_selector_mismatch (and a detail hint), and counted in + // FilteredBySelector rather than FilteredByPolicy. + t.Run("RevisionSelectorMismatchSurfacedWithReasonAndCounts", func(t *testing.T) { + labeledComp := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithLabels(map[string]string{"version": "0.0.2"}). + BuildAsUnstructured() + + selectorMismatchXR := tu.NewResource("example.org/v1", "XR", "selector-xr"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + WithSpecField("crossplane", map[string]any{"compositionUpdatePolicy": "Automatic"}). + WithCompositionRevisionSelector(xp.CrossplaneAPIExtGroupV2, map[string]string{"version": "0.0.1"}, nil). + Build() + + client := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). + Build() + + proc, _ := newCompProcessorForTest(t, client, false /* IncludeManual */) + + got, err := proc.processSingleComposition(t.Context(), labeledComp, []*un.Unstructured{selectorMismatchXR}, true /* surfaceFiltered */) + if err != nil { + t.Fatalf("processSingleComposition: %v", err) + } + + if len(got.ImpactAnalysis) != 1 { + t.Fatalf("ImpactAnalysis: got %d entries, want 1 (%+v)", len(got.ImpactAnalysis), got.ImpactAnalysis) + } + + impact := got.ImpactAnalysis[0] + if impact.Status != renderer.XRStatusFiltered { + t.Errorf("Status: got %q, want %q", impact.Status, renderer.XRStatusFiltered) + } + + if impact.FilterReason != renderer.FilterReasonRevisionSelectorMismatch { + t.Errorf("FilterReason: got %q, want %q", impact.FilterReason, renderer.FilterReasonRevisionSelectorMismatch) + } + + if !strings.Contains(impact.FilterDetail, "does not match composition labels") { + t.Errorf("FilterDetail: got %q, want it to explain the mismatch", impact.FilterDetail) + } + + if got.AffectedResources.FilteredBySelector != 1 { + t.Errorf("FilteredBySelector: got %d, want 1", got.AffectedResources.FilteredBySelector) + } + + if got.AffectedResources.FilteredByPolicy != 0 { + t.Errorf("FilteredByPolicy: got %d, want 0", got.AffectedResources.FilteredByPolicy) + } + + if got.AffectedResources.Total != 1 { + t.Errorf("Total: got %d, want 1", got.AffectedResources.Total) + } + }) +} diff --git a/internal/diff/diffprocessor/composite_resolver.go b/internal/diff/diffprocessor/composite_resolver.go new file mode 100644 index 00000000..c16215bc --- /dev/null +++ b/internal/diff/diffprocessor/composite_resolver.go @@ -0,0 +1,370 @@ +package diffprocessor + +import ( + "context" + "fmt" + "maps" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + clixrgen "github.com/crossplane/cli/v2/cmd/crossplane/xr" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// clusterWrittenFields are the Crossplane machinery fields of a composite that Crossplane, not the +// composite's author, writes: the composite reconciler selects the composition and revision, and the +// apiserver defaults the update policy. An apply that omits one leaves the cluster's value in place, +// so a composite whose input says nothing about one is reconciled with the cluster's. Each lives at +// spec.crossplane. on a modern XR and at spec. on a legacy one. +// +//nolint:gochecknoglobals // A constant list; Go has no const slices. +var clusterWrittenFields = []string{"compositionRef", "compositionRevisionRef", "compositionUpdatePolicy"} + +// EffectiveComposite is one composite seen the ways a diff needs it. +type EffectiveComposite struct { + // Authored is the composite as supplied (see DefaultDiffProcessor.SanitizeXR). It is never + // modified, because it is what gets validated and dry-run applied: under server-side apply every + // field in a payload claims ownership of that field (#503). For a claim it is the claim, which is + // defaulted only with its own CRD (by the apiserver, or by the lenient Defaulter for a predicted + // addition), never its XR's: the two differ (crossplane-runtime pkg/xcrd schemas.go:75 and :209), + // since only the XR's defaults compositionUpdatePolicy and only the claim's compositeDeletePolicy. + Authored *cmp.Unstructured + + // Cluster is the cluster's copy of Authored, or nil if the cluster has none. For a claim it is the + // claim. + Cluster *un.Unstructured + + // Backing is the cluster's copy of a claim's backing XR. It is nil for an XR, and for a claim whose + // backing XR does not exist yet. + Backing *un.Unstructured + + // Effective is the XR Crossplane would compose, before CRD defaulting. For an XR, that is Authored + // plus the fields Crossplane writes that Authored leaves out, taken from Cluster. For a claim it is + // the backing XR (Backing, or one synthesized from the claim) with the claim's spec synced into it + // the way Crossplane's claim syncer does. It is a fresh object the caller may modify. + Effective *un.Unstructured + + // IsClaim reports whether Authored is a claim. + IsClaim bool +} + +// Composed returns the cluster object whose composed resources Effective observes: Backing for a claim, +// Cluster for an XR. It is nil when that object does not exist yet. +func (c EffectiveComposite) Composed() *un.Unstructured { + if c.IsClaim { + return c.Backing + } + + return c.Cluster +} + +// CompositeResolver computes the effective composite: what Crossplane would actually reconcile for a +// composite as supplied, given what the cluster already holds. It is computed once per composite, +// before the composition is resolved, so that composition resolution and render agree on which +// revision, composition and update policy apply. +type CompositeResolver struct { + resources ResourceManager + defs xp.DefinitionClient + logger logging.Logger + advisories dt.Advisories +} + +// NewCompositeResolver creates a CompositeResolver. +func NewCompositeResolver(resources ResourceManager, defs xp.DefinitionClient, logger logging.Logger, advisories dt.Advisories) *CompositeResolver { + return &CompositeResolver{resources: resources, defs: defs, logger: logger, advisories: advisories} +} + +// Resolve computes the effective composite for authored. existing is authored's cluster copy when the +// caller already has it (a nested XR, found among its parent's observed resources); otherwise it is nil +// and Resolve fetches it. +// +// Fetch errors are fatal: a composite resolved without the cluster state it depends on would be +// rendered against the wrong revision, composition or observed resources, and the diff would be +// silently wrong. +func (r *CompositeResolver) Resolve(ctx context.Context, authored *cmp.Unstructured, existing *un.Unstructured) (EffectiveComposite, error) { + c := EffectiveComposite{Authored: authored, Cluster: existing} + + if c.Cluster == nil { + cluster, _, err := r.resources.FetchCurrentObject(ctx, nil, authored.GetUnstructured()) + if err != nil { + return EffectiveComposite{}, errors.Wrapf(err, "cannot fetch %s %q from the cluster", authored.GetKind(), authored.GetName()) + } + + c.Cluster = cluster + } + + if !r.defs.IsClaimResource(ctx, authored.GetUnstructured()) { + c.Effective = inheritClusterFields(authored.GetUnstructured(), c.Cluster) + return c, nil + } + + c.IsClaim = true + + backing, base, err := r.backingXR(ctx, authored, c.Cluster) + if err != nil { + return EffectiveComposite{}, err + } + + c.Backing = backing + + var backingSpec map[string]any + if backing != nil { + backingSpec, _, _ = un.NestedMap(backing.Object, "spec") + } + + manual, err := isManualPolicy(base) + if err != nil { + return EffectiveComposite{}, errors.Wrapf(err, "cannot read compositionUpdatePolicy of the backing XR of claim %q", authored.GetName()) + } + + spec, err := syncClaimSpec(authored.GetUnstructured(), backingSpec, manual) + if err != nil { + return EffectiveComposite{}, errors.Wrapf(err, "cannot sync claim %q into its backing XR", authored.GetName()) + } + + if err := un.SetNestedField(base.Object, spec, "spec"); err != nil { + return EffectiveComposite{}, errors.Wrapf(err, "cannot set the spec of the backing XR of claim %q", authored.GetName()) + } + + c.Effective = base + + r.logger.Debug("Claim resolves to its backing XR", + "claim", authored.GetName(), + "backingXR", base.GetName(), + "backingXRExists", backing != nil) + + return c, nil +} + +// backingXR returns the cluster's copy of claim's backing XR (nil if it has none), and a fresh copy of +// the XR to sync the claim into: that same XR, or one synthesized from the claim when there is none. +// +// The backing XR is the one cluster's spec.resourceRef names. A claim with no cluster copy, or whose +// cluster copy names no XR yet, is bound to a new one, synthesized under the claim's own name. A +// resourceRef naming an XR that does not exist is treated the same way, but under the name the ref +// gives, because that is the XR the claim syncer would create. It applies the XR under the ref's name +// (claim/syncer_ssa.go:78-80), and it binds the claim before creating the XR (syncer_ssa.go:166-172, +// 215-225), so the ref can name an XR that does not exist yet; it would also re-create one deleted out +// of band. Any other fetch error is fatal, never a reason to diff without the backing XR (#533). +func (r *CompositeResolver) backingXR(ctx context.Context, claim *cmp.Unstructured, cluster *un.Unstructured) (*un.Unstructured, *un.Unstructured, error) { + ref, hasRef := resourceRef(cluster) + if !hasRef { + synthetic, err := r.synthesizeBackingXR(ctx, claim, claim.GetName()) + return nil, synthetic, err + } + + want := &un.Unstructured{} + want.SetAPIVersion(ref.apiVersion) + want.SetKind(ref.kind) + want.SetName(ref.name) + + backing, _, err := r.resources.FetchCurrentObject(ctx, nil, want) + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot fetch backing XR %s %q of claim %q", ref.kind, ref.name, claim.GetName()) + } + + if backing == nil { + // The user should know the composed resources are diffed as though they were all new. + r.advisories.Raise(dt.OutputWarning{ + Message: "The claim's backing XR does not exist in the cluster; diffing as though Crossplane will create it", + Context: map[string]string{ + "claim": fmt.Sprintf("%s/%s", claim.GetKind(), claim.GetName()), + "namespace": claim.GetNamespace(), + "backingXR": fmt.Sprintf("%s/%s", ref.kind, ref.name), + }, + }) + + synthetic, err := r.synthesizeBackingXR(ctx, claim, ref.name) + + return nil, synthetic, err + } + + return backing, backing.DeepCopy(), nil +} + +// backingRef identifies a claim's backing XR. +type backingRef struct { + apiVersion, kind, name string +} + +// resourceRef reads a cluster claim's spec.resourceRef. It reports false when there is no cluster claim, +// or no complete ref on it: Crossplane has not bound the claim to an XR yet. +func resourceRef(cluster *un.Unstructured) (backingRef, bool) { + if cluster == nil { + return backingRef{}, false + } + + ref, found, err := un.NestedStringMap(cluster.Object, "spec", "resourceRef") + if err != nil || !found || ref["apiVersion"] == "" || ref["kind"] == "" || ref["name"] == "" { + return backingRef{}, false + } + + return backingRef{apiVersion: ref["apiVersion"], kind: ref["kind"], name: ref["name"]}, true +} + +// synthesizeBackingXR creates the backing XR a claim would be bound to that has none yet, named name. +// It carries a spec.claimRef, the claim's labels and annotations, and a generated UID, as Crossplane's +// would, so that compositions reading any of them render. +// +// The conversion is upstream's cli helper. The XR kind comes from the XRD, because XRDs need not follow +// the "X"+claimKind convention, and the name is pinned rather than given upstream's random suffix, +// for cleaner diff output. +func (r *CompositeResolver) synthesizeBackingXR(ctx context.Context, claim *cmp.Unstructured, name string) (*un.Unstructured, error) { + xrd, err := r.defs.GetXRDForClaim(ctx, claim.GroupVersionKind()) + if err != nil { + return nil, errors.Wrap(err, "cannot get XRD for claim") + } + + xrKind, _, _ := un.NestedString(xrd.Object, "spec", "names", "kind") + + xr, err := clixrgen.ConvertClaimToXR(claim.GetUnstructured(), clixrgen.Options{ + Name: name, + Kind: xrKind, + Direct: false, // sets spec.claimRef, which compositions may reference + GenerateUID: true, + }) + if err != nil { + return nil, errors.Wrap(err, "cannot convert claim to a backing XR") + } + + r.logger.Debug("Synthesized a backing XR for a claim with none in the cluster", + "claim", claim.GetName(), + "namespace", claim.GetNamespace(), + "xrName", xr.GetName(), + "kind", xr.GetKind()) + + return xr.GetUnstructured(), nil +} + +// isManualPolicy reports whether xr's compositionUpdatePolicy is Manual. +func isManualPolicy(xr *un.Unstructured) (bool, error) { + policy, err := xp.XRUpdatePolicy(xr.Object, xr.GetAPIVersion()) + return policy == compositionUpdatePolicyManual, err +} + +// inheritClusterFields returns a copy of authored carrying cluster's UID and each of +// clusterWrittenFields that authored leaves out, copied to the path cluster holds it at. A field +// authored sets at either path is authored's opinion about it, so it always wins: that is how a +// Manual composite is moved to another revision. +// +// The UID matters because render keeps an input UID (crossplane internal/render/composite.Render), so +// it is what composition functions observe and what render checks observed resources' controller +// references against, rather than a fake one derived from the XR's name. +func inheritClusterFields(authored, cluster *un.Unstructured) *un.Unstructured { + effective := authored.DeepCopy() + if cluster == nil { + return effective + } + + effective.SetUID(cluster.GetUID()) + + for _, field := range clusterWrittenFields { + inheritField(cluster, effective, field) + } + + return effective +} + +// inheritField copies source's value of a Crossplane machinery field onto target, at each of the +// modern (spec.crossplane.) and legacy (spec.) paths source has it at, unless target +// already has it at either path. +func inheritField(source, target *un.Unstructured, field string) { + paths := [][]string{{"spec", "crossplane", field}, {"spec", field}} + + for _, path := range paths { + if _, found, _ := un.NestedFieldNoCopy(target.Object, path...); found { + return + } + } + + for _, path := range paths { + value, found, err := un.NestedFieldCopy(source.Object, path...) + if err != nil || !found { + continue + } + + // Fails only if target holds a non-object on the way to path (spec.crossplane: "x", say), which + // its schema rejects anyway; validation reports that. + _ = un.SetNestedField(target.Object, value, path...) + } +} + +// syncClaimSpec returns the spec Crossplane's claim syncer gives a claim's backing XR. backingSpec is +// the backing XR's spec in the cluster, or nil when it does not exist yet. manual reports whether the +// backing XR's update policy is Manual. +// +// The rules are the syncers' (crossplane v2.4.2, internal/controller/apiextensions/claim/syncer_ssa.go, +// the default, and syncer_csa.go, which agrees on rules 1 to 3): +// +// 1. The claim's spec is propagated, minus the claim's own fields (resourceRef, compositeDeletePolicy, +// writeConnectionSecretToRef) and the XR machinery only the XR controller may set (resourceRefs, +// claimRef, compositionRevisionRef); compositionRef, compositionSelector, compositionUpdatePolicy +// and compositionRevisionSelector are propagated (syncer_ssa.go:136-145, syncer_csa.go:115-123). +// 2. compositionRevisionRef is propagated too, but only when the backing XR's update policy is +// Manual; under Automatic the XR controller owns it (syncer_ssa.go:147-152, syncer_csa.go:125-131). +// The policy is read off the backing XR before the sync, as upstream reads it. A synthesized backing +// XR carries the claim's own policy, which is the one it would be created with. +// 3. spec.claimRef is set to the claim (syncer_ssa.go:162-164, syncer_csa.go:141-143). +// +// The SSA syncer applies the result with the claim controller's own field manager (syncer_ssa.go:228), +// so a field the claim no longer has is removed from the XR, while the fields other managers wrote +// survive. Those are clusterWrittenFields, plus the legacy-only resourceRefs and +// writeConnectionSecretToRef, which the XR controller also writes. So each of those the propagated spec +// lacks is kept from backingSpec, and nothing else is. A synthesized backing XR (backingSpec nil) has +// none yet. +// +// Not modelled: an XRD's enforcedCompositionRef, under which the claim's compositionRef is not +// propagated either. +func syncClaimSpec(claim *un.Unstructured, backingSpec map[string]any, manual bool) (map[string]any, error) { + claimSpec, _, err := un.NestedMap(claim.Object, "spec") + if err != nil { + return nil, errors.Wrap(err, "claim spec is not an object") + } + + stripped := xcrd.CompositeResourceClaimSpecProps(nil) + maps.Copy(stripped, xcrd.CompositeResourceSpecProps(apiextensionsv1.CompositeResourceScopeLegacyCluster, nil)) + + for _, field := range xcrd.PropagateSpecProps { + delete(stripped, field) + } + + if manual { + delete(stripped, xcrd.CompositionRevisionRef) + } + + spec := make(map[string]any, len(claimSpec)) + + for field, value := range claimSpec { + if _, strip := stripped[field]; !strip { + spec[field] = value + } + } + + spec[fieldClaimRef] = map[string]any{ + "apiVersion": claim.GetAPIVersion(), + "kind": claim.GetKind(), + "name": claim.GetName(), + "namespace": claim.GetNamespace(), + } + + for _, field := range append([]string{fieldResourceRefs, fieldWriteConnectionSecretToRef}, clusterWrittenFields...) { + if _, set := spec[field]; set { + continue + } + + if value, kept := backingSpec[field]; kept { + spec[field] = runtime.DeepCopyJSONValue(value) + } + } + + return spec, nil +} diff --git a/internal/diff/diffprocessor/composite_resolver_test.go b/internal/diff/diffprocessor/composite_resolver_test.go new file mode 100644 index 00000000..2eb62019 --- /dev/null +++ b/internal/diff/diffprocessor/composite_resolver_test.go @@ -0,0 +1,529 @@ +package diffprocessor + +import ( + "context" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +// fakeClusterResources is a ResourceManager whose FetchCurrentObject answers from a fixed set of cluster +// objects, keyed by kind and name. A name in notFound is reported as not existing; a name in errs fails; +// any other name fails too, so a test notices a fetch it did not expect. +type fakeClusterResources struct { + objects map[string]*un.Unstructured + notFound map[string]bool + errs map[string]error +} + +func fetchKey(kind, name string) string { return kind + "/" + name } + +func (f *fakeClusterResources) FetchCurrentObject(_ context.Context, _ *un.Unstructured, desired *un.Unstructured) (*un.Unstructured, bool, error) { + key := fetchKey(desired.GetKind(), desired.GetName()) + + switch { + case f.objects[key] != nil: + return f.objects[key].DeepCopy(), false, nil + case f.notFound[key]: + return nil, true, nil + case f.errs[key] != nil: + return nil, false, f.errs[key] + default: + return nil, false, errors.Errorf("unexpected fetch of %s", key) + } +} + +func (f *fakeClusterResources) UpdateOwnerRefs(context.Context, *un.Unstructured, *un.Unstructured) {} + +func (f *fakeClusterResources) FetchObservedResources(context.Context, *cmp.Unstructured) ([]cpd.Unstructured, error) { + return nil, nil +} + +func TestCompositeResolver_Resolve(t *testing.T) { + const ( + claimAPIVersion = "example.org/v1" + generatedUID = "generated-uid" + ) + + xrd := &un.Unstructured{Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1", + "kind": "CompositeResourceDefinition", + "metadata": map[string]any{"name": "xrs.example.org"}, + "spec": map[string]any{ + "group": "example.org", + "names": map[string]any{"kind": "XR", "plural": "xrs"}, + "claimNames": map[string]any{"kind": "Claim", "plural": "claims"}, + }, + }} + + claimRef := map[string]any{"apiVersion": claimAPIVersion, "kind": "Claim", "name": "my-claim", "namespace": "ns"} + resourceRef := map[string]any{"apiVersion": claimAPIVersion, "kind": "XR", "name": "my-claim-abc12"} + + authoredXR := tu.NewResource("example.org/v1", "XR", "my-xr").WithSpecField("field", "authored").BuildUComposite() + + clusterXR := tu.NewResource("example.org/v1", "XR", "my-xr").WithUID("cluster-uid"). + WithSpecField("field", "in-cluster"). + WithNestedField(map[string]any{"name": "comp"}, "spec", "crossplane", "compositionRef"). + WithNestedField(map[string]any{"name": "comp-abc1234"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build() + + // The claim as supplied: it has no ref of its own, as a manifest kept in git would not. + authoredClaim := tu.NewResource(claimAPIVersion, "Claim", "my-claim").InNamespace("ns"). + WithSpecField("field", "authored").BuildUComposite() + + clusterClaim := tu.NewResource(claimAPIVersion, "Claim", "my-claim").InNamespace("ns").WithUID("claim-uid"). + WithSpecField("field", "in-cluster"). + WithSpecField("resourceRef", resourceRef). + Build() + + backingXR := tu.NewResource(claimAPIVersion, "XR", "my-claim-abc12").WithUID("backing-uid"). + WithLabels(map[string]string{LabelClaimName: "my-claim", LabelClaimNamespace: "ns"}). + WithSpecField("field", "in-cluster"). + WithSpecField("claimRef", claimRef). + WithSpecField("compositionRef", map[string]any{"name": "comp"}). + WithSpecField("compositionRevisionRef", map[string]any{"name": "comp-abc1234"}). + WithSpecField("compositionUpdatePolicy", "Automatic"). + WithSpecField("resourceRefs", []any{map[string]any{"apiVersion": "nop/v1", "kind": "Nop", "name": "nop"}}). + Build() + + // synthesized is the backing XR ConvertClaimToXR gives authoredClaim under name. + synthesized := func(name string) *un.Unstructured { + return tu.NewResource(claimAPIVersion, "XR", name).WithUID(generatedUID). + WithLabels(map[string]string{LabelClaimName: "my-claim", LabelClaimNamespace: "ns"}). + WithSpecField("field", "authored"). + WithSpecField("claimRef", claimRef). + Build() + } + + tests := map[string]struct { + reason string + authored *cmp.Unstructured + existing *un.Unstructured + resources *fakeClusterResources + want EffectiveComposite + wantErr bool + }{ + "NewXRIsComposedAsAuthored": { + reason: "An XR with no cluster copy is composed as written.", + authored: authoredXR, + resources: &fakeClusterResources{notFound: map[string]bool{"XR/my-xr": true}}, + want: EffectiveComposite{ + Authored: authoredXR, + Effective: authoredXR.GetUnstructured(), + }, + }, + "ExistingXRInheritsWhatCrossplaneWrote": { + reason: "An existing XR's input says nothing about its composition, revision or policy, so it is reconciled with the cluster's, and with the cluster's UID.", + authored: authoredXR, + resources: &fakeClusterResources{objects: map[string]*un.Unstructured{"XR/my-xr": clusterXR}}, + want: EffectiveComposite{ + Authored: authoredXR, + Cluster: clusterXR, + Effective: tu.NewResource("example.org/v1", "XR", "my-xr").WithUID("cluster-uid"). + WithSpecField("field", "authored"). + WithNestedField(map[string]any{"name": "comp"}, "spec", "crossplane", "compositionRef"). + WithNestedField(map[string]any{"name": "comp-abc1234"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + }, + "SuppliedClusterCopyIsNotFetchedAgain": { + reason: "A nested XR's cluster copy is already known from its parent's observed resources.", + authored: authoredXR, + existing: clusterXR, + resources: &fakeClusterResources{}, + want: EffectiveComposite{ + Authored: authoredXR, + Cluster: clusterXR, + Effective: tu.NewResource("example.org/v1", "XR", "my-xr").WithUID("cluster-uid"). + WithSpecField("field", "authored"). + WithNestedField(map[string]any{"name": "comp"}, "spec", "crossplane", "compositionRef"). + WithNestedField(map[string]any{"name": "comp-abc1234"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + }, + "XRFetchErrorIsFatal": { + reason: "Resolving without the cluster copy could render the wrong revision.", + authored: authoredXR, + resources: &fakeClusterResources{errs: map[string]error{"XR/my-xr": errors.New("boom")}}, + wantErr: true, + }, + "ClaimIsComposedAsItsBackingXR": { + reason: "Crossplane composes a claim's backing XR, with the claim's spec synced in. The claim has " + + "no ref and the backing XR is Automatic, so the backing XR keeps its own (#498).", + authored: authoredClaim, + resources: &fakeClusterResources{objects: map[string]*un.Unstructured{ + "Claim/my-claim": clusterClaim, + "XR/my-claim-abc12": backingXR, + }}, + want: EffectiveComposite{ + Authored: authoredClaim, + Cluster: clusterClaim, + Backing: backingXR, + Effective: tu.NewResource(claimAPIVersion, "XR", "my-claim-abc12").WithUID("backing-uid"). + WithLabels(map[string]string{LabelClaimName: "my-claim", LabelClaimNamespace: "ns"}). + WithSpecField("field", "authored"). + WithSpecField("claimRef", claimRef). + WithSpecField("compositionRef", map[string]any{"name": "comp"}). + WithSpecField("compositionRevisionRef", map[string]any{"name": "comp-abc1234"}). + WithSpecField("compositionUpdatePolicy", "Automatic"). + WithSpecField("resourceRefs", []any{map[string]any{"apiVersion": "nop/v1", "kind": "Nop", "name": "nop"}}). + Build(), + IsClaim: true, + }, + }, + "BackingXRFetchErrorIsFatal": { + reason: "Rendering a claim without its backing XR would diff its composed resources against nothing (#533).", + authored: authoredClaim, + resources: &fakeClusterResources{ + objects: map[string]*un.Unstructured{"Claim/my-claim": clusterClaim}, + errs: map[string]error{"XR/my-claim-abc12": apierrors.NewForbidden(schema.GroupResource{Resource: "xrs"}, "my-claim-abc12", errors.New("denied"))}, + }, + wantErr: true, + }, + "MissingBackingXRIsSynthesizedUnderItsRefName": { + reason: "A resourceRef naming no XR is the XR the claim syncer would create, under that name.", + authored: authoredClaim, + resources: &fakeClusterResources{ + objects: map[string]*un.Unstructured{"Claim/my-claim": clusterClaim}, + notFound: map[string]bool{"XR/my-claim-abc12": true}, + }, + want: EffectiveComposite{ + Authored: authoredClaim, + Cluster: clusterClaim, + Effective: synthesized("my-claim-abc12"), + IsClaim: true, + }, + }, + "NewClaimIsSynthesizedUnderItsOwnName": { + reason: "A claim with no cluster copy is bound to a new XR, carrying a claimRef.", + authored: authoredClaim, + resources: &fakeClusterResources{notFound: map[string]bool{"Claim/my-claim": true}}, + want: EffectiveComposite{ + Authored: authoredClaim, + Effective: synthesized("my-claim"), + IsClaim: true, + }, + }, + "UnboundClaimIsSynthesizedUnderItsOwnName": { + reason: "A cluster claim Crossplane has not bound to an XR yet is treated as new.", + authored: authoredClaim, + resources: &fakeClusterResources{objects: map[string]*un.Unstructured{ + "Claim/my-claim": tu.NewResource(claimAPIVersion, "Claim", "my-claim").InNamespace("ns").Build(), + }}, + want: EffectiveComposite{ + Authored: authoredClaim, + Cluster: tu.NewResource(claimAPIVersion, "Claim", "my-claim").InNamespace("ns").Build(), + Effective: synthesized("my-claim"), + IsClaim: true, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + defs := tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, res *un.Unstructured) bool { return res.GetKind() == "Claim" }). + WithXRDForClaim(xrd). + Build() + + authoredBefore := tt.authored.DeepCopy() + + got, err := NewCompositeResolver(tt.resources, defs, tu.TestLogger(t, false), NewAdvisoryCollector(nil)).Resolve(t.Context(), tt.authored, tt.existing) + + if tt.wantErr { + if err == nil { + t.Errorf("%s\nResolve(): want error, got none", tt.reason) + } + + return + } + + if err != nil { + t.Fatalf("%s\nResolve(): %v", tt.reason, err) + } + + // A synthesized backing XR gets a random UID; pin it so the rest can be compared exactly. + if got.Backing == nil && got.IsClaim && got.Effective.GetUID() != "" { + got.Effective.SetUID(generatedUID) + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\nResolve() (-want +got):\n%s", tt.reason, diff) + } + + if diff := gcmp.Diff(authoredBefore, tt.authored); diff != "" { + t.Errorf("%s\nResolve() modified the authored composite (-before +after):\n%s", tt.reason, diff) + } + }) + } +} + +// TestInheritClusterFields covers how an XR inherits the fields Crossplane writes from its cluster copy. +// The load-bearing behaviour is the refusal: a field the input sets at either path is the input's +// opinion about it, so it always wins. That is how a Manual composite is moved to another revision. +func TestInheritClusterFields(t *testing.T) { + tests := map[string]struct { + reason string + authored *un.Unstructured + cluster *un.Unstructured + want *un.Unstructured + }{ + "NoClusterCopyIsACopyOfTheInput": { + reason: "A new XR has nothing to inherit.", + authored: tu.NewResource("v1", "XR", "xr").WithSpecField("coolField", "v").Build(), + want: tu.NewResource("v1", "XR", "xr").WithSpecField("coolField", "v").Build(), + }, + "V2FieldsAreCopiedToTheV2Path": { + reason: "A modern XR keeps its machinery under spec.crossplane, alongside any the input sets.", + authored: tu.NewResource("v1", "XR", "xr"). + WithNestedField(map[string]any{"matchLabels": map[string]any{"a": "b"}}, "spec", "crossplane", "compositionSelector"). + Build(), + cluster: tu.NewResource("v1", "XR", "xr").WithUID("uid"). + WithNestedField(map[string]any{"name": "comp"}, "spec", "crossplane", "compositionRef"). + WithNestedField(map[string]any{"name": "comp-rev"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: tu.NewResource("v1", "XR", "xr").WithUID("uid"). + WithNestedField(map[string]any{"matchLabels": map[string]any{"a": "b"}}, "spec", "crossplane", "compositionSelector"). + WithNestedField(map[string]any{"name": "comp"}, "spec", "crossplane", "compositionRef"). + WithNestedField(map[string]any{"name": "comp-rev"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + "V1FieldsAreCopiedToTheV1Path": { + reason: "A legacy XR keeps its machinery directly under spec.", + authored: tu.NewResource("v1", "XR", "xr").WithSpecField("coolField", "v").Build(), + cluster: tu.NewResource("v1", "XR", "xr").WithUID("uid"). + WithSpecField("compositionRef", map[string]any{"name": "comp"}). + WithSpecField("compositionRevisionRef", map[string]any{"name": "comp-rev"}). + WithSpecField("compositionUpdatePolicy", "Automatic"). + Build(), + want: tu.NewResource("v1", "XR", "xr").WithUID("uid"). + WithSpecField("coolField", "v"). + WithSpecField("compositionRef", map[string]any{"name": "comp"}). + WithSpecField("compositionRevisionRef", map[string]any{"name": "comp-rev"}). + WithSpecField("compositionUpdatePolicy", "Automatic"). + Build(), + }, + "BothPathsAreCopiedWhenTheClusterHasBoth": { + reason: "Pathological, but render must see what the cluster holds at whichever path is read.", + authored: tu.NewResource("v1", "XR", "xr").Build(), + cluster: tu.NewResource("v1", "XR", "xr"). + WithSpecField("compositionRevisionRef", map[string]any{"name": "v1-rev"}). + WithNestedField(map[string]any{"name": "v2-rev"}, "spec", "crossplane", "compositionRevisionRef"). + Build(), + want: tu.NewResource("v1", "XR", "xr"). + WithSpecField("compositionRevisionRef", map[string]any{"name": "v1-rev"}). + WithNestedField(map[string]any{"name": "v2-rev"}, "spec", "crossplane", "compositionRevisionRef"). + Build(), + }, + "InputAtTheSamePathWins": { + reason: "The input's own pin and policy: pointing a Manual composite at another revision must keep working.", + authored: tu.NewResource("v1", "XR", "xr"). + WithNestedField(map[string]any{"name": "user-rev"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + cluster: tu.NewResource("v1", "XR", "xr"). + WithNestedField(map[string]any{"name": "cluster-rev"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: tu.NewResource("v1", "XR", "xr"). + WithNestedField(map[string]any{"name": "user-rev"}, "spec", "crossplane", "compositionRevisionRef"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + "InputAtTheOtherPathWins": { + reason: "A field set at either path is an opinion about it, so the cluster's is not layered on top.", + authored: tu.NewResource("v1", "XR", "xr"). + WithSpecField("compositionRef", map[string]any{"name": "user-comp"}). + Build(), + cluster: tu.NewResource("v1", "XR", "xr"). + WithNestedField(map[string]any{"name": "cluster-comp"}, "spec", "crossplane", "compositionRef"). + Build(), + want: tu.NewResource("v1", "XR", "xr"). + WithSpecField("compositionRef", map[string]any{"name": "user-comp"}). + Build(), + }, + "OnlyCrossplaneWrittenFieldsAreInherited": { + reason: "A field the input drops is gone, unless Crossplane wrote it: an apply removes what it no longer sets.", + authored: tu.NewResource("v1", "XR", "xr").WithSpecField("coolField", "v").Build(), + cluster: tu.NewResource("v1", "XR", "xr").WithUID("uid"). + WithSpecField("coolField", "old"). + WithSpecField("removedField", "old"). + WithNestedField(map[string]any{"matchLabels": map[string]any{"a": "b"}}, "spec", "crossplane", "compositionSelector"). + Build(), + want: tu.NewResource("v1", "XR", "xr").WithUID("uid").WithSpecField("coolField", "v").Build(), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + authoredBefore := tt.authored.DeepCopy() + + var clusterBefore *un.Unstructured + if tt.cluster != nil { + clusterBefore = tt.cluster.DeepCopy() + } + + got := inheritClusterFields(tt.authored, tt.cluster) + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\ninheritClusterFields() (-want +got):\n%s", tt.reason, diff) + } + + // The copy must be deep: later writes to the effective XR (the ref to the revision it renders, + // say) must reach neither the input nor the cluster's object. + for _, path := range [][]string{{"spec", "crossplane", "compositionRevisionRef"}, {"spec", "compositionRevisionRef"}} { + _ = un.SetNestedField(got.Object, "mutated", append(path, "name")...) + } + + if diff := gcmp.Diff(authoredBefore, tt.authored); diff != "" { + t.Errorf("%s\ninheritClusterFields() aliased the input (-before +after):\n%s", tt.reason, diff) + } + + if diff := gcmp.Diff(clusterBefore, tt.cluster); diff != "" { + t.Errorf("%s\ninheritClusterFields() aliased the cluster copy (-before +after):\n%s", tt.reason, diff) + } + }) + } +} + +// TestSyncClaimSpec covers the spec Crossplane's claim syncer gives a claim's backing XR. The claim's +// spec is the source of truth for what the claim authors; the XR controller's own fields survive the +// sync; and compositionRevisionRef travels from the claim only under a Manual policy. +func TestSyncClaimSpec(t *testing.T) { + claimRef := map[string]any{"apiVersion": "example.org/v1", "kind": "Claim", "name": "my-claim", "namespace": "ns"} + + claim := func(spec map[string]any) *un.Unstructured { + return tu.NewResource("example.org/v1", "Claim", "my-claim").InNamespace("ns").WithSpec(spec).Build() + } + + tests := map[string]struct { + reason string + claim *un.Unstructured + backingSpec map[string]any + manual bool + want map[string]any + }{ + "FieldsRemovedFromTheClaimAreRemoved": { + reason: "The claim controller owns what it propagated, so a field the claim drops leaves the XR.", + claim: claim(map[string]any{"newField": "new"}), + backingSpec: map[string]any{"newField": "new", "deprecatedField": "old", "claimRef": claimRef}, + want: map[string]any{"newField": "new", "claimRef": claimRef}, + }, + "XRControllerFieldsSurvive": { + reason: "compositionRef, compositionRevisionRef, compositionUpdatePolicy, resourceRefs and " + + "writeConnectionSecretToRef are written by the XR controller (or defaulted), so the claim's apply leaves them.", + claim: claim(map[string]any{"field": "v"}), + backingSpec: map[string]any{ + "field": "old", + "compositionRef": map[string]any{"name": "comp"}, + "compositionRevisionRef": map[string]any{"name": "comp-rev"}, + "compositionUpdatePolicy": "Automatic", + "resourceRefs": []any{map[string]any{"kind": "Nop", "name": "nop"}}, + "writeConnectionSecretToRef": map[string]any{"name": "uid", "namespace": "crossplane-system"}, + }, + want: map[string]any{ + "field": "v", + "claimRef": claimRef, + "compositionRef": map[string]any{"name": "comp"}, + "compositionRevisionRef": map[string]any{"name": "comp-rev"}, + "compositionUpdatePolicy": "Automatic", + "resourceRefs": []any{map[string]any{"kind": "Nop", "name": "nop"}}, + "writeConnectionSecretToRef": map[string]any{"name": "uid", "namespace": "crossplane-system"}, + }, + }, + "PropagatedFieldsComeFromTheClaim": { + reason: "compositionRef, compositionSelector, compositionUpdatePolicy and compositionRevisionSelector propagate claim to XR.", + claim: claim(map[string]any{ + "compositionRef": map[string]any{"name": "new-comp"}, + "compositionSelector": map[string]any{"matchLabels": map[string]any{"a": "b"}}, + "compositionUpdatePolicy": "Manual", + "compositionRevisionSelector": map[string]any{"matchLabels": map[string]any{"c": "d"}}, + }), + backingSpec: map[string]any{"compositionRef": map[string]any{"name": "old-comp"}, "compositionUpdatePolicy": "Automatic"}, + want: map[string]any{ + "claimRef": claimRef, + "compositionRef": map[string]any{"name": "new-comp"}, + "compositionSelector": map[string]any{"matchLabels": map[string]any{"a": "b"}}, + "compositionUpdatePolicy": "Manual", + "compositionRevisionSelector": map[string]any{"matchLabels": map[string]any{"c": "d"}}, + }, + }, + "CompositionSelectorTheClaimDropsIsRemoved": { + reason: "The selector is the claim's to propagate, not the XR controller's to write.", + claim: claim(map[string]any{"field": "v"}), + backingSpec: map[string]any{"compositionSelector": map[string]any{"matchLabels": map[string]any{"a": "b"}}}, + want: map[string]any{"field": "v", "claimRef": claimRef}, + }, + "ClaimOnlyAndXRMachineryFieldsAreStripped": { + reason: "A claim's own fields mean nothing on its XR, and the XR machinery a claim could smuggle in is the XR controller's.", + claim: claim(map[string]any{ + "field": "v", + "resourceRef": map[string]any{"name": "x"}, + "compositeDeletePolicy": "Background", + "writeConnectionSecretToRef": map[string]any{"name": "claim-secret"}, + "resourceRefs": []any{map[string]any{"name": "smuggled"}}, + "claimRef": map[string]any{"name": "smuggled"}, + }), + backingSpec: map[string]any{"writeConnectionSecretToRef": map[string]any{"name": "uid", "namespace": "crossplane-system"}}, + want: map[string]any{ + "field": "v", + "claimRef": claimRef, + "writeConnectionSecretToRef": map[string]any{"name": "uid", "namespace": "crossplane-system"}, + }, + }, + "AutomaticClaimRevisionRefIsStripped": { + reason: "Under Automatic the XR controller owns the ref, so the backing XR keeps its own, whatever the claim carries (#498).", + claim: claim(map[string]any{"compositionRevisionRef": map[string]any{"name": "claim-rev"}}), + backingSpec: map[string]any{ + "compositionRevisionRef": map[string]any{"name": "xr-rev"}, + }, + want: map[string]any{"claimRef": claimRef, "compositionRevisionRef": map[string]any{"name": "xr-rev"}}, + }, + "ManualClaimRevisionRefIsPropagated": { + reason: "Under Manual the claim is authoritative for the ref.", + claim: claim(map[string]any{"compositionRevisionRef": map[string]any{"name": "claim-rev"}}), + backingSpec: map[string]any{"compositionRevisionRef": map[string]any{"name": "xr-rev"}}, + manual: true, + want: map[string]any{"claimRef": claimRef, "compositionRevisionRef": map[string]any{"name": "claim-rev"}}, + }, + "ManualClaimWithoutARefKeepsTheBackingXRsPin": { + reason: "The claim syncer copies the ref back to the claim only under Automatic, so a Manual claim often carries none.", + claim: claim(map[string]any{"field": "v"}), + backingSpec: map[string]any{"compositionRevisionRef": map[string]any{"name": "xr-rev"}}, + manual: true, + want: map[string]any{"field": "v", "claimRef": claimRef, "compositionRevisionRef": map[string]any{"name": "xr-rev"}}, + }, + "NewBackingXRHasOnlyWhatTheClaimPropagates": { + reason: "An XR that does not exist yet has no fields of its own to keep.", + claim: claim(map[string]any{"field": "v", "compositionRevisionRef": map[string]any{"name": "claim-rev"}}), + want: map[string]any{"field": "v", "claimRef": claimRef}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := syncClaimSpec(tt.claim, tt.backingSpec, tt.manual) + if err != nil { + t.Fatalf("%s\nsyncClaimSpec(): %v", tt.reason, err) + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\nsyncClaimSpec() (-want +got):\n%s", tt.reason, diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/defaulter.go b/internal/diff/diffprocessor/defaulter.go new file mode 100644 index 00000000..a0945435 --- /dev/null +++ b/internal/diff/diffprocessor/defaulter.go @@ -0,0 +1,130 @@ +package diffprocessor + +import ( + "context" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + clixr "github.com/crossplane/cli/v2/pkg/xr" +) + +// Defaulter predicts the defaults the apiserver would apply to a resource from its CRD's schema. +// +// Nothing it returns may be sent to the apiserver. Under server-side apply every field in a request +// claims ownership of that field, so a locally defaulted field would take a value away from whichever +// manager set it in the cluster and report a change that applying the manifest would not make (#503). +// It has exactly two uses: the XR that composition and render consume, which must carry the spec +// Crossplane would see (StrictDefaulting); and the predicted result of an addition that has no +// apiserver result (LenientDefaulting). +// +// The prediction covers CRD `default:` values only. Mutating admission and admission plugins are not +// modelled at all. Nor is a multi-version CRD's storage version: the prediction defaults against the +// requested version only, while the cluster may also apply the storage version's defaults and convert +// the resource through it. That one cannot be predicted without the conversion, but it can be named, +// so Default reports the storage version whenever it differs from the requested one, and the +// unverified addition's warning says the diff may omit its defaults and conversion (#528). +// Nor does it prune fields the schema does not declare, as the apiserver would, but that never shows: +// schema validation rejects an undeclared field as an unknownField error before any prediction runs. +type Defaulter interface { + // Default returns a copy of obj with its CRD's defaults applied, and the version that CRD stores + // obj at when that is not obj's own version. The version is empty when there is no other: for a + // built-in type, a resource whose CRD is not found, a single-version CRD, or a resource at its + // CRD's storage version. obj itself is never modified. + Default(ctx context.Context, obj *un.Unstructured) (defaulted *un.Unstructured, storageVersion string, err error) +} + +// DefaultingPolicy says what a Defaulter does with a resource it finds no CRD for. +type DefaultingPolicy int + +const ( + // StrictDefaulting fails for a resource it finds no CRD for. It is the policy for the XR that render + // consumes: rendering without the defaults Crossplane would see could produce a wrong diff, so a + // missing XRD or CRD must not be passed over. + StrictDefaulting DefaultingPolicy = iota + + // LenientDefaulting returns a resource it finds no CRD for as an unchanged copy: a built-in type, + // which has no CRD, or a resource whose CRD cannot be found. It is the policy for predicting an + // addition the apiserver did not see, which is best effort by nature; the schema validator, not + // the Defaulter, is the gate on missing CRDs. + LenientDefaulting +) + +// CRDDefaulter implements Defaulter from the CRDs a SchemaClient knows. +type CRDDefaulter struct { + schemaClient k8.SchemaClient + defClient xp.DefinitionClient + policy DefaultingPolicy +} + +// NewDefaulter creates a CRDDefaulter with the given policy. +func NewDefaulter(sc k8.SchemaClient, dc xp.DefinitionClient, policy DefaultingPolicy) Defaulter { + return &CRDDefaulter{schemaClient: sc, defClient: dc, policy: policy} +} + +// Default returns a copy of obj with its CRD's defaults applied, and the CRD's storage version when it +// is not obj's. What happens when no CRD can be found depends on the policy. A CRD that is found but +// does not define obj's apiVersion is an error under either policy: there is nothing to predict from, +// and returning obj as though defaulted would hide it. +func (d *CRDDefaulter) Default(ctx context.Context, obj *un.Unstructured) (*un.Unstructured, string, error) { + out := obj.DeepCopy() + gvk := out.GroupVersionKind() + + crd, err := d.crdFor(ctx, gvk) + if err != nil { + if d.policy == LenientDefaulting { + return out, "", nil + } + + return nil, "", errors.Wrapf(err, "cannot default %s %q", gvk.String(), out.GetName()) + } + + if err := clixr.ApplyCRDDefaults(out.Object, out.GetAPIVersion(), *crd); err != nil { + return nil, "", errors.Wrapf(err, "cannot apply CRD defaults to %s %q", gvk.String(), out.GetName()) + } + + return out, otherStorageVersion(crd, gvk.Version), nil +} + +// otherStorageVersion returns the version crd stores its resources at, or "" when that is version +// itself. ApplyCRDDefaults has already failed for a version crd does not define, so a different +// storage version means crd serves more than one. Which conversion strategy crd uses does not matter: +// even with None the storage version's defaults may be applied, so both are reported. +func otherStorageVersion(crd *extv1.CustomResourceDefinition, version string) string { + for _, v := range crd.Spec.Versions { + if v.Storage && v.Name != version { + return v.Name + } + } + + return "" +} + +// crdFor finds the CRD for gvk. An XR's comes from its XRD, by name, from the CRDs loaded when the +// processor initialized; any other resource's is looked up by GVK. A built-in type has none. +func (d *CRDDefaulter) crdFor(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if xrd, err := d.defClient.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { + crd, err := d.schemaClient.GetCRDByName(xrd.GetName()) + if err != nil { + return nil, errors.Wrapf(err, "cannot find CRD for XRD %s", xrd.GetName()) + } + + return crd, nil + } + + if !d.schemaClient.IsCRDRequired(ctx, gvk) { + return nil, errors.Errorf("%s is a built-in type, which has no CRD", gvk.String()) + } + + crd, err := d.schemaClient.GetCRD(ctx, gvk) + if err != nil { + return nil, errors.Wrapf(err, "cannot find CRD for %s", gvk.String()) + } + + return crd, nil +} diff --git a/internal/diff/diffprocessor/defaulter_test.go b/internal/diff/diffprocessor/defaulter_test.go new file mode 100644 index 00000000..acc58867 --- /dev/null +++ b/internal/diff/diffprocessor/defaulter_test.go @@ -0,0 +1,250 @@ +package diffprocessor + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +var _ Defaulter = (*tu.MockDefaulter)(nil) + +func TestCRDDefaulter_Default(t *testing.T) { + // An XR, whose CRD is resolved through its XRD, by name. + xrCRD := tu.NewCRD("xsizeds.example.org", "example.org", "XSized"). + WithPlural("xsizeds"). + WithVersion("v1", true, true). + WithDefaultedStringFieldSchema("size", "small"). + Build() + xrd := tu.NewXRD("xsizeds.example.org", "example.org", "XSized"). + WithPlural("xsizeds"). + WithVersion("v1", true, true). + BuildAsUnstructured() + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XSized"} + + // A composed resource, which is not an XR, so its CRD is resolved by GVK. + mrCRD := tu.NewCRD("sizeds.example.org", "example.org", "Sized"). + WithPlural("sizeds"). + WithVersion("v1", true, true). + WithDefaultedStringFieldSchema("size", "small"). + Build() + mrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Sized"} + + // A composed resource whose CRD serves two versions that default spec.size differently, and stores + // the newer one. + versionedCRD := tu.NewCRD("versioneds.example.org", "example.org", "Versioned"). + WithPlural("versioneds"). + WithVersion("v1alpha1", true, false). + WithDefaultedStringFieldSchema("size", "small"). + WithVersion("v1", true, true). + WithDefaultedStringFieldSchema("size", "medium"). + Build() + versionedCRDByGVK := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD("example.org", "Versioned", versionedCRD). + WithAllResourcesRequiringCRDs(). + Build() + } + + xrdsFor := func(gvk schema.GroupVersionKind) *tu.MockDefinitionClient { + return tu.NewMockDefinitionClient().WithXRDForGVK(gvk, xrd).Build() + } + noXRDs := func() *tu.MockDefinitionClient { + return tu.NewMockDefinitionClient().WithXRDForXRNotFound().Build() + } + + // Each schema client only answers the lookup the case expects to be used, so a case also pins + // which lookup resolved the CRD. + xrCRDByName := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithSuccessfulCRDByNameFetch("xsizeds.example.org", xrCRD). + WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.New("an XR's CRD must be resolved through its XRD, not by GVK") + }). + Build() + } + mrCRDByGVK := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD("example.org", "Sized", mrCRD). + WithResourcesRequiringCRDs(mrGVK). + Build() + } + unknownCRD := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient().WithAllResourcesRequiringCRDs().WithCRDNotFound().Build() + } + builtIn := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(context.Context, schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.New("GetCRD must not be called for a built-in type") + }). + Build() + } + + // Structural defaulting fills in fields of objects that exist, so a resource needs a spec for + // spec.size to be defaulted into. + unsizedXR := tu.NewResource("example.org/v1", "XSized", "xr").WithNestedField(map[string]any{}, "spec").Build() + unsizedMR := tu.NewResource("example.org/v1", "Sized", "mr").WithNestedField(map[string]any{}, "spec").Build() + configMap := tu.NewResource("v1", "ConfigMap", "cm").WithNestedField(map[string]any{"k": "v"}, "data").Build() + + type want struct { + out *un.Unstructured + storageVersion string + err bool + } + + tests := map[string]struct { + reason string + policy DefaultingPolicy + schemaClient *tu.MockSchemaClient + defClient *tu.MockDefinitionClient + in *un.Unstructured + want want + }{ + "StrictAppliesAnXRsDefaultsFromItsXRDsCRD": { + reason: "An XR is defaulted from the CRD its XRD names, which is what render needs to see.", + policy: StrictDefaulting, + schemaClient: xrCRDByName(), + defClient: xrdsFor(xrGVK), + in: unsizedXR, + want: want{out: tu.NewResource("example.org/v1", "XSized", "xr").WithSpecField("size", "small").Build()}, + }, + "LenientAppliesAComposedResourcesDefaultsFromItsCRD": { + reason: "A resource that is not an XR is defaulted from the CRD found for its GVK.", + policy: LenientDefaulting, + schemaClient: mrCRDByGVK(), + defClient: noXRDs(), + in: unsizedMR, + want: want{out: tu.NewResource("example.org/v1", "Sized", "mr").WithSpecField("size", "small").Build()}, + }, + "ReportsAStorageVersionOtherThanTheRequestedOne": { + reason: "A resource requested at a version its CRD does not store is still defaulted against the requested version only, and the version the CRD stores is reported so the caller can say what the prediction does not model.", + policy: LenientDefaulting, + schemaClient: versionedCRDByGVK(), + defClient: noXRDs(), + in: tu.NewResource("example.org/v1alpha1", "Versioned", "mr").WithNestedField(map[string]any{}, "spec").Build(), + want: want{ + out: tu.NewResource("example.org/v1alpha1", "Versioned", "mr").WithSpecField("size", "small").Build(), + storageVersion: "v1", + }, + }, + "ReportsNoStorageVersionForARequestAtTheStorageVersion": { + reason: "A resource requested at its CRD's storage version is stored as it is requested, so there is no other version to report, however many versions the CRD serves.", + policy: LenientDefaulting, + schemaClient: versionedCRDByGVK(), + defClient: noXRDs(), + in: tu.NewResource("example.org/v1", "Versioned", "mr").WithNestedField(map[string]any{}, "spec").Build(), + want: want{out: tu.NewResource("example.org/v1", "Versioned", "mr").WithSpecField("size", "medium").Build()}, + }, + "KeepsAValueTheResourceSets": { + reason: "A default never overrides a value the resource already carries.", + policy: StrictDefaulting, + schemaClient: xrCRDByName(), + defClient: xrdsFor(xrGVK), + in: tu.NewResource("example.org/v1", "XSized", "xr").WithSpecField("size", "large").Build(), + want: want{out: tu.NewResource("example.org/v1", "XSized", "xr").WithSpecField("size", "large").Build()}, + }, + "StrictFailsWhenTheXRDsCRDIsMissing": { + reason: "Rendering an XR without the defaults Crossplane would see could produce a wrong diff, so a missing CRD is an error.", + policy: StrictDefaulting, + schemaClient: tu.NewMockSchemaClient(). + WithGetCRDByName(func(name string) (*extv1.CustomResourceDefinition, error) { + return nil, errors.Errorf("CRD with name %s not found in cache", name) + }). + Build(), + defClient: xrdsFor(xrGVK), + in: unsizedXR, + want: want{err: true}, + }, + "LenientPassesThroughWhenTheXRDsCRDIsMissing": { + reason: "A best-effort prediction leaves a resource it has no CRD for as it is.", + policy: LenientDefaulting, + schemaClient: tu.NewMockSchemaClient(). + WithGetCRDByName(func(name string) (*extv1.CustomResourceDefinition, error) { + return nil, errors.Errorf("CRD with name %s not found in cache", name) + }). + Build(), + defClient: xrdsFor(xrGVK), + in: unsizedXR, + want: want{out: unsizedXR}, + }, + "StrictFailsForAnUnknownCRD": { + reason: "With no XRD and no CRD for the GVK there is nothing to default from, which strict defaulting must not hide.", + policy: StrictDefaulting, + schemaClient: unknownCRD(), + defClient: noXRDs(), + in: unsizedMR, + want: want{err: true}, + }, + "LenientPassesThroughAnUnknownCRD": { + reason: "The schema validator, not the Defaulter, is the gate on missing CRDs.", + policy: LenientDefaulting, + schemaClient: unknownCRD(), + defClient: noXRDs(), + in: unsizedMR, + want: want{out: unsizedMR}, + }, + "StrictFailsForABuiltInType": { + reason: "A built-in type has no CRD, so it can never be a valid render input to default.", + policy: StrictDefaulting, + schemaClient: builtIn(), + defClient: noXRDs(), + in: configMap, + want: want{err: true}, + }, + "LenientPassesThroughABuiltInType": { + reason: "A built-in type has no CRD to default from, so it comes back unchanged without a CRD lookup.", + policy: LenientDefaulting, + schemaClient: builtIn(), + defClient: noXRDs(), + in: configMap, + want: want{out: configMap}, + }, + "LenientFailsForAnAPIVersionTheCRDDoesNotDefine": { + reason: "A CRD that does not define the resource's version cannot predict anything; returning the input as though defaulted would hide that.", + policy: LenientDefaulting, + schemaClient: tu.NewMockSchemaClient(). + WithFoundCRD("example.org", "Sized", mrCRD). + WithAllResourcesRequiringCRDs(). + Build(), + defClient: noXRDs(), + in: tu.NewResource("example.org/v2", "Sized", "mr").WithNestedField(map[string]any{}, "spec").Build(), + want: want{err: true}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + before := tt.in.DeepCopy() + + out, storageVersion, err := NewDefaulter(tt.schemaClient, tt.defClient, tt.policy).Default(t.Context(), tt.in) + + if d := cmp.Diff(before, tt.in); d != "" { + t.Errorf("%s\nDefault() mutated its input (-before +after):\n%s", tt.reason, d) + } + + if (err != nil) != tt.want.err { + t.Fatalf("%s\nDefault() error = %v, want error: %v", tt.reason, err, tt.want.err) + } + + if d := cmp.Diff(tt.want.out, out); d != "" { + t.Errorf("%s\nDefault() (-want +got):\n%s", tt.reason, d) + } + + if storageVersion != tt.want.storageVersion { + t.Errorf("%s\nDefault() storage version = %q, want %q", tt.reason, storageVersion, tt.want.storageVersion) + } + + if out != nil && out == tt.in { + t.Errorf("%s\nDefault() returned its input rather than a copy", tt.reason) + } + }) + } +} diff --git a/internal/diff/diffprocessor/diff_calculator.go b/internal/diff/diffprocessor/diff_calculator.go new file mode 100644 index 00000000..5ed54741 --- /dev/null +++ b/internal/diff/diffprocessor/diff_calculator.go @@ -0,0 +1,844 @@ +package diffprocessor + +import ( + "context" + "fmt" + "maps" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +// DiffCalculator calculates differences between resources. +type DiffCalculator interface { + // CalculateDiff computes the diff for a single resource + CalculateDiff(ctx context.Context, composite *un.Unstructured, desired *un.Unstructured) (*dt.ResourceDiff, error) + + // CalculateDiffs computes all diffs including removals for the rendered resources. + // This is the primary method that most code should use. + CalculateDiffs(ctx context.Context, xr *cmp.Unstructured, desired render.CompositionOutputs) (map[string]*dt.ResourceDiff, error) + + // CalculateNonRemovalDiffs computes diffs for modified/added resources and returns + // what was rendered. This is used by nested XR processing. + // Returns: (diffs map, rendered resources, error) + CalculateNonRemovalDiffs(ctx context.Context, xr *cmp.Unstructured, desired render.CompositionOutputs) (map[string]*dt.ResourceDiff, dtypes.RenderedResources, error) + + // CalculateRemovedResourceDiffs identifies the composed resources in the cluster that Crossplane + // would garbage collect, given what was rendered. This is called after nested XR processing is + // complete. + CalculateRemovedResourceDiffs(ctx context.Context, xr *un.Unstructured, rendered dtypes.RenderedResources) (map[string]*dt.ResourceDiff, error) +} + +// DefaultDiffCalculator implements the DiffCalculator interface. +type DefaultDiffCalculator struct { + treeClient xp.ResourceTreeClient + applyClient k8.ApplyClient + accessChecker k8.AccessChecker + resourceManager ResourceManager + logger logging.Logger + diffOptions renderer.DiffOptions + dryRunOn DryRunOn + predictor Defaulter +} + +// SetDiffOptions updates the diff options used by the calculator. +func (c *DefaultDiffCalculator) SetDiffOptions(options renderer.DiffOptions) { + c.diffOptions = options +} + +// NewDiffCalculator creates a new DefaultDiffCalculator. predictor predicts CRD defaults for the +// additions that get no apiserver result; see dryRunCreateAddition. It should be lenient +// (LenientDefaulting): a prediction is best effort, and must not fail an addition for want of a CRD. +func NewDiffCalculator(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, diffOptions renderer.DiffOptions, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator { + return &DefaultDiffCalculator{ + treeClient: tree, + applyClient: apply, + accessChecker: access, + resourceManager: resourceManager, + logger: logger, + diffOptions: diffOptions, + dryRunOn: dryRunOn, + predictor: predictor, + } +} + +// CalculateDiff calculates the diff for a single resource. +func (c *DefaultDiffCalculator) CalculateDiff(ctx context.Context, composite *un.Unstructured, desired *un.Unstructured) (*dt.ResourceDiff, error) { + // Get resource identification information + name := desired.GetName() + generateName := desired.GetGenerateName() + + // Create a resource ID for logging purposes + var resourceID string + + switch { + case name != "": + resourceID = fmt.Sprintf("%s/%s", desired.GetKind(), name) + case generateName != "": + resourceID = fmt.Sprintf("%s/%s(generated)", desired.GetKind(), generateName) + default: + resourceID = fmt.Sprintf("%s/", desired.GetKind()) + } + + c.logger.Debug("Calculating diff", "resource", resourceID) + + // Fetch current object from cluster + current, isNewObject, err := c.resourceManager.FetchCurrentObject(ctx, composite, desired) + if err != nil { + c.logger.Debug("Failed to fetch current object", "resource", resourceID, "error", err) + return nil, errors.Wrap(err, "cannot fetch current object") + } + + // Log the resource status + if isNewObject { + c.logger.Debug("Resource is new (not found in cluster)", "resource", resourceID) + } else if current != nil { + c.logger.Debug("Found existing resource", + "resourceID", resourceID, + "existingName", current.GetName(), + "resourceVersion", current.GetResourceVersion(), + "resource", current) + } + + // Preserve existing resource identity for resources with generateName + desired = c.preserveExistingResourceIdentity(current, desired, resourceID, name) + + // Preserve the composite label for ALL existing resources + // This is critical because in Crossplane, all resources in a tree point to the ROOT composite, + // not their immediate parent. We must never change this label. + desired = c.preserveCompositeLabel(current, desired, resourceID) + + // Update owner references if needed (done after preserving existing labels) + // IMPORTANT: For composed resources, the owner should be the XR, not a Claim. + // When composite is the current XR from the cluster, we use it as the owner. + // This ensures composed resources only have the XR as their controller owner. + c.resourceManager.UpdateOwnerRefs(ctx, composite, desired) + + // Determine what the resource would look like after application. Both branches below assign it, + // so it is declared without an initialiser. + var wouldBeResult *un.Unstructured + + // dryRunInfo stays nil whenever the desired state did reach the apiserver, which is the success + // case and the common one. See dt.DryRunInfo. + var dryRunInfo *dt.DryRunInfo + + if current != nil { + // Extract the Crossplane field owner from the existing object's managedFields. + // This ensures our dry-run apply uses the same field owner as Crossplane, + // which correctly handles field removal detection (SSA removes fields that + // are owned by this manager but not present in the apply request). + fieldOwner := k8.GetComposedFieldOwner(current) + + applyDesired := sanitizeForDryRun(desired) + + // Perform a dry-run apply to get the result after we'd apply + c.logger.Debug("Performing dry-run apply", + "resource", resourceID, + "name", desired.GetName(), + "fieldOwner", fieldOwner, + "desired", applyDesired) + + wouldBeResult, err = c.applyClient.DryRunApply(ctx, applyDesired, fieldOwner) + if err != nil { + c.logger.Debug("Dry-run apply failed", "resource", resourceID, "error", err) + return nil, c.classifyApplyFailure(ctx, desired, resourceID, err) + } + + c.logger.Debug("Dry-run apply succeeded", "resource", resourceID, "result", wouldBeResult) + } else { + wouldBeResult, dryRunInfo, err = c.dryRunCreateAddition(ctx, desired, resourceID) + if err != nil { + return nil, err + } + } + + // Generate diff with the configured options + diff, err := renderer.GenerateDiffWithOptions(ctx, current, wouldBeResult, c.logger, c.diffOptions) + if err != nil { + c.logger.Debug("Failed to generate diff", "resource", resourceID, "error", err) + return nil, err + } + + if diff != nil { + diff.DryRun = dryRunInfo + } + + // Log the outcome + if diff != nil { + c.logger.Debug("Diff generated", + "resource", resourceID, + "diffType", diff.DiffType, + "hasChanges", diff.DiffType != dt.DiffTypeEqual) + } + + return diff, nil +} + +// CalculateNonRemovalDiffs computes diffs for modified/added resources and returns +// the set of rendered resource keys for removal detection. +// +// TWO-PHASE DIFF ALGORITHM: +// This method implements Phase 1 of our two-phase diff calculation. The two-phase +// approach is necessary to correctly handle nested XRs (Composite Resources that +// themselves create other Composite Resources). +// +// WHY TWO PHASES? +// When processing nested XRs, we must: +// 1. Phase 1 (this method): Calculate diffs for all rendered resources (adds/modifications) +// and build a set of "rendered resource keys" that tracks what was generated +// 2. Phase 2 (CalculateRemovedResourceDiffs): Compare cluster state against rendered +// resources to identify removals +// +// The separation is critical because: +// - Nested XRs are processed recursively BETWEEN these phases +// - Nested XRs generate additional composed resources that must be added to the +// "rendered resources" set before removal detection +// - If we detected removals too early, we'd falsely identify nested XR resources +// as "to be removed" before they've been processed +// +// EXAMPLE SCENARIO: +// +// Parent XR renders: [Resource-A, NestedXR-B] +// NestedXR-B renders: [Resource-C, Resource-D] +// +// Without two phases: +// - We'd see cluster has [Resource-A, Resource-C, Resource-D] from prior render +// - We'd see new render has [Resource-A, NestedXR-B] +// - We'd INCORRECTLY mark Resource-C and Resource-D as removed +// +// With two phases: +// Phase 1: Calculate diffs for [Resource-A, NestedXR-B], track as rendered +// Process nested: Recurse into NestedXR-B, add Resource-C and Resource-D to rendered set +// Phase 2: Now see [Resource-A, NestedXR-B, Resource-C, Resource-D] as rendered +// No false removal detection! +// +// What it records as rendered is keyed for both of its consumers; see dtypes.RenderedResources. Each +// composed resource is recorded as desired by desired.CompositeResource, the composite render consumed +// (for a claim, its backing XR, not the claim xr is), because that is the composite that composes it. +// +// Returns: (diffs map, rendered resources, error). +func (c *DefaultDiffCalculator) CalculateNonRemovalDiffs(ctx context.Context, xr *cmp.Unstructured, desired render.CompositionOutputs) (map[string]*dt.ResourceDiff, dtypes.RenderedResources, error) { + xrName := xr.GetName() + c.logger.Debug("Calculating diffs", + "xr", xrName, + "composedCount", len(desired.ComposedResources)) + + diffs := make(map[string]*dt.ResourceDiff) + + var ( + errs []error + rendered dtypes.RenderedResources + ) + + if desired.CompositeResource == nil { + return nil, rendered, errors.New("render produced no composite resource (possible fatal pipeline error)") + } + + compositeID := identityOf(desired.CompositeResource.GetUnstructured()) + + // Calculate diff for the XR. It is diffed as the caller passed it, never as rendered, nested or + // not: render adds nothing to an XR that belongs in its payload (see diffSingleResourceInternal). + xrDiff, err := c.CalculateDiff(ctx, nil, xr.GetUnstructured()) + if err != nil || xrDiff == nil { + return nil, rendered, errors.Wrap(err, "cannot calculate diff for XR") + } + + // Always store the XR diff, even when DiffTypeEqual. + // We need xrDiff.Current.Raw for removal detection downstream, regardless of whether the XR itself changed. + key := xrDiff.GetDiffKey() + diffs[key] = xrDiff + + // Then calculate diffs for all composed resources + seen := make(map[string]string) // version-independent identity -> description of its first rendering + + for _, d := range desired.ComposedResources { + un := &un.Unstructured{Object: d.UnstructuredContent()} + + // Generate a key to identify this resource + apiVersion := un.GetAPIVersion() + kind := un.GetKind() + name := un.GetName() + generateName := un.GetGenerateName() + + // For logging purposes - create a resource ID that might use generateName + resourceID := fmt.Sprintf("%s/%s", kind, name) + if name == "" && generateName != "" { + resourceID = fmt.Sprintf("%s/%s*", kind, generateName) + } + + // Recorded before anything below can skip the resource: the composite desires it whether or not + // it can be diffed, and that alone keeps Crossplane from garbage collecting what it composed under + // this name. + if resName, ok := un.GetAnnotations()["crossplane.io/composition-resource-name"]; ok { + rendered.AddDesired(compositeID, resName) + } + + // For resources using generateName, the name will be empty but we shouldn't skip them + // Only skip if both name and generateName are empty (likely a template issue) + if name == "" && generateName == "" { + c.logger.Debug("Skipping resource with empty name and generateName", + "kind", kind, + "apiVersion", apiVersion) + + continue + } + + // One object rendered more than once by this XR — under two composition resource names, or at two + // API versions (served versions are views of one stored object, so identity ignores the version) — + // would collapse to one diff, or to two diffs for one object. Crossplane applies every rendering + // with the same field manager, in random order, so no single diff predicts the outcome. Fail it. + if id := identityOf(un); id != "" { + resName := un.GetAnnotations()["crossplane.io/composition-resource-name"] + rendering := fmt.Sprintf("%s (apiVersion %s, composition resource name %q)", resourceID, apiVersion, resName) + + if prev, dup := seen[id]; dup { + errs = append(errs, errors.Errorf("cannot calculate diff for XR %s: the composition renders the same "+ + "object twice, as %s and as %s; Crossplane applies both, in no fixed order, so no single diff "+ + "predicts the result", xrName, prev, rendering)) + + continue + } + + seen[id] = rendering + } + + // For new XRs (xrDiff.Current.Raw is nil) fall back to the input XR as the + // composite parent so UpdateOwnerRefs can assign a placeholder UID to + // composed resources' owner references. Without this, dry-run apply on + // an existing composed resource fails with + // `metadata.ownerReferences.uid: Invalid value: "": must not be empty`, + // because the render pipeline emits empty UIDs when the XR has none. + // + // Skip for generateName-only XRs: they get a synthetic display name like + // "foo(generated)" that is invalid as a label selector value. + composite := xrDiff.Current.Raw + if composite == nil && xr.GetName() != "" && xr.GetGenerateName() == "" { + composite = xr.GetUnstructured() + } + + diff, err := c.CalculateDiff(ctx, composite, un) + if err != nil { + c.logger.Debug("Error calculating diff for composed resource", "resource", resourceID, "error", err) + errs = append(errs, errors.Wrapf(err, "cannot calculate diff for %s", resourceID)) + + continue + } + + diffKey := diff.GetDiffKey() + if diff.DiffType != dt.DiffTypeEqual { + diffs[diffKey] = diff + } + + rendered.AddKey(diffKey) + c.logger.Debug("Added resource to rendered resources", + "xr", xrName, + "diffKey", diffKey, + "diffType", diff.DiffType) + } + + // Log a summary + c.logger.Debug("Diff calculation complete", + "totalDiffs", len(diffs), + "renderedResourcesCount", len(rendered.Keys()), + "errors", len(errs), + "xr", xrName) + + if len(errs) > 0 { + return diffs, rendered, errors.Join(errs...) + } + + return diffs, rendered, nil +} + +// CalculateDiffs computes all diffs including removals for the rendered resources. +// This is the primary method that most code should use. +func (c *DefaultDiffCalculator) CalculateDiffs(ctx context.Context, xr *cmp.Unstructured, desired render.CompositionOutputs) (map[string]*dt.ResourceDiff, error) { + // First calculate diffs for modified/added resources + diffs, rendered, err := c.CalculateNonRemovalDiffs(ctx, xr, desired) + if err != nil { + return nil, err + } + + // Then detect removed resources + removedDiffs, err := c.CalculateRemovedResourceDiffs(ctx, xr.GetUnstructured(), rendered) + if err != nil { + return nil, err + } + + // Merge removed diffs into the main diffs map + maps.Copy(diffs, removedDiffs) + + return diffs, nil +} + +// CalculateRemovedResourceDiffs identifies the composed resources Crossplane would garbage collect, and +// calculates their removal diffs. +// +// It applies Crossplane's own rule (crossplane v2.4.2, internal/controller/apiextensions/composite/ +// composition_functions.go). ExistingComposedResourceObserver.ObserveComposedResources observes each +// resource in a composite's resourceRefs, keyed by its composition resource name, except one another +// controller owns. DeletingComposedResourceGarbageCollector.GarbageCollectComposedResources then deletes +// each observed resource whose name is not among the names the composite's render desired, unless it +// has no controller reference. Matching is by composition resource name within the composite, never by +// API version or object name: a resource rendered at a new apiVersion is updated in place (#552). +// +// The walk covers nested XRs' resources too, each matched against the composite it hangs from in the +// tree, which is the one that composed it. A composite that was not rendered, such as a nested XR being +// removed, desires nothing, so what it composed is removed with it. +func (c *DefaultDiffCalculator) CalculateRemovedResourceDiffs(ctx context.Context, xr *un.Unstructured, rendered dtypes.RenderedResources) (map[string]*dt.ResourceDiff, error) { + xrName := xr.GetName() + c.logger.Debug("Checking for resources to be removed", + "xr", xrName, + "renderedResourceCount", len(rendered.Keys())) + + removedDiffs := make(map[string]*dt.ResourceDiff) + + // Try to get the resource tree + resourceTree, err := c.treeClient.GetResourceTree(ctx, xr) + if err != nil { + c.logger.Debug("Cannot get resource tree; aborting", "error", err) + return nil, errors.Wrap(err, "cannot get resource tree") + } + + // The objects rendered, by version-independent identity, to tell when one Crossplane garbage collects + // is rendered again. + renderedIDs := make(map[string]bool, len(rendered.Keys())) + for key := range rendered.Keys() { + renderedIDs[diffKeyIdentity(key)] = true + } + + var errs []error + + // Create a handler function to recursively traverse the tree and find composed resources. composite + // is the node that node hangs from: the composite whose resourceRefs name it. + var findRemovedResources func(composite, node *resource.Resource) + + findRemovedResources = func(composite, node *resource.Resource) { + // Only composed resources carry the annotation; a claim's backing XR, say, does not. + if resName, hasAnno := node.Unstructured.GetAnnotations()["crossplane.io/composition-resource-name"]; hasAnno && garbageCollected(composite, node, resName, rendered) { + resourceID := fmt.Sprintf("%s/%s", node.Unstructured.GetKind(), node.Unstructured.GetName()) + + // Crossplane garbage collects before it applies what was rendered, so it would delete this + // object and then create it again: a removal and an addition that no single diff of it shows. + if id := identityOf(&node.Unstructured); id != "" && renderedIDs[id] { + errs = append(errs, errors.Errorf("cannot calculate removals for XR %s: %s/%s composed %s as %q, which "+ + "it no longer renders, but it renders the same object again under another composition resource "+ + "name; Crossplane deletes the resource it no longer renders before it applies the ones it does, "+ + "so it would delete the object and create it again, which no single diff predicts", + xrName, composite.Unstructured.GetKind(), composite.Unstructured.GetName(), resourceID, resName)) + + return + } + + c.logger.Debug("Resource will be removed", "resource", resourceID) + + diff, err := renderer.GenerateDiffWithOptions(ctx, &node.Unstructured, nil, c.logger, c.diffOptions) + if err != nil { + c.logger.Debug("Cannot calculate removal diff (continuing)", + "resource", resourceID, + "error", err) + + return + } + + if diff != nil { + diffKey := diff.GetDiffKey() + removedDiffs[diffKey] = diff + } + } + + // Continue recursively traversing children + for _, child := range node.Children { + findRemovedResources(node, child) + } + } + + // Start the traversal from the root's children to skip the XR itself + for _, child := range resourceTree.Children { + findRemovedResources(resourceTree, child) + } + + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + c.logger.Debug("Found resources to be removed", "count", len(removedDiffs)) + + return removedDiffs, nil +} + +// garbageCollected reports whether Crossplane would garbage collect node, which composite composed as +// resName: whether composite controls node, and its render no longer desires resName. See +// CalculateRemovedResourceDiffs. +func garbageCollected(composite, node *resource.Resource, resName string, rendered dtypes.RenderedResources) bool { + // ObserveComposedResources skips a resource another controller owns, and + // GarbageCollectComposedResources never deletes one with no controller reference. + ctrl := metav1.GetControllerOf(&node.Unstructured) + if ctrl == nil || ctrl.UID != composite.Unstructured.GetUID() { + return false + } + + return !rendered.Desires(identityOf(&composite.Unstructured), resName) +} + +// dryRunCreateAddition computes the post-apply form of a resource that does not yet exist, by +// dry-run creating it. +// +// On success it returns the apiserver's view of the object, which is the whole point: server-side +// defaulting and mutating admission are invisible to the render pipeline. The rendered object is sent +// as it is, with no locally predicted defaults, because the apiserver applies its own. +// +// When the dry run could not be performed it returns the rendered object with locally predicted CRD +// defaults (see predictLocally) plus a DryRunInfo saying why, rather than failing: a missing +// permission or an unreachable webhook is a property of the environment, not a finding about the +// resource, and an addition has a reasonable lower-fidelity fallback. It returns an error only when +// the cluster actually refused the resource (a real finding, routed to the schema-validation +// exit-code tier) or when something is wrong that we must not paper over. +func (c *DefaultDiffCalculator) dryRunCreateAddition(ctx context.Context, desired *un.Unstructured, resourceID string) (*un.Unstructured, *dt.DryRunInfo, error) { + if c.dryRunOn == DryRunOnExisting { + return c.predictLocally(ctx, desired, resourceID, &dt.DryRunInfo{SkipReason: dt.DryRunSkipDisabled}) + } + + createDesired := sanitizeForDryRun(desired) + + c.logger.Debug("Performing dry-run create", "resource", resourceID, "desired", createDesired) + + created, err := c.applyClient.DryRunCreate(ctx, createDesired) + + switch { + case err == nil: + c.logger.Debug("Dry-run create succeeded", "resource", resourceID, "result", created) + return mergeDryRunCreateResult(desired, createDesired, created), nil, nil + + case errors.Is(err, k8.ErrUnresolvableGVK): + // MUST precede every apierrors check below. The request never reached the apiserver: the GVK + // could not be resolved to a resource. A discovery 404 is apierrors-NotFound, so without this + // branch an unknown type would be reported as a missing namespace — the user would go looking + // at the wrong thing entirely. Nor is it a degradation: we cannot diff a type the cluster does + // not serve, and pretending otherwise would present rendered output for a resource that cannot + // exist. + return nil, nil, errors.Wrapf(err, "cannot dry-run create %s", resourceID) + + case apierrors.IsForbidden(err): + // A 403 is ambiguous. It can mean we lack the create verb, in which case nothing was learned + // about the resource and we must degrade quietly. It can equally come from ResourceQuota or + // from a validating webhook, which are real findings the user needs. Only the authorizer can + // tell the two apart, and deciding by pattern-matching the apiserver's message would rest that + // distinction on unversioned prose. + return c.resolveForbiddenCreate(ctx, desired, resourceID, err) + + case apierrors.IsNotFound(err): + // The resource's target namespace does not exist yet. GVKToGVR has already resolved the + // resource itself, so in practice this is NamespaceLifecycle admission and nothing else. + // + // This is a degradation, NOT a finding, and the distinction is the whole point: a quota or + // webhook refusal describes the object and will still hold when the user applies, whereas a + // missing namespace is a precondition they are very often about to satisfy in the SAME apply — + // a Namespace and the resources inside it in one `kubectl apply -f ./manifests/` is routine. + // crossplane-diff cannot know whether the namespace is part of that apply, so refusing to diff + // would break a supported workflow to report something that may not be true by the time it + // matters. TestDiffConcurrentDirectory diffs 21 XRs into a namespace that is never created and + // is exactly this case. + return c.predictLocally(ctx, desired, resourceID, &dt.DryRunInfo{SkipReason: dt.DryRunSkipNamespaceNotFound, Detail: apiserverMessage(err)}) + + case apierrors.IsInvalid(err): + return nil, nil, NewAdmissionRejectionError(resourceID, desired, err) + + case apierrors.IsAlreadyExists(err): + // We only get here because FetchCurrentObject reported no existing resource. Either its + // matching logic is wrong or something created the resource underneath us; either way the + // diff would be built on a false premise, so fail loudly instead of degrading. + return nil, nil, errors.Wrapf(err, "dry-run create of %s reports it already exists, but it was not found in the cluster", resourceID) + + case apierrors.IsInternalError(err), apierrors.IsServiceUnavailable(err), apierrors.IsTimeout(err): + // The apiserver could not complete the admission chain — classically an unreachable webhook + // with failurePolicy: Fail. We cannot know what it would have done, so we must not present + // rendered output as though it were verified. + return c.predictLocally(ctx, desired, resourceID, &dt.DryRunInfo{SkipReason: dt.DryRunSkipWebhookUnavailable, Detail: apiserverMessage(err)}) + + default: + return nil, nil, errors.Wrapf(err, "cannot dry-run create %s", resourceID) + } +} + +// predictLocally is what an addition shows when it got no apiserver result: the rendered object with +// the CRD defaults the apiserver would have applied, predicted locally, and the DryRunInfo saying why +// the apiserver was not asked. The prediction is never sent anywhere, so unlike a dry-run payload it +// cannot claim fields. It covers CRD `default:` values only; see Defaulter for what it misses. Of +// that, a multi-version CRD's storage version is recorded on the DryRunInfo, so that the addition's +// warning can say its diff may omit that version's defaults and conversion (#528). It does not prune +// undeclared fields either, but cannot meet one: schema validation rejects them as unknownField +// errors before an addition gets here. +func (c *DefaultDiffCalculator) predictLocally(ctx context.Context, desired *un.Unstructured, resourceID string, info *dt.DryRunInfo) (*un.Unstructured, *dt.DryRunInfo, error) { + predicted, storageVersion, err := c.predictor.Default(ctx, desired) + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot predict defaults for %s", resourceID) + } + + info.StorageVersion = storageVersion + + return predicted, info, nil +} + +// resolveForbiddenCreate asks the authorizer whether a Forbidden from a dry-run create was an +// authorization denial (degrade) or an admission/quota refusal (report). +func (c *DefaultDiffCalculator) resolveForbiddenCreate(ctx context.Context, desired *un.Unstructured, resourceID string, createErr error) (*un.Unstructured, *dt.DryRunInfo, error) { + // Can reports whether we MAY create, so a denial is !allowed. Reading this the wrong way round + // inverts the whole feature: an authorized user would silently lose fidelity, and an unauthorized + // one would be told the cluster rejected a resource it never saw. + allowed, reason, ssarErr := c.accessChecker.Can(ctx, desired.GroupVersionKind(), desired.GetNamespace(), dtypes.VerbCreate) + + switch { + case ssarErr != nil: + // We hold a 403 we cannot classify. Reporting it as a cluster rejection would assert a finding + // we have not established; swallowing it as a permission problem would hide one. Say we do not + // know. + return nil, nil, errors.Wrapf(createErr, "cannot dry-run create %s, and cannot determine whether that was an authorization denial (%v)", resourceID, ssarErr) + + case !allowed: + return c.predictLocally(ctx, desired, resourceID, &dt.DryRunInfo{SkipReason: dt.DryRunSkipForbidden, Detail: reason}) + + default: + // Authorized to create, yet refused: admission or quota. A real finding. + return nil, nil, NewAdmissionRejectionError(resourceID, desired, createErr) + } +} + +// classifyApplyFailure turns a failed dry-run apply of an EXISTING resource into the right kind of +// error. A cluster rejection is reported as such — the same fact, in the same exit-code tier, as a +// rejection of an addition, rather than depending on whether the resource happened to exist already +// (crossplane-diff#334). +// +// Unlike an addition this path never degrades. An existing resource's diff depends on the +// apiserver's merge result for SSA field-removal detection, so one computed without it would be +// wrong rather than merely less detailed — and crossplane-diff has always required the patch verb +// (see the README's RBAC section). +func (c *DefaultDiffCalculator) classifyApplyFailure(ctx context.Context, desired *un.Unstructured, resourceID string, applyErr error) error { + switch { + case errors.Is(applyErr, k8.ErrUnresolvableGVK): + // MUST precede the apierrors checks, for the reason given in dryRunCreateAddition: a + // discovery-layer failure is shaped like an admission-layer one, and reporting "the cluster + // rejected this" for a type the cluster does not serve would be actively misleading. + return errors.Wrapf(applyErr, "cannot dry-run apply %s", resourceID) + + case apierrors.IsForbidden(applyErr): + // As in resolveForbiddenCreate: Can reports whether we MAY patch, so a denial is !allowed. + allowed, reason, ssarErr := c.accessChecker.Can(ctx, desired.GroupVersionKind(), desired.GetNamespace(), dtypes.VerbPatch) + + switch { + case ssarErr != nil: + return errors.Wrapf(applyErr, "cannot dry-run apply desired object %s, and cannot determine whether that was an authorization denial (%v)", resourceID, ssarErr) + case !allowed: + return errors.Wrapf(applyErr, "not authorized to dry-run apply %s (%s); crossplane-diff requires the 'patch' verb on every GVK it diffs", resourceID, reason) + default: + return NewAdmissionRejectionError(resourceID, desired, applyErr) + } + + case apierrors.IsInvalid(applyErr): + return NewAdmissionRejectionError(resourceID, desired, applyErr) + + default: + return errors.Wrap(applyErr, "cannot dry-run apply desired object") + } +} + +// mergeDryRunCreateResult combines the apiserver's view of a dry-run created object with the three +// things that must come from our side instead. +// +// Owner references are LIVE. sanitizeForDryRun strips them before sending, so the server hands back an +// object with none, yet the real apply would create it with the rendered controller reference. The +// desired object is read for more than display: render-overlap detection (controllerOf in +// input_validator.go) names the XR that would control each composed resource from it, so without the +// restoration two XRs contending for one new object are misreported as merely disagreeing about it. +// Any references the server did return can only have been added by a mutating webhook, and are kept +// after ours. +// +// The other two restorations are LATENT — correct when reached, but not reachable on any path today. +// They are kept as guards because each protects against a genuinely wrong output if the surrounding +// code changes, and neither costs anything. Do not read either as live behaviour. +// +// Identity, when we sent a generateName and no name: the apiserver runs names.Generator in +// rest.BeforeCreate, ahead of the dry-run short-circuit at the storage layer, so it invents a random +// name, which would make an addition's diff differ on every run. For a *named* resource we keep the +// server's name, so a mutating webhook that rewrites a name still surfaces. +// Latent because nothing reaches here with an empty name: SanitizeXR synthesizes a +// deterministic name for generateName XRs (see SynthesizeGeneratedName), and the render binary names +// generateName composed resources itself. +// +// Status, whenever the rendered object had one: composition pipelines are allowed to write their own +// status, so a rendered status is authored content. The apiserver contributes nothing to status on +// create (it is a subresource) and hands back an empty one, so taking the server's would delete the +// user's output under the banner of fidelity. +// Latent because status never reaches any rendered diff in the first place: cleanupForDiff +// (renderer/diff_formatter.go) deletes metadata.status unconditionally from both sides, and Clean is +// the only view renderers read. That makes this the same shape of mistake as the ownerReferences +// comment this change deleted — a restoration justified by an output effect that does not exist — so +// it is labelled rather than presented as load-bearing. Whether composition-authored status SHOULD be +// visible in a diff is a rendering decision, tracked separately. +func mergeDryRunCreateResult(rendered, sent, created *un.Unstructured) *un.Unstructured { + out := created.DeepCopy() + + if refs := rendered.GetOwnerReferences(); len(refs) > 0 { + out.SetOwnerReferences(append(refs, out.GetOwnerReferences()...)) + } + + if sent.GetGenerateName() != "" && sent.GetName() == "" { + out.SetName("") + out.SetGenerateName(sent.GetGenerateName()) + } + + if status, found, err := un.NestedFieldCopy(rendered.Object, "status"); err == nil && found { + _ = un.SetNestedField(out.Object, status, "status") + } + + return out +} + +// apiserverMessage returns the apiserver's own message for a dry-run failure, without the +// "failed to dry-run create resource /" wrapping ApplyClient adds. The wrapping names the +// resource, which is useful in an error but wrong in a cause: it would make every resource's cause +// unique, so the renderer's summary of unverified additions (one warning per GVK + namespace + cause) +// would split into one warning per resource. Status().Message carries no resource name. +// +// It is only called from branches selected by an apierrors predicate (IsNotFound, IsInternalError, +// ...), and every one of those finds the APIStatus through errors.As, so the status is always present +// here. +func apiserverMessage(err error) string { + var status apierrors.APIStatus + + _ = errors.As(err, &status) + + return status.Status().Message +} + +// sanitizeForDryRun returns a deep copy of obj with the server-owned metadata removed, ready to send +// to the apiserver for a dry run. The copy matters: the caller's desired object is also what +// downstream diff comparison reads, so it must not be mutated. +// +// Every field stripped is either assigned by the server or rejected outright on input, so sending it +// is never useful and is sometimes fatal. +// +// ownerReferences is in this list, and a note on why, because the code it replaces claimed the +// opposite. The previous comment here justified stripping ownerRefs on the update path only, on the +// grounds that "for new resources we skip DryRunApply entirely so the rendered ownerRefs still +// surface in the diff output". They never surface: cleanupForDiff (renderer/diff_formatter.go) +// removes metadata.ownerReferences unconditionally from both sides of every comparison, ungated by +// forVerdict. So dropping them from the REQUEST costs nothing in displayed output, while sending them +// risks two real failures — an empty owner UID, which the apiserver rejects outright, and +// OwnerReferencesPermissionEnforcement (enabled by default on OpenShift), which demands delete +// permission on the owner. The SSA multi-controller hazard the original comment described is also +// still avoided, since leaving ownerRefs out of an apply preserves whatever the cluster already has. +// +// That is a statement about the request only. The desired object's controller reference IS read +// beyond display (render-overlap detection attributes each composed resource to its controlling XR), +// so the reference must not be lost from the RESULT. For an existing resource the apply preserves the +// cluster's; for an addition there is nothing to preserve, which is why mergeDryRunCreateResult +// restores the rendered references after the round trip. Reading "costs nothing" as "ownerReferences +// don't matter here" is the mistake that restoration fixed. +// +// managedFields matters for a different reason: client-go's dynamic Apply refuses any object that +// has it populated ("cannot apply an object with managed fields already set"). diff_processor.go +// guards the XR against this; composed resources had no equivalent. Stripping here closes it for +// every path at the one point where objects leave for the apiserver. +// +// resourceVersion is the same shape of problem: a stale value from a user's input file (very +// plausible for anything exported with `kubectl get -o yaml`) makes the apiserver reject the request +// on optimistic concurrency, and it is illegal on a create. diff_processor.go already clears it for +// the XR; composed resources had no equivalent. +func sanitizeForDryRun(obj *un.Unstructured) *un.Unstructured { + out := obj.DeepCopy() + + for _, field := range []string{ + "resourceVersion", + "uid", + "creationTimestamp", + "generation", + "selfLink", + "managedFields", + "ownerReferences", + } { + un.RemoveNestedField(out.Object, "metadata", field) + } + + return out +} + +// preserveExistingResourceIdentity preserves the identity (name, generateName, labels) from an existing +// resource to ensure dry-run apply works on the correct resource identity. +// This is critical for: +// - Claim scenarios where the rendered name differs from the generated name +// - Nested XRs where the composition adds environment-specific prefixes to names. +func (c *DefaultDiffCalculator) preserveExistingResourceIdentity(current, desired *un.Unstructured, resourceID, renderedName string) *un.Unstructured { + // Only preserve identity for existing resources with a name + // Note: We don't require generateName because nested XRs and other resources may have + // fixed names from the composition that differ from the cluster name due to prefixes. + if current == nil || current.GetName() == "" { + return desired + } + + currentName := current.GetName() + currentGenerateName := current.GetGenerateName() + + c.logger.Debug("Using existing resource identity for dry-run apply", + "resource", resourceID, + "renderedName", renderedName, + "currentName", currentName, + "currentGenerateName", currentGenerateName) + + // Create a copy of the desired object and set the identity from the current resource + // This ensures dry-run apply works on the correct resource identity + desiredCopy := desired.DeepCopy() + desiredCopy.SetName(currentName) + desiredCopy.SetGenerateName(currentGenerateName) + + // Preserve important labels from the existing resource, particularly the composite label + // This ensures the dry-run apply gets the right labels and preserves them correctly + CopyLabels(current, desiredCopy, LabelComposite) + + if compositeLabel := current.GetLabels()[LabelComposite]; compositeLabel != "" { + c.logger.Debug("Preserved composite label for dry-run apply", + "resource", resourceID, + "compositeLabel", compositeLabel) + } + + return desiredCopy +} + +// preserveCompositeLabel preserves the crossplane.io/composite label from an existing composed resource. +// This is critical because in Crossplane, all composed resources (including nested XRs) point to the ROOT composite. +// We must never change this label for existing composed resources. +// Root XRs don't have the composition-resource-name annotation and shouldn't have this label. +func (c *DefaultDiffCalculator) preserveCompositeLabel(current, desired *un.Unstructured, resourceID string) *un.Unstructured { + // Only preserve for composed resources (those with composition-resource-name annotation) + if current == nil || desired.GetAnnotations()["crossplane.io/composition-resource-name"] == "" { + return desired + } + + compositeLabel := current.GetLabels()[LabelComposite] + if compositeLabel == "" { + return desired + } + + // Preserve the composite label + desiredCopy := desired.DeepCopy() + CopyLabels(current, desiredCopy, LabelComposite) + + c.logger.Debug("Preserved composite label from existing composed resource", + "resource", resourceID, + "compositeLabel", compositeLabel) + + return desiredCopy +} diff --git a/internal/diff/diffprocessor/diff_calculator_test.go b/internal/diff/diffprocessor/diff_calculator_test.go new file mode 100644 index 00000000..88762876 --- /dev/null +++ b/internal/diff/diffprocessor/diff_calculator_test.go @@ -0,0 +1,2470 @@ +package diffprocessor + +import ( + "context" + "sort" + "strings" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + dtypes "github.com/crossplane/cli/v2/internal/diff/types" +) + +// Ensure MockDiffCalculator implements the DiffCalculator interface. +var _ DiffCalculator = &tu.MockDiffCalculator{} + +func TestDefaultDiffCalculator_CalculateDiff(t *testing.T) { + ctx := t.Context() + + // Create test resources + existingResource := tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "old-value"). + Build() + + modifiedResource := tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "new-value"). + Build() + + newResource := tu.NewResource("example.org/v1", "TestResource", "new-resource"). + WithSpecField("field", "value"). + Build() + + const ParentXRName = "parent-xr" + + composedResource := tu.NewResource("example.org/v1", "ComposedResource", "cpd-resource"). + WithSpecField("field", "old-value"). + WithLabels(map[string]string{ + "crossplane.io/composite": ParentXRName, + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build() + + // Parent XR + parentXR := tu.NewResource("example.org/v1", "XR", ParentXRName). + WithSpecField("field", "value"). + Build() + + tests := map[string]struct { + setupMocks func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) + composite *un.Unstructured + desired *un.Unstructured + wantDiff *dt.ResourceDiff + wantNil bool + wantErr bool + + // accessChecker is the authorizer consulted when a dry run comes back Forbidden. Nil means + // "allows everything", which is what routes a Forbidden to the cluster-rejection path. + accessChecker k8.AccessChecker + + // dryRunOn mirrors the CLI flag. The zero value means DryRunOnAll, as in ProcessorConfig, so + // unset cases exercise the default behaviour rather than a special test-only mode. + dryRunOn DryRunOn + + // storedAt is the storage version the case's Defaulter reports for every resource it defaults: + // the version, other than the requested one, that the resource's CRD stores. Empty, the common + // case, means there is none. + storedAt string + + // wantDryRun is the expected DryRunInfo on the resulting diff. Nil asserts its ABSENCE, i.e. + // that the desired state really did go through the apiserver. + wantDryRun *dt.DryRunInfo + + // wantSchemaValidationErr asserts the error lands in the schema-validation exit-code tier, + // which is how a cluster rejection is distinguished from a tool failure. + wantSchemaValidationErr bool + + // wantErrContains, when set, requires the error message to contain this substring. Several + // failure modes are all "an error" but must not be confused with each other. + wantErrContains string + + // wantServerField, when set, requires spec.defaultedField in the DESIRED side of the diff to + // hold this value. That field is never present in the rendered input, so it can only have come + // from the apiserver's response — which is the whole claim this feature makes. + wantServerField string + + // wantControllerOf, when set, requires the DESIRED side of the diff to carry a controller owner + // reference naming this owner. Render-overlap detection reads it to attribute a composed resource + // to the XR that would control it, so an addition must not lose it to the dry-run round trip. + wantControllerOf string + + // wantLocallyDefaulted says whether the DESIRED side carries the marker field every case's + // Defaulter adds. It must be set exactly when no apiserver result exists — an addition + // that was not dry-run created — and never otherwise: a locally defaulted field in a dry-run + // payload claims ownership of it (#503), and the fake apply clients echo their payload, so a + // defaulted payload would surface here. + wantLocallyDefaulted bool + }{ + "ExistingResourceModified": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingResource). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: modifiedResource, + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "existing-resource", + DiffType: dt.DiffTypeModified, + }, + }, + "NewResource": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // An addition is now dry-run CREATED, not left as rendered, so this mock must stub the + // create path as well. + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + WithSuccessfulDryRunCreate(). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourceNotFound(). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: newResource, + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + }, + "ComposedResource": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(composedResource). + WithResourcesFoundByLabel([]*un.Unstructured{composedResource}, "crossplane.io/composite", ParentXRName). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: parentXR, + desired: tu.NewResource("example.org/v1", "ComposedResource", "cpd-resource"). + WithSpecField("field", "new-value"). + WithLabels(map[string]string{ + "crossplane.io/composite": ParentXRName, + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "ComposedResource", Group: "example.org", Version: "v1"}, + ResourceName: "cpd-resource", + DiffType: dt.DiffTypeModified, + }, + }, + "NoChanges": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingResource). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: existingResource.DeepCopy(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "existing-resource", + DiffType: dt.DiffTypeEqual, + }, + }, + // --- dry-run-create of additions (crossplane-diff#334) ------------------------------------- + // + // These cases exercise every outcome of the addition path. The shared shape is a resource that + // is NOT in the cluster, so CalculateDiff takes the create branch; what varies is what the + // apiserver says and what the authorizer says about a Forbidden. + + "AdditionPicksUpServerSideChanges": { + // The point of the whole feature: the apiserver's view of the object is what gets diffed, so + // fields it defaulted or a mutating webhook injected appear in the addition diff. Asserted by + // having the fake return a field the rendered object never had. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(_ context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + served := obj.DeepCopy() + _ = un.SetNestedField(served.Object, "server-assigned", "spec", "defaultedField") + + return served, nil + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantServerField: "server-assigned", + }, + "AdditionKeepsRenderedControllerReference": { + // sanitizeForDryRun strips ownerReferences from the request, and the fake echoes the request + // back, so the server's object carries none: exactly what a real apiserver returns. The real + // apply would create the object WITH its controller reference, so the desired side must too. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient().WithSuccessfulDryRunCreate().Build() + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: tu.NewResource("example.org/v1", "TestResource", "new-resource"). + WithSpecField("field", "value"). + WithControllerReference("XR", ParentXRName, "example.org/v1", "parent-xr-uid"). + Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantControllerOf: ParentXRName, + }, + "AdditionNotDryRunWhenDisabled": { + // --dry-run-on=existing reproduces the pre-#334 behaviour, and says so per-resource rather + // than presenting rendered output as verified. DryRunCreate is deliberately left unstubbed: + // if the flag were ignored the mock's "not implemented" error would fail the case. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient().Build() + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + dryRunOn: DryRunOnExisting, + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantLocallyDefaulted: true, + wantDryRun: &dt.DryRunInfo{SkipReason: dt.DryRunSkipDisabled}, + }, + "AdditionNotDryRunWhenDisabledRecordsTheStorageVersion": { + // The local prediction defaults against the requested version only, so a CRD that stores + // another version may default or convert the resource in ways the diff does not show. The + // calculator records the version the Defaulter reports so the renderer can say so. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient().Build() + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + dryRunOn: DryRunOnExisting, + storedAt: "v2", + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantLocallyDefaulted: true, + wantDryRun: &dt.DryRunInfo{SkipReason: dt.DryRunSkipDisabled, StorageVersion: "v2"}, + }, + "AdditionForbiddenByRBACRecordsTheStorageVersion": { + // Every fallback that predicts locally records the storage version, not just the one the + // user chose: the prediction is the same whatever kept the apiserver from being asked. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "new-resource", errors.New("nope")) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + storedAt: "v2", + accessChecker: tu.NewMockAccessChecker().WithDenied("no create on testresources").Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantLocallyDefaulted: true, + wantDryRun: &dt.DryRunInfo{SkipReason: dt.DryRunSkipForbidden, Detail: "no create on testresources", StorageVersion: "v2"}, + }, + "AdditionForbiddenByRBACDegrades": { + // Forbidden + authorizer says we may NOT create: a property of our credentials, not a finding + // about the resource. Fall back to rendered output and say so; do not fail the run. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "new-resource", errors.New("nope")) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + accessChecker: tu.NewMockAccessChecker().WithDenied("no create on testresources").Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantLocallyDefaulted: true, + wantDryRun: &dt.DryRunInfo{SkipReason: dt.DryRunSkipForbidden, Detail: "no create on testresources"}, + }, + "AdditionForbiddenWhileAuthorizedIsARejection": { + // Forbidden + authorizer says we MAY create: the refusal came from admission, quota, or a + // missing namespace. A real finding, and the case that would be silently swallowed if every + // 403 were treated as an RBAC problem. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "new-resource", errors.New("exceeded quota: storage")) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + accessChecker: tu.NewMockAccessChecker().WithAllowed().Build(), + wantErr: true, + wantSchemaValidationErr: true, + wantErrContains: "exceeded quota", + }, + "AdditionInvalidIsARejection": { + // A 422 needs no authorizer round-trip: it is unambiguously about the content. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewInvalid( + schema.GroupKind{Group: "example.org", Kind: "TestResource"}, + "new-resource", + field.ErrorList{field.Invalid(field.NewPath("spec", "field"), "value", "must be uppercase")}, + ) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantErr: true, + wantSchemaValidationErr: true, + wantErrContains: "must be uppercase", + }, + "AdditionWebhookUnavailableDegrades": { + // The apiserver could not complete admission, so we cannot know what it would have done. Fall + // back, but disclaim it — presenting rendered output as verified here would be the dishonest + // option. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewInternalError(errors.New(`failed calling webhook "policy.example.org": connection refused`)) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantLocallyDefaulted: true, + wantDryRun: &dt.DryRunInfo{ + SkipReason: dt.DryRunSkipWebhookUnavailable, + Detail: `Internal error occurred: failed calling webhook "policy.example.org": connection refused`, + }, + }, + "AdditionWithUnresolvableGVKIsNotBlamedOnItsNamespace": { + // PR #502 review finding. The error here is deliberately BOTH sentinel-joined AND + // apierrors-NotFound, because that is the real situation: DryRunCreate resolves the GVK + // through discovery, and a discovery 404 for an unserved group/version is NotFound-shaped, + // indistinguishable from NamespaceLifecycle refusing a resource whose type is fine. + // + // So this case pins the ORDERING of the classification, not just its output. If the + // ErrUnresolvableGVK branch is moved below the IsNotFound branch, an unknown type gets + // reported as a missing namespace and the user goes looking at entirely the wrong thing. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, errors.Join( + k8.ErrUnresolvableGVK, + apierrors.NewNotFound(schema.GroupResource{Group: "totallynotserved.k8s.io", Resource: "widgets"}, ""), + ) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantErr: true, + wantSchemaValidationErr: false, + wantErrContains: "cannot resolve resource type", + }, + "AdditionWithTransientDiscoveryFailureIsNotBlamedOnAdmission": { + // The reviewer's second variant, and a genuine misclassification rather than just a poor + // message. Discovery can fail with 503/timeout as easily as 404, and those shapes feed the + // webhookUnavailable branch — so without the sentinel check first, "discovery was briefly + // unavailable" is reported as "the cluster could not complete admission", and the resource is + // silently degraded instead of failing. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, errors.Join( + k8.ErrUnresolvableGVK, + apierrors.NewServiceUnavailable("discovery is warming up"), + ) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantErr: true, + wantSchemaValidationErr: false, + wantErrContains: "cannot resolve resource type", + }, + "ExistingResourceWithUnresolvableGVKIsNotARejection": { + // The mirror on the apply path. Here the sentinel branch changes the message rather than the + // exit-code tier — a discovery failure would otherwise land in the catch-all, which is still + // a tool error. So this asserts the resource-bearing message the sentinel branch produces + // ("cannot dry-run apply TestResource/existing-resource"), not the generic "desired object" + // wording the catch-all emits; otherwise the case would pass with the branch removed and pin + // nothing at all. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunApply(func(context.Context, *un.Unstructured, string) (*un.Unstructured, error) { + return nil, errors.Join( + k8.ErrUnresolvableGVK, + apierrors.NewNotFound(schema.GroupResource{Group: "totallynotserved.k8s.io", Resource: "widgets"}, ""), + ) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourcesExist(existingResource).Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: modifiedResource, + wantErr: true, + wantSchemaValidationErr: false, + wantErrContains: "cannot dry-run apply TestResource/existing-resource", + }, + "AdditionInMissingNamespaceDegrades": { + // Regression test for the e2e failure this classification originally caused + // (TestDiffConcurrentDirectory diffs 21 XRs into a namespace that is never created). + // + // A NotFound from a dry-run create is NamespaceLifecycle admission: the target namespace does + // not exist yet. That must degrade, not fail. A namespace and the resources inside it are + // routinely applied together, so the namespace's absence at diff time says nothing about + // whether the apply will succeed — which is exactly what separates it from a quota or webhook + // refusal, where the verdict describes the object and will still hold. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, "does-not-exist") + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "new-resource", + DiffType: dt.DiffTypeAdded, + }, + wantLocallyDefaulted: true, + wantDryRun: &dt.DryRunInfo{ + SkipReason: dt.DryRunSkipNamespaceNotFound, + Detail: `namespaces "does-not-exist" not found`, + }, + }, + "AdditionAlreadyExistsFailsLoudly": { + // FetchCurrentObject said this resource does not exist and the apiserver says it does. The + // diff would rest on a false premise, so this must not degrade — and must not be reported as + // a cluster rejection of the user's content either. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewAlreadyExists(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "new-resource") + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + wantErr: true, + wantSchemaValidationErr: false, + wantErrContains: "already exists, but it was not found in the cluster", + }, + "AdditionForbiddenWithUnusableAuthorizerFailsLoudly": { + // We hold a 403 we cannot classify. Claiming a cluster rejection would assert an unestablished + // finding; claiming RBAC would hide a real one. Saying "we don't know" is the only honest + // option, and it must not be the schema-validation tier. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunCreate(func(context.Context, *un.Unstructured) (*un.Unstructured, error) { + return nil, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "new-resource", errors.New("denied")) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourceNotFound().Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: newResource, + accessChecker: tu.NewMockAccessChecker().WithFailedCheck(errors.New("ssar exploded")).Build(), + wantErr: true, + wantSchemaValidationErr: false, + wantErrContains: "cannot determine whether that was an authorization denial", + }, + "ExistingResourceRejectionIsAValidationError": { + // The #334 reclassification: a cluster rejection of a MODIFICATION is the same fact as one of + // an addition, so it lands in the same exit-code tier rather than depending on whether the + // resource happened to exist already. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunApply(func(context.Context, *un.Unstructured, string) (*un.Unstructured, error) { + return nil, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "existing-resource", + errors.New(`admission webhook "policy.example.org" denied the request`)) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourcesExist(existingResource).Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: modifiedResource, + accessChecker: tu.NewMockAccessChecker().WithAllowed().Build(), + wantErr: true, + wantSchemaValidationErr: true, + wantErrContains: "admission webhook", + }, + "ExistingResourceMissingPatchIsAToolError": { + // The mirror of AdditionForbiddenByRBACDegrades, and deliberately NOT symmetric with it. An + // existing resource's diff depends on the apiserver's merge result for SSA field-removal + // detection, so falling back to rendered output would be wrong rather than merely less + // detailed. It must also not be reported as a rejection of the user's content. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + applyClient := tu.NewMockApplyClient(). + WithDryRunApply(func(context.Context, *un.Unstructured, string) (*un.Unstructured, error) { + return nil, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, "existing-resource", errors.New("nope")) + }). + Build() + + resourceClient := tu.NewMockResourceClient().WithResourcesExist(existingResource).Build() + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, tu.NewMockResourceTreeClient().Build(), resourceManager + }, + composite: nil, + desired: modifiedResource, + accessChecker: tu.NewMockAccessChecker().WithDeniedVerb(dtypes.VerbPatch, "no patch on testresources").Build(), + wantErr: true, + wantSchemaValidationErr: false, + wantErrContains: "requires the 'patch' verb", + }, + "ErrorGettingCurrentObject": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client (not used because test fails earlier) + applyClient := tu.NewMockApplyClient().Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager that returns an error + resourceClient := tu.NewMockResourceClient(). + WithGetResource(func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) { + return nil, errors.New("resource not found") + }). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: existingResource, + wantErr: true, + }, + "DryRunError": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client that returns an error + applyClient := tu.NewMockApplyClient(). + WithFailedDryRun("apply error"). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingResource). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: modifiedResource, + wantErr: true, + }, + "FieldOwnerExtractedFromManagedFields": { + // This test verifies that the Crossplane composed field owner is extracted from + // the existing resource's managedFields and passed to the dry-run apply. + // This is critical for correct field removal detection with Server-Side Apply. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + const expectedFieldOwner = "apiextensions.crossplane.io/composed/abc123def456" + + // Create existing resource WITH managed fields containing Crossplane composed prefix + existingWithManagedFields := tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "old-value"). + WithFieldManagers("kubectl-client-side-apply", expectedFieldOwner, "other-controller"). + Build() + + // Create mock apply client that captures and verifies the field owner + applyClient := tu.NewMockApplyClient(). + WithDryRunApply(func(_ context.Context, obj *un.Unstructured, fieldOwner string) (*un.Unstructured, error) { + // Verify the field owner was correctly extracted + if fieldOwner != expectedFieldOwner { + t.Errorf("DryRunApply called with wrong field owner: got %q, want %q", fieldOwner, expectedFieldOwner) + } + + return obj, nil + }). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingWithManagedFields). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "new-value"). + Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "existing-resource", + DiffType: dt.DiffTypeModified, + }, + }, + "FieldOwnerDefaultsWhenNotInManagedFields": { + // This test verifies that when no Crossplane composed field owner is found + // in the existing resource's managedFields, we use the default field owner. + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create existing resource WITHOUT Crossplane managed fields + existingWithoutCrossplaneManagedFields := tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "old-value"). + WithFieldManagers("kubectl-client-side-apply", "some-other-controller"). + Build() + + // Create mock apply client that captures and verifies the field owner is empty + // (which means the default will be used) + applyClient := tu.NewMockApplyClient(). + WithDryRunApply(func(_ context.Context, obj *un.Unstructured, fieldOwner string) (*un.Unstructured, error) { + // Verify no Crossplane field owner was extracted (defaults to empty) + if fieldOwner != "" { + t.Errorf("DryRunApply called with unexpected field owner: got %q, want empty string", fieldOwner) + } + + return obj, nil + }). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingWithoutCrossplaneManagedFields). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: nil, + desired: tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "new-value"). + Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "TestResource", Group: "example.org", Version: "v1"}, + ResourceName: "existing-resource", + DiffType: dt.DiffTypeModified, + }, + }, + "FindAndDiffResourceWithGenerateName": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // The composed resource with generateName + composedWithGenName := tu.NewResource("example.org/v1", "ComposedResource", ""). + WithLabels(map[string]string{ + "crossplane.io/composite": ParentXRName, + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build() + + // Set generateName instead of name + composedWithGenName.SetGenerateName("test-resource-") + + // The existing resource on the cluster with a generated name + existingComposed := tu.NewResource("example.org/v1", "ComposedResource", "test-resource-abc123"). + WithLabels(map[string]string{ + "crossplane.io/composite": ParentXRName, + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + WithSpecField("field", "old-value"). + Build() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client (not used in this test) + resourceTreeClient := tu.NewMockResourceTreeClient().Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + // Return "not found" for direct name lookup + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + // This should fail as the resource has generateName, not name + if name == "test-resource-abc123" { + return existingComposed, nil + } + + return nil, apierrors.NewNotFound( + schema.GroupResource{ + Group: gvk.Group, + Resource: strings.ToLower(gvk.Kind) + "s", + }, + name, + ) + }). + // Return our existing resource when looking up by label + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + // Verify we're looking up with the right composite owner label + if owner, exists := sel.MatchLabels["crossplane.io/composite"]; exists && owner == ParentXRName { + return []*un.Unstructured{existingComposed}, nil + } + + return []*un.Unstructured{}, nil + }). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + composite: parentXR, + desired: tu.NewResource("example.org/v1", "ComposedResource", ""). + WithLabels(map[string]string{ + "crossplane.io/composite": ParentXRName, + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + WithSpecField("field", "new-value"). + WithGenerateName("test-resource-"). + Build(), + wantDiff: &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Kind: "ComposedResource", Group: "example.org", Version: "v1"}, + ResourceName: "test-resource-abc123", // Should have found the existing resource name + DiffType: dt.DiffTypeModified, // Should be modified, not added + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + // Setup mocks + applyClient, resourceTreeClient, resourceManager := tt.setupMocks(t) + + accessChecker := tt.accessChecker + if accessChecker == nil { + accessChecker = tu.NewMockAccessChecker().Build() + } + + // Setup the diff calculator with the mocks + calculator := NewDiffCalculator( + applyClient, + accessChecker, + resourceTreeClient, + resourceManager, + logger, + renderer.DefaultDiffOptions(), + tt.dryRunOn, + markerDefaulterStoringAt(tt.storedAt), + ) + + // Call the function under test + diff, err := calculator.CalculateDiff(ctx, tt.composite, tt.desired) + + // Check error condition + if tt.wantErr { + if err == nil { + t.Fatalf("CalculateDiff() expected error but got none") + } + + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("CalculateDiff() error %q does not contain %q", err, tt.wantErrContains) + } + + if got := IsSchemaValidationError(err); got != tt.wantSchemaValidationErr { + t.Errorf("IsSchemaValidationError() = %v, want %v; a cluster rejection must land in the schema-validation exit-code tier and a tool failure must not: %v", + got, tt.wantSchemaValidationErr, err) + } + + return + } + + if err != nil { + t.Fatalf("CalculateDiff() unexpected error: %v", err) + } + + // Check nil diff case + if tt.wantNil { + if diff != nil { + t.Errorf("CalculateDiff() expected nil diff but got: %v", diff) + } + + return + } + + // Check non-nil case + if diff == nil { + t.Fatalf("CalculateDiff() returned nil diff, expected non-nil") + } + + // Check the basics of the diff + if diff := gcmp.Diff(tt.wantDiff.Gvk, diff.Gvk); diff != "" { + t.Errorf("Gvk mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.wantDiff.ResourceName, diff.ResourceName); diff != "" { + t.Errorf("ResourceName mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.wantDiff.DiffType, diff.DiffType); diff != "" { + t.Errorf("DiffType mismatch (-want +got):\n%s", diff) + } + + // For modified resources, check that LineDiffs is populated + if diff.DiffType == dt.DiffTypeModified && len(diff.LineDiffs) == 0 { + t.Errorf("LineDiffs is empty for %s", name) + } + + // Nil wantDryRun asserts absence, which is the positive claim that the desired state was + // verified against the apiserver — not merely "we didn't check". + if d := gcmp.Diff(tt.wantDryRun, diff.DryRun); d != "" { + t.Errorf("DryRun mismatch (-want +got):\n%s", d) + } + + if tt.wantControllerOf != "" { + if diff.Desired.Raw == nil { + t.Fatalf("expected a desired view carrying the controller reference, got no desired view at all") + } + + switch ref := metav1.GetControllerOf(diff.Desired.Raw); { + case ref == nil: + t.Errorf("desired side has no controller reference: the dry-run round trip dropped the rendered one") + case ref.Name != tt.wantControllerOf: + t.Errorf("desired side's controller = %q, want %q", ref.Name, tt.wantControllerOf) + } + } + + if diff.Desired.Raw != nil { + _, got, _ := un.NestedString(diff.Desired.Raw.Object, "spec", locallyDefaultedField) + if got != tt.wantLocallyDefaulted { + t.Errorf("desired side carries the locally defaulted field: %v, want %v", got, tt.wantLocallyDefaulted) + } + } + + if tt.wantServerField != "" { + if diff.Desired.Raw == nil { + t.Fatalf("expected the apiserver's object on the desired side, got no desired view at all") + } + + got, found, err := un.NestedString(diff.Desired.Raw.Object, "spec", "defaultedField") + switch { + case err != nil: + t.Errorf("spec.defaultedField: %v", err) + case !found: + t.Errorf("spec.defaultedField absent from the desired side: the apiserver's response was discarded, so the addition diff shows only rendered output") + case got != tt.wantServerField: + t.Errorf("spec.defaultedField = %q, want %q", got, tt.wantServerField) + } + } + }) + } +} + +// locallyDefaultedField is the spec field markerDefaulter adds. +const locallyDefaultedField = "locallyDefaulted" + +// markerDefaulterStoringAt is a Defaulter that adds spec.locallyDefaulted, so a case can tell whether +// a diff's desired side was locally defaulted, and reports storageVersion as the version, other than +// the requested one, that every resource's CRD stores. +func markerDefaulterStoringAt(storageVersion string) Defaulter { + return &tu.MockDefaulter{DefaultFn: func(_ context.Context, obj *un.Unstructured) (*un.Unstructured, string, error) { + out := obj.DeepCopy() + err := un.SetNestedField(out.Object, "predicted", "spec", locallyDefaultedField) + + return out, storageVersion, err + }} +} + +func TestDefaultDiffCalculator_CalculateDiffs(t *testing.T) { + ctx := t.Context() + + // Create test XR + modifiedXr := tu.NewResource("example.org/v1", "XR", "test-xr"). + WithSpecField("field", "new-value"). + BuildUComposite() + + // Create test rendered resources + renderedXR := tu.NewResource("example.org/v1", "XR", "test-xr"). + BuildUComposite() + + // Create rendered composed resources + composedResource1 := tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + WithSpecField("field", "new-value"). + BuildUComposed() + + // Create existing resources for the client to find + existingXRBuilder := tu.NewResource("example.org/v1", "XR", "test-xr"). + WithUID("xr-uid"). + WithSpecField("field", "old-value") + existingXR := existingXRBuilder.Build() + existingXrUComp := existingXRBuilder.BuildUComposite() + + existingComposed := tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + ControlledBy(existingXR). + WithSpecField("field", "old-value"). + Build() + + // cpd-1 as the XR's resourceRefs (and so its resource tree) name it after it was composed at v1beta1, + // before a revision moved it to v1beta2. The apiserver serves the one object at both versions. + composedAt := func(apiVersion, value string) *tu.ResourceBuilder { + return tu.NewResource(apiVersion, "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + WithSpecField("field", value) + } + observedAtV1beta1 := composedAt("example.org/v1beta1", "old-value").ControlledBy(existingXR).Build() + existingAtV1beta2 := composedAt("example.org/v1beta2", "old-value").ControlledBy(existingXR).Build() + + // apiVersionBump sets up the cluster for a render that moves cpd-1 from v1beta1 to v1beta2. + apiVersionBump := func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, observedAtV1beta1, existingAtV1beta2). + WithResourcesFoundByLabel([]*un.Unstructured{existingAtV1beta2}, "crossplane.io/composite", "test-xr"). + Build() + + return tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + tu.NewMockResourceTreeClient().WithResourceTreeFromXRAndComposed(existingXR, []*un.Unstructured{observedAtV1beta1}).Build(), + NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + } + + tests := map[string]struct { + setupMocks func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) + inputXR *cmp.Unstructured + renderedOut render.CompositionOutputs + expectedDiffs map[string]dt.DiffType // Map of expected keys and their diff types + wantErr bool + wantErrSubstr string // when set, the error must contain it + }{ + "XRAndComposedResourceModifications": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client + resourceTreeClient := tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed). + WithResourcesFoundByLabel([]*un.Unstructured{existingComposed}, "crossplane.io/composite", "test-xr"). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + ComposedResources: []cpd.Unstructured{*composedResource1}, + }, + expectedDiffs: map[string]dt.DiffType{ + "example.org/v1/XR//test-xr": dt.DiffTypeModified, + "example.org/v1/Composed//cpd-1": dt.DiffTypeModified, + }, + wantErr: false, + }, + "XRNotModifiedComposedResourceModified": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client + resourceTreeClient := tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed). + WithResourcesFoundByLabel([]*un.Unstructured{existingComposed}, "crossplane.io/composite", "test-xr"). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + inputXR: existingXrUComp, + renderedOut: render.CompositionOutputs{ + CompositeResource: func() *cmp.Unstructured { + // Create XR with same values (no changes) + sameXR := &cmp.Unstructured{} + sameXR.SetUnstructuredContent(existingXR.UnstructuredContent()) + + return sameXR + }(), + ComposedResources: []cpd.Unstructured{*composedResource1}, + }, + // XR diff is always stored (even when DiffTypeEqual) for removal detection + expectedDiffs: map[string]dt.DiffType{ + "example.org/v1/Composed//cpd-1": dt.DiffTypeModified, + "example.org/v1/XR//test-xr": dt.DiffTypeEqual, + }, + wantErr: false, + }, + "ErrorCalculatingDiff": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create mock apply client that returns an error + applyClient := tu.NewMockApplyClient(). + WithFailedDryRun("dry run error"). + Build() + + // Create mock resource tree client + resourceTreeClient := tu.NewMockResourceTreeClient(). + Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + inputXR: existingXrUComp, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + ComposedResources: []cpd.Unstructured{*composedResource1}, + }, + expectedDiffs: map[string]dt.DiffType{}, + wantErr: true, + }, + "ResourceTreeWithPotentialRemoval": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create a resource that isn't in the rendered output + extraComposedResource := tu.NewResource("example.org/v1", "Composed", "cpd-2"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-to-be-removed"). + ControlledBy(existingXR). + WithSpecField("field", "value"). + Build() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client with the XR as root and some composed resources as children + resourceTreeClient := tu.NewMockResourceTreeClient(). + WithResourceTreeFromXRAndComposed(existingXR, []*un.Unstructured{ + existingComposed, + extraComposedResource, + }). + Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed, extraComposedResource). + WithResourcesFoundByLabel([]*un.Unstructured{existingComposed}, "crossplane.io/composite", "test-xr"). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + ComposedResources: []cpd.Unstructured{*composedResource1}, + }, + expectedDiffs: map[string]dt.DiffType{ + "example.org/v1/XR//test-xr": dt.DiffTypeModified, + "example.org/v1/Composed//cpd-1": dt.DiffTypeModified, + "example.org/v1/Composed//cpd-2": dt.DiffTypeRemoved, + }, + wantErr: false, + }, + "ResourceRemovalDetection": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + // Create existing version of the resource + existingComposedWithOldValue := tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + ControlledBy(existingXR). + WithSpecField("field", "old-value"). + Build() + + // Create an extra resource that should be removed + extraResource := tu.NewResource("example.org/v1", "Composed", "resource-to-remove"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-to-remove"). + ControlledBy(existingXR). + Build() + + // Create mock apply client + applyClient := tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build() + + // Create mock resource tree client + resourceTreeClient := tu.NewMockResourceTreeClient(). + WithResourceTreeFromXRAndComposed( + existingXR, + []*un.Unstructured{existingComposedWithOldValue, extraResource}, + ). + Build() + + // Create mock resource client for resource manager + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposedWithOldValue, extraResource). + WithResourcesFoundByLabel( + []*un.Unstructured{existingComposedWithOldValue, extraResource}, + "crossplane.io/composite", + "test-xr", + ). + Build() + + // Create resource manager + resourceManager := NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + return applyClient, resourceTreeClient, resourceManager + }, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + // Include a modified version of composedResource1 with new value + ComposedResources: []cpd.Unstructured{*tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + WithSpecField("field", "new-value"). // Different value than existing + BuildUComposed()}, + }, + expectedDiffs: map[string]dt.DiffType{ + "example.org/v1/XR//test-xr": dt.DiffTypeModified, + "example.org/v1/Composed//cpd-1": dt.DiffTypeModified, + "example.org/v1/Composed//resource-to-remove": dt.DiffTypeRemoved, + }, + wantErr: false, + }, + // #552: Crossplane matches what a composite composed to what it renders by composition resource + // name, so moving a resource to a new API version updates it in place. The version it was composed + // at, which the XR's resourceRefs still name, must not be reported as removed. + "APIVersionBumpAloneIsNoChange": { + setupMocks: apiVersionBump, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + ComposedResources: []cpd.Unstructured{*composedAt("example.org/v1beta2", "old-value").BuildUComposed()}, + }, + expectedDiffs: map[string]dt.DiffType{ + "example.org/v1/XR//test-xr": dt.DiffTypeModified, + }, + }, + "APIVersionBumpWithFieldChangeIsOneModification": { + setupMocks: apiVersionBump, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + ComposedResources: []cpd.Unstructured{*composedAt("example.org/v1beta2", "new-value").BuildUComposed()}, + }, + // One diff for the object, at the version it is rendered at: no removal at the old version to + // contradict it. + expectedDiffs: map[string]dt.DiffType{ + "example.org/v1/XR//test-xr": dt.DiffTypeModified, + "example.org/v1beta2/Composed//cpd-1": dt.DiffTypeModified, + }, + }, + "SameObjectUnderTwoResourceNamesFails": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed). + WithResourcesFoundByLabel([]*un.Unstructured{existingComposed}, "crossplane.io/composite", "test-xr"). + Build() + + return tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + }, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + // Crossplane applies both, in random order, so no single diff predicts the outcome. + ComposedResources: []cpd.Unstructured{ + *composedResource1, + *tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-2"). + WithSpecField("field", "other-value"). + BuildUComposed(), + }, + }, + wantErr: true, + wantErrSubstr: `the composition renders the same object twice, as Composed/cpd-1 (apiVersion example.org/v1, ` + + `composition resource name "resource-1") and as Composed/cpd-1 (apiVersion example.org/v1, ` + + `composition resource name "resource-2")`, + }, + "SameObjectAtTwoAPIVersionsFails": { + setupMocks: func(t *testing.T) (k8.ApplyClient, xp.ResourceTreeClient, ResourceManager) { + t.Helper() + + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed). + WithResourcesFoundByLabel([]*un.Unstructured{existingComposed}, "crossplane.io/composite", "test-xr"). + Build() + + return tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + }, + inputXR: modifiedXr, + renderedOut: render.CompositionOutputs{ + CompositeResource: renderedXR, + // Served versions are views of one stored object, so these are the same resource + // despite their different diff keys. + ComposedResources: []cpd.Unstructured{ + *composedResource1, + *tu.NewResource("example.org/v1beta1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-2"). + WithSpecField("field", "new-value"). + BuildUComposed(), + }, + }, + wantErr: true, + wantErrSubstr: `the composition renders the same object twice, as Composed/cpd-1 (apiVersion example.org/v1, ` + + `composition resource name "resource-1") and as Composed/cpd-1 (apiVersion example.org/v1beta1, ` + + `composition resource name "resource-2")`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + // Setup mocks + applyClient, resourceTreeClient, resourceManager := tt.setupMocks(t) + + // Create a diff calculator with default options + calculator := NewDiffCalculator( + applyClient, + tu.NewMockAccessChecker().Build(), + resourceTreeClient, + resourceManager, + logger, + renderer.DefaultDiffOptions(), + DryRunOnAll, + &tu.MockDefaulter{}, + ) + + // Call the function under test + // Use CalculateDiffs which includes removal detection + diffs, err := calculator.CalculateDiffs(ctx, tt.inputXR, tt.renderedOut) + + // Check error condition + if tt.wantErr { + if err == nil { + t.Errorf("CalculateDiffs() expected error but got none") + } else if !strings.Contains(err.Error(), tt.wantErrSubstr) { + t.Errorf("CalculateDiffs() error %q does not contain %q", err, tt.wantErrSubstr) + } + + return + } + + if err != nil { + t.Fatalf("CalculateDiffs() unexpected error: %v", err) + } + + // Check that we have the expected number of diffs + if diff := gcmp.Diff(len(tt.expectedDiffs), len(diffs)); diff != "" { + t.Errorf("CalculateDiffs() number of diffs mismatch (-want +got):\n%s", diff) + + // Print what diffs we actually got to help debug + for key, diff := range diffs { + t.Logf("Found diff: %s of type %s", key, diff.DiffType) + } + } + + // Check each expected diff + for expectedKey, expectedType := range tt.expectedDiffs { + diff, found := diffs[expectedKey] + if !found { + t.Errorf("CalculateDiffs() missing expected diff for key %s", expectedKey) + continue + } + + if diff := gcmp.Diff(expectedType, diff.DiffType); diff != "" { + t.Errorf("CalculateDiffs() diff type for key %s mismatch (-want +got):\n%s", expectedKey, diff) + } + + // Check that LineDiffs is not empty for non-equal diffs + // DiffTypeEqual entries may have empty LineDiffs since there are no changes + if diff.DiffType != dt.DiffTypeEqual && len(diff.LineDiffs) == 0 { + t.Errorf("CalculateDiffs() returned diff with empty LineDiffs for key %s", expectedKey) + } + } + + // Check for unexpected diffs + for key := range diffs { + if _, expected := tt.expectedDiffs[key]; !expected { + t.Errorf("CalculateDiffs() returned unexpected diff for key %s", key) + } + } + }) + } +} + +func TestDefaultDiffCalculator_CalculateRemovedResourceDiffs(t *testing.T) { + ctx := t.Context() + + // The XR as the cluster holds it: removal detection reads it from the resource tree, UID and all. + xr := tu.NewResource("example.org/v1", "XR", "test-xr"). + WithUID("xr-uid"). + Build() + + // composedBy is a resource owner composed under the given composition resource name, as Crossplane leaves + // it: controlled by owner, labelled with the root composite, and (by being in the tree) in owner's + // resourceRefs. + composedBy := func(owner *un.Unstructured, apiVersion, name, resourceName string) *tu.ResourceBuilder { + return tu.NewResource(apiVersion, "Composed", name). + WithCompositeOwner(xr.GetName()). + WithCompositionResourceName(resourceName). + ControlledBy(owner) + } + + resourceToKeep := composedBy(xr, "example.org/v1", "resource-to-keep", "keep").Build() + resourceToRemove := composedBy(xr, "example.org/v1", "resource-to-remove", "remove").Build() + + // A nested XR, and a resource it composed under a composition resource name its parent also uses. + nestedXR := tu.NewResource("example.org/v1", "XNested", "nested-xr"). + WithUID("nested-uid"). + WithCompositeOwner(xr.GetName()). + WithCompositionResourceName("nested"). + ControlledBy(xr). + Build() + nestedChild := composedBy(nestedXR, "example.org/v1", "nested-child", "keep").Build() + + // renderOf is one composite's render: the composite, and the composed resources it desired. + type renderOf struct { + composite *un.Unstructured + desired []*un.Unstructured + } + + // rendered records renders as CalculateNonRemovalDiffs and ProcessNestedXRs do. + rendered := func(renders ...renderOf) dtypes.RenderedResources { + var r dtypes.RenderedResources + + for _, rd := range renders { + for _, d := range rd.desired { + r.AddDesired(identityOf(rd.composite), d.GetAnnotations()["crossplane.io/composition-resource-name"]) + r.AddKey(dt.MakeDiffKeyFromResource(d)) + } + } + + return r + } + + tree := func(root *un.Unstructured, children ...*tu.TreeNodeBuilder) xp.ResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(context.Context, *un.Unstructured) (*resource.Resource, error) { + return tu.NewTreeNode(root).WithChildren(children...).Build(), nil + }). + Build() + } + + type want struct { + Removed []string // diff keys, sorted + Err string // a substring of the error; empty for none + } + + tests := map[string]struct { + reason string + tree xp.ResourceTreeClient + rendered dtypes.RenderedResources + want want + }{ + "IdentifiesRemovedResources": { + reason: "A resource whose composition resource name the XR no longer desires is removed.", + tree: tree(xr, tu.NewTreeNode(resourceToKeep), tu.NewTreeNode(resourceToRemove)), + rendered: rendered(renderOf{xr, []*un.Unstructured{resourceToKeep}}), + want: want{Removed: []string{"example.org/v1/Composed//resource-to-remove"}}, + }, + "NoRemovedResources": { + reason: "Nothing is removed while the XR desires every composition resource name it composed.", + tree: tree(xr, tu.NewTreeNode(resourceToKeep), tu.NewTreeNode(resourceToRemove)), + rendered: rendered(renderOf{xr, []*un.Unstructured{resourceToKeep, resourceToRemove}}), + want: want{}, + }, + "APIVersionChangeIsNotRemoval": { + reason: "Crossplane matches what it observed to what is desired by composition resource name, so a " + + "resource rendered at a new API version is updated in place, not deleted (#552).", + tree: tree(xr, tu.NewTreeNode(composedBy(xr, "example.org/v1beta1", "migrating", "migrating").Build())), + rendered: rendered(renderOf{xr, []*un.Unstructured{ + composedBy(xr, "example.org/v1beta2", "migrating", "migrating").Build(), + }}), + want: want{}, + }, + "ObjectRenamedUnderSameResourceNameIsNotRemoval": { + reason: "What Crossplane matches is the composition resource name; it applies the desired resource " + + "under the observed one's name, so nothing is deleted.", + tree: tree(xr, tu.NewTreeNode(resourceToKeep)), + rendered: rendered(renderOf{xr, []*un.Unstructured{ + composedBy(xr, "example.org/v1", "resource-renamed", "keep").Build(), + }}), + want: want{}, + }, + "ResourceNameDesiredOnlyByAnotherComposite": { + reason: "Composition resource names are unique only within a composite, so the root desiring \"keep\" " + + "does not keep the nested XR's \"keep\" once the nested XR no longer desires it.", + tree: tree(xr, tu.NewTreeNode(resourceToKeep), tu.NewTreeNode(nestedXR).WithChildren(tu.NewTreeNode(nestedChild))), + rendered: rendered( + renderOf{xr, []*un.Unstructured{resourceToKeep, nestedXR}}, + renderOf{nestedXR, nil}, + ), + want: want{Removed: []string{"example.org/v1/Composed//nested-child"}}, + }, + "NestedXRKeepsWhatItDesires": { + reason: "A nested XR's composed resources are matched against the nested XR's own render.", + tree: tree(xr, tu.NewTreeNode(resourceToKeep), tu.NewTreeNode(nestedXR).WithChildren(tu.NewTreeNode(nestedChild))), + rendered: rendered( + renderOf{xr, []*un.Unstructured{resourceToKeep, nestedXR}}, + renderOf{nestedXR, []*un.Unstructured{nestedChild}}, + ), + want: want{}, + }, + "RemovedNestedXRTakesItsResourcesWithIt": { + reason: "A nested XR the root no longer desires is removed, and with it everything it composed.", + tree: tree(xr, tu.NewTreeNode(resourceToKeep), tu.NewTreeNode(nestedXR).WithChildren(tu.NewTreeNode(nestedChild))), + rendered: rendered(renderOf{xr, []*un.Unstructured{resourceToKeep}}), + want: want{Removed: []string{ + "example.org/v1/Composed//nested-child", + "example.org/v1/XNested//nested-xr", + }}, + }, + "ClaimRootMatchesAgainstItsBackingXR": { + reason: "A claim's tree reaches the composed resources through its backing XR, which composed them.", + tree: tree( + tu.NewResource("example.org/v1", "Claim", "test-claim").InNamespace("ns").Build(), + tu.NewTreeNode(xr).WithChildren(tu.NewTreeNode(resourceToKeep), tu.NewTreeNode(resourceToRemove)), + ), + rendered: rendered(renderOf{xr, []*un.Unstructured{resourceToKeep}}), + want: want{Removed: []string{"example.org/v1/Composed//resource-to-remove"}}, + }, + "UncontrolledResourceIsNotRemoved": { + reason: "Crossplane never garbage collects a resource with no controller reference, since it cannot " + + "prove it composed it.", + tree: tree(xr, tu.NewTreeNode(tu.NewResource("example.org/v1", "Composed", "uncontrolled"). + WithCompositeOwner(xr.GetName()). + WithCompositionResourceName("remove"). + Build())), + rendered: rendered(renderOf{xr, nil}), + want: want{}, + }, + "ResourceControlledByAnotherIsNotRemoved": { + reason: "Crossplane does not observe a resource another controller owns, so never garbage collects it.", + tree: tree(xr, tu.NewTreeNode(composedBy( + tu.NewResource("example.org/v1", "XR", "other-xr").WithUID("other-uid").Build(), + "example.org/v1", "foreign", "remove").Build())), + rendered: rendered(renderOf{xr, nil}), + want: want{}, + }, + "ObjectRenderedAgainUnderAnotherResourceNameFails": { + reason: "Crossplane deletes what it composed as \"remove\" before it applies the object again as " + + "\"other\", so it would delete and recreate it. No single diff for the object predicts that.", + tree: tree(xr, tu.NewTreeNode(resourceToRemove)), + rendered: rendered(renderOf{xr, []*un.Unstructured{ + composedBy(xr, "example.org/v1", "resource-to-remove", "other").Build(), + }}), + want: want{Err: `XR/test-xr composed Composed/resource-to-remove as "remove", which it no longer ` + + `renders, but it renders the same object again under another composition resource name`}, + }, + "ErrorGettingResourceTree": { + reason: "A tree that cannot be fetched fails removal detection.", + tree: tu.NewMockResourceTreeClient(). + WithFailedResourceTreeFetch("failed to get resource tree"). + Build(), + want: want{Err: "cannot get resource tree"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + + calculator := NewDiffCalculator( + tu.NewMockApplyClient().Build(), + tu.NewMockAccessChecker().Build(), + tt.tree, + NewResourceManager(tu.NewMockResourceClient().Build(), tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), logger, NewAdvisoryCollector(nil)), + logger, + renderer.DefaultDiffOptions(), + DryRunOnAll, + &tu.MockDefaulter{}, + ) + + diffs, err := calculator.CalculateRemovedResourceDiffs(ctx, xr, tt.rendered) + + var got want + + if err != nil { + got.Err = err.Error() + if tt.want.Err != "" && strings.Contains(got.Err, tt.want.Err) { + got.Err = tt.want.Err // matched as a substring + } + } + + for key, d := range diffs { + if d.DiffType != dt.DiffTypeRemoved { + t.Errorf("%s\nCalculateRemovedResourceDiffs(): %s has diff type %s, want %s", tt.reason, key, d.DiffType, dt.DiffTypeRemoved) + } + + got.Removed = append(got.Removed, key) + } + + sort.Strings(got.Removed) + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\nCalculateRemovedResourceDiffs(): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultDiffCalculator_preserveCompositeLabel(t *testing.T) { + tests := []struct { + name string + current *un.Unstructured + desired *un.Unstructured + expectedLabel string + expectLabelExists bool + }{ + { + name: "PreservesCompositeLabelFromExistingResourceWithFullName", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "labels": map[string]any{ + "crossplane.io/composite": "root-xr-name", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "composed-resource", + }, + "labels": map[string]any{ + "crossplane.io/composite": "child-xr-name", + }, + }, + }, + }, + expectedLabel: "root-xr-name", + expectLabelExists: true, + }, + { + name: "PreservesCompositeLabelFromExistingResourceWithGenerateName", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "generateName": "existing-resource-", + "name": "existing-resource-abc123", + "labels": map[string]any{ + "crossplane.io/composite": "root-xr-name", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "generateName": "existing-resource-", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "composed-resource", + }, + "labels": map[string]any{ + "crossplane.io/composite": "child-xr-name", + }, + }, + }, + }, + expectedLabel: "root-xr-name", + expectLabelExists: true, + }, + { + name: "NoPreservationWhenCurrentIsNil", + current: nil, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "new-resource", + "labels": map[string]any{ + "crossplane.io/composite": "child-xr-name", + }, + }, + }, + }, + expectedLabel: "child-xr-name", + expectLabelExists: true, + }, + { + name: "NoPreservationWhenCurrentHasNoCompositeLabel", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "labels": map[string]any{ + "some-other-label": "value", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "labels": map[string]any{ + "crossplane.io/composite": "child-xr-name", + }, + }, + }, + }, + expectedLabel: "child-xr-name", + expectLabelExists: true, + }, + { + name: "NoPreservationWhenCurrentHasNoLabels", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "labels": map[string]any{ + "crossplane.io/composite": "child-xr-name", + }, + }, + }, + }, + expectedLabel: "child-xr-name", + expectLabelExists: true, + }, + { + name: "CreatesLabelsMapWhenDesiredHasNoLabels", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "labels": map[string]any{ + "crossplane.io/composite": "root-xr-name", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "composed-resource", + }, + }, + }, + }, + expectedLabel: "root-xr-name", + expectLabelExists: true, + }, + { + name: "PreservesOtherLabelsOnDesiredResource", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "labels": map[string]any{ + "crossplane.io/composite": "root-xr-name", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "existing-resource", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "nop-resource", + }, + "labels": map[string]any{ + "crossplane.io/composite": "child-xr-name", + "custom-label": "custom-value", + "another-label": "another-value", + }, + }, + }, + }, + expectedLabel: "root-xr-name", + expectLabelExists: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a DiffCalculator instance + logger := tu.TestLogger(t, false) + calc := &DefaultDiffCalculator{ + logger: logger, + } + + // Call the function + result := calc.preserveCompositeLabel(tt.current, tt.desired, "test-resource") + + // Verify the result + labels := result.GetLabels() + if tt.expectLabelExists { + if labels == nil { + t.Fatal("Expected labels map to exist, but it was nil") + } + + actualLabel, exists := labels["crossplane.io/composite"] + if !exists { + t.Fatal("Expected crossplane.io/composite label to exist, but it did not") + } + + if actualLabel != tt.expectedLabel { + t.Errorf("Expected composite label to be %q, got %q", tt.expectedLabel, actualLabel) + } + + // Verify that result is a deep copy only when we actually preserved a label + // (i.e., when current had a composite label to preserve) + if tt.current != nil && tt.current.GetLabels() != nil { + if _, hasCompositeLabel := tt.current.GetLabels()["crossplane.io/composite"]; hasCompositeLabel { + if result == tt.desired { + t.Error("Expected result to be a deep copy when preserving label, but got the same pointer") + } + } + } + + // Verify other labels are preserved + if tt.name == "PreservesOtherLabelsOnDesiredResource" { + if labels["custom-label"] != "custom-value" { + t.Errorf("Expected custom-label to be preserved as 'custom-value', got %q", labels["custom-label"]) + } + + if labels["another-label"] != "another-value" { + t.Errorf("Expected another-label to be preserved as 'another-value', got %q", labels["another-label"]) + } + } + } + }) + } +} + +// TestDefaultDiffCalculator_preserveExistingResourceIdentity tests that the identity +// of existing resources is preserved correctly, especially for nested XRs where +// the composition may transform the name but not use generateName. +func TestDefaultDiffCalculator_preserveExistingResourceIdentity(t *testing.T) { + tests := []struct { + name string + current *un.Unstructured + desired *un.Unstructured + renderedName string + expectedName string + expectedGenerateName string + expectPreserved bool // whether identity should be preserved (deep copy made) + }{ + { + name: "PreservesIdentityWhenNameSetButGenerateNameEmpty", + // This is the key scenario fixed: nested XRs where composition transforms + // the name (e.g., adds -sg suffix) but doesn't use generateName + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "platform.example.org/v1alpha1", + "kind": "XSecurityGroup", + "metadata": map[string]any{ + "name": "dub-glass-redis-sg", // Actual cluster name with suffix + // NOTE: generateName is intentionally NOT set (null/empty) + "labels": map[string]any{ + "crossplane.io/composite": "root-xr", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "platform.example.org/v1alpha1", + "kind": "XSecurityGroup", + "metadata": map[string]any{ + "name": "dub-glass-redis", // Rendered name without suffix + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "security-group", + }, + }, + }, + }, + renderedName: "dub-glass-redis", + expectedName: "dub-glass-redis-sg", // Should preserve cluster name + expectedGenerateName: "", // Should preserve empty generateName + expectPreserved: true, + }, + { + name: "PreservesIdentityWithGenerateName", + // Traditional generateName scenario - should still work + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "my-resource-abc123", + "generateName": "my-resource-", + "labels": map[string]any{ + "crossplane.io/composite": "root-xr", + }, + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "generateName": "my-resource-", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "nop-resource", + }, + }, + }, + }, + renderedName: "", + expectedName: "my-resource-abc123", + expectedGenerateName: "my-resource-", + expectPreserved: true, + }, + { + name: "NoPreservationWhenCurrentIsNil", + current: nil, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "name": "new-resource", + }, + }, + }, + renderedName: "new-resource", + expectedName: "new-resource", + expectedGenerateName: "", + expectPreserved: false, + }, + { + name: "NoPreservationWhenCurrentHasNoName", + current: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "generateName": "my-resource-", + // No name set yet (resource not created) + }, + }, + }, + desired: &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "nop.crossplane.io/v1alpha1", + "kind": "NopResource", + "metadata": map[string]any{ + "generateName": "my-resource-", + }, + }, + }, + renderedName: "", + expectedName: "", + expectedGenerateName: "my-resource-", + expectPreserved: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create the calculator + calculator := &DefaultDiffCalculator{ + logger: tu.TestLogger(t, false), + } + + // Call the method + result := calculator.preserveExistingResourceIdentity(tt.current, tt.desired, "test-resource", tt.renderedName) + + // Check the resulting name + if result.GetName() != tt.expectedName { + t.Errorf("Expected name %q, got %q", tt.expectedName, result.GetName()) + } + + // Check the resulting generateName + if result.GetGenerateName() != tt.expectedGenerateName { + t.Errorf("Expected generateName %q, got %q", tt.expectedGenerateName, result.GetGenerateName()) + } + + // Check if a deep copy was made when expected + if tt.expectPreserved { + if result == tt.desired { + t.Error("Expected result to be a deep copy when preserving identity, but got the same pointer") + } + } else { + if result != tt.desired { + t.Error("Expected result to be the same pointer when not preserving identity, but got a different pointer") + } + } + }) + } +} + +// TestDefaultDiffCalculator_DegradedDryRunDetail covers what a degraded dry run records on the diff, +// which is everything the renderer's summary of unverified additions is built from: one warning per +// GVK + namespace + distinct cause, counting its resources (see renderer.dryRunWarnings). +// +// For that grouping to work, dryRun.detail must be the cause and nothing else. The mock wraps its error +// exactly as DefaultApplyClient.DryRunCreate does, naming the resource, because that wrapper is what +// made every resource's cause unique when err.Error() was used. +// +// It also pins that no advisory is raised along the way (the calculator has no advisory channel, but +// its ResourceManager does), so a regression which warned per resource again, duplicating the +// renderer's summary, is caught. +func TestDefaultDiffCalculator_DegradedDryRunDetail(t *testing.T) { + // wrapLikeApplyClient reproduces DefaultApplyClient.DryRunCreate's wrapping, resource name included. + wrapLikeApplyClient := func(obj *un.Unstructured, err error) error { + return errors.Wrapf(err, "failed to dry-run create resource %s/%s", obj.GetKind(), obj.GetName()) + } + + resource := func(kind, name, namespace string) *un.Unstructured { + r := tu.NewResource("example.org/v1", kind, name).WithSpecField("f", name).Build() + r.SetNamespace(namespace) + + return r + } + + tests := map[string]struct { + reason string + desired []*un.Unstructured + dryRunCreate func(context.Context, *un.Unstructured) (*un.Unstructured, error) + accessChecker k8.AccessChecker + // wantReason is every resource's dryRun.skipReason. + wantReason dt.DryRunSkipReason + // wantDetails is each resource's dryRun.detail, in input order. It must carry the cause and no + // resource name, or the summary derived from it would split per resource. + wantDetails []string + }{ + "ForbiddenCarriesTheAuthorizerReason": { + reason: "Denied additions across several GVK+namespace groups each carry the authorizer's reason, not the 403.", + desired: []*un.Unstructured{ + resource("TestResource", "a", "ns-a"), + resource("TestResource", "b", "ns-a"), + resource("TestResource", "c", "ns-b"), + resource("OtherResource", "d", "ns-a"), + }, + dryRunCreate: func(_ context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + return nil, wrapLikeApplyClient(obj, apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "testresources"}, obj.GetName(), errors.New("nope"))) + }, + accessChecker: tu.NewMockAccessChecker().WithDenied("no create").Build(), + wantReason: dt.DryRunSkipForbidden, + wantDetails: []string{"no create", "no create", "no create", "no create"}, + }, + "ResourceNamedWrapperDoesNotLeakIntoTheCause": { + reason: "The cause is the apiserver's own message, not the wrapped error that names each resource. With err.Error() as the cause, three resources in one namespace would be summarised as three warnings.", + desired: []*un.Unstructured{ + resource("TestResource", "a", "ns-a"), + resource("TestResource", "b", "ns-a"), + resource("TestResource", "c", "ns-a"), + }, + dryRunCreate: func(_ context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + return nil, wrapLikeApplyClient(obj, apierrors.NewNotFound(schema.GroupResource{Resource: "namespaces"}, obj.GetNamespace())) + }, + wantReason: dt.DryRunSkipNamespaceNotFound, + wantDetails: []string{`namespaces "ns-a" not found`, `namespaces "ns-a" not found`, `namespaces "ns-a" not found`}, + }, + "DistinctCausesAreAllKept": { + reason: "Two different apiserver causes in one GVK+namespace group are recorded as two causes, so the summary can keep both rather than whichever came first.", + desired: []*un.Unstructured{ + resource("TestResource", "a", "ns-a"), + resource("TestResource", "b", "ns-a"), + resource("TestResource", "c", "ns-a"), + }, + dryRunCreate: func(_ context.Context, obj *un.Unstructured) (*un.Unstructured, error) { + webhook := "policy.example.org" + if obj.GetName() == "b" { + webhook = "quota.example.org" + } + + return nil, wrapLikeApplyClient(obj, apierrors.NewInternalError(errors.Errorf("failed calling webhook %q: connection refused", webhook))) + }, + wantReason: dt.DryRunSkipWebhookUnavailable, + wantDetails: []string{ + `Internal error occurred: failed calling webhook "policy.example.org": connection refused`, + `Internal error occurred: failed calling webhook "quota.example.org": connection refused`, + `Internal error occurred: failed calling webhook "policy.example.org": connection refused`, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + advisories := NewAdvisoryCollector(nil) + + accessChecker := tc.accessChecker + if accessChecker == nil { + accessChecker = tu.NewMockAccessChecker().Build() + } + + calculator := NewDiffCalculator( + tu.NewMockApplyClient().WithDryRunCreate(tc.dryRunCreate).Build(), + accessChecker, + tu.NewMockResourceTreeClient().Build(), + NewResourceManager( + tu.NewMockResourceClient().WithResourceNotFound().Build(), + tu.NewMockDefinitionClient().Build(), + tu.NewMockResourceTreeClient().Build(), + tu.TestLogger(t, false), advisories), + tu.TestLogger(t, false), + renderer.DefaultDiffOptions(), + DryRunOnAll, + &tu.MockDefaulter{}, + ) + + gotDetails := make([]string, 0, len(tc.desired)) + + for _, d := range tc.desired { + diff, err := calculator.CalculateDiff(t.Context(), nil, d) + if err != nil { + t.Fatalf("\n%s\nCalculateDiff(%s/%s) unexpected error: %v", tc.reason, d.GetKind(), d.GetName(), err) + } + + // Every degraded resource must carry the typed field: it is both the machine-readable record + // and what the human-facing summary is derived from. + if diff.DryRun == nil { + t.Fatalf("\n%s\n%s/%s: expected a dryRun report, got none", tc.reason, d.GetKind(), d.GetName()) + } + + if diff.DryRun.SkipReason != tc.wantReason { + t.Errorf("\n%s\n%s/%s: dryRun.skipReason = %q, want %q", tc.reason, d.GetKind(), d.GetName(), diff.DryRun.SkipReason, tc.wantReason) + } + + gotDetails = append(gotDetails, diff.DryRun.Detail) + } + + if got := advisories.Advisories(); got != nil { + t.Errorf("\n%s\nadvisories were raised, but the renderer summarises degraded dry runs: %v", tc.reason, got) + } + + if d := gcmp.Diff(tc.wantDetails, gotDetails); d != "" { + t.Errorf("\n%s\ndryRun.detail mismatch (-want +got):\n%s", tc.reason, d) + } + }) + } +} + +func TestCalculateNonRemovalDiffs_NilCompositeResource(t *testing.T) { + calculator := &DefaultDiffCalculator{ + logger: tu.TestLogger(t, false), + } + + xr := cmp.New() + xr.SetAPIVersion("example.org/v1") + xr.SetKind("XMyResource") + xr.SetName("test-xr") + + _, _, err := calculator.CalculateNonRemovalDiffs( + t.Context(), + xr, + render.CompositionOutputs{CompositeResource: nil}, + ) + if err == nil { + t.Fatal("expected error when CompositeResource is nil, got nil") + } + + want := "render produced no composite resource (possible fatal pipeline error)" + if err.Error() != want { + t.Errorf("error message = %q, want %q", err.Error(), want) + } +} + +// TestDefaultDiffCalculator_CalculateNonRemovalDiffsRendered checks what CalculateNonRemovalDiffs records as +// rendered, which removal detection relies on: every resource's diff key, and each composition resource name +// as desired by the composite render consumed. +func TestDefaultDiffCalculator_CalculateNonRemovalDiffsRendered(t *testing.T) { + xr := tu.NewResource("example.org/v1", "XR", "test-xr").InNamespace("ns").Build() + backingXR := tu.NewResource("example.org/v1", "XR", "test-claim-abcde").Build() + claim := tu.NewResource("example.org/v1", "Claim", "test-claim").InNamespace("ns").Build() + + composed := func(name, resourceName string) *un.Unstructured { + return tu.NewResource("example.org/v1", "Composed", name). + InNamespace("ns"). + WithCompositionResourceName(resourceName). + Build() + } + + a, b := composed("a", "res-a"), composed("b", "res-b") + + // A rendered resource with neither name nor generateName cannot be diffed, but is still desired. + unnamed := tu.NewResource("example.org/v1", "Composed", "").WithCompositionResourceName("res-unnamed").Build() + + rendered := func(composite *un.Unstructured, desired []*un.Unstructured, keys ...*un.Unstructured) dtypes.RenderedResources { + var r dtypes.RenderedResources + + for _, d := range desired { + r.AddDesired(identityOf(composite), d.GetAnnotations()["crossplane.io/composition-resource-name"]) + } + + for _, k := range keys { + r.AddKey(dt.MakeDiffKeyFromResource(k)) + } + + return r + } + + asComposite := func(u *un.Unstructured) *cmp.Unstructured { + c := cmp.New() + c.SetUnstructuredContent(u.DeepCopy().Object) + + return c + } + + asComposed := func(us ...*un.Unstructured) []cpd.Unstructured { + out := make([]cpd.Unstructured, 0, len(us)) + for _, u := range us { + out = append(out, cpd.Unstructured{Unstructured: *u.DeepCopy()}) + } + + return out + } + + tests := map[string]struct { + reason string + payload *un.Unstructured + desired render.CompositionOutputs + want dtypes.RenderedResources + }{ + "XR": { + reason: "An XR's composed resources are recorded as desired by the XR.", + payload: xr, + desired: render.CompositionOutputs{CompositeResource: asComposite(xr), ComposedResources: asComposed(a, b)}, + want: rendered(xr, []*un.Unstructured{a, b}, a, b), + }, + "Claim": { + reason: "A claim's composed resources are recorded as desired by the backing XR that render consumed, " + + "which is the composite that composes them, not by the claim.", + payload: claim, + desired: render.CompositionOutputs{CompositeResource: asComposite(backingXR), ComposedResources: asComposed(a)}, + want: rendered(backingXR, []*un.Unstructured{a}, a), + }, + "UndiffableResourceIsStillDesired": { + reason: "The composite desires a resource it cannot be diffed, so what it composed under that name is kept.", + payload: xr, + desired: render.CompositionOutputs{CompositeResource: asComposite(xr), ComposedResources: asComposed(a, unnamed)}, + want: rendered(xr, []*un.Unstructured{a, unnamed}, a), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + resourceClient := tu.NewMockResourceClient().WithResourcesExist(xr, claim, a, b).Build() + + calculator := NewDiffCalculator( + tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + tu.NewMockAccessChecker().Build(), + tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), logger, NewAdvisoryCollector(nil)), + logger, + renderer.DefaultDiffOptions(), + DryRunOnAll, + &tu.MockDefaulter{}, + ) + + _, got, err := calculator.CalculateNonRemovalDiffs(t.Context(), asComposite(tt.payload), tt.desired) + if err != nil { + t.Fatalf("%s\nCalculateNonRemovalDiffs(): unexpected error: %v", tt.reason, err) + } + + if diff := gcmp.Diff(tt.want, got, gcmp.AllowUnexported(dtypes.RenderedResources{})); diff != "" { + t.Errorf("%s\nCalculateNonRemovalDiffs(): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go new file mode 100644 index 00000000..549c17af --- /dev/null +++ b/internal/diff/diffprocessor/diff_processor.go @@ -0,0 +1,1444 @@ +// Package diffprocessor contains the logic to calculate and render diffs. +package diffprocessor + +import ( + "context" + "fmt" + "maps" + "os" + "slices" + "sort" + "strconv" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + "github.com/crossplane/cli/v2/internal/diff/types" +) + +const ( + // Legacy XR spec field names used when syncing a Claim into its backing XR (see syncClaimSpec). + fieldClaimRef = "claimRef" + fieldResourceRefs = "resourceRefs" + fieldWriteConnectionSecretToRef = "writeConnectionSecretToRef" + + // Composition update policy values, mirroring Crossplane's CompositionUpdatePolicy. + compositionUpdatePolicyManual = "Manual" +) + +// DiffProcessor interface for processing resources. +type DiffProcessor interface { + // PerformDiff processes resources using a composition provider function. + // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. + PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) + + // DiffSingleResource processes a single resource and returns its diffs. + DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) + + // Initialize loads required resources like CRDs and environment configs + Initialize(ctx context.Context) error + + // Cleaner releases any resources held by the processor (e.g., Docker containers). + // Should be called when the processor is no longer needed. + Cleaner +} + +// DefaultDiffProcessor implements DiffProcessor with modular components. +type DefaultDiffProcessor struct { + credentialClient xp.CredentialClient + defClient xp.DefinitionClient + schemaClient k8.SchemaClient + resourceClient k8.ResourceClient + resourceManager ResourceManager + compositeResolver *CompositeResolver + config ProcessorConfig + functionProvider FunctionProvider + schemaValidator SchemaValidator + renderDefaulter Defaulter // strict; defaults the XR render consumes (see EffectiveComposite) + diffCalculator DiffCalculator + diffRenderer renderer.DiffRenderer + requirementsProvider *RequirementsProvider + // engineFn is set when using the default engine-backed RenderFunc so its + // Docker network and function runtimes can be released in Cleanup. nil when + // the caller injected a RenderFunc via WithRenderFunc. + engineFn *EngineRenderFn +} + +// NewDiffProcessor creates a new DefaultDiffProcessor with the provided options. +func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) DiffProcessor { + // Create default configuration + // Note: Presentation defaults (Colorize, Compact) are intentionally not set here. They should be + // provided via ProcessorOptions from the CLI layer. Engine limits default here so a caller that + // doesn't go through the CLI gets the same limits. + config := ProcessorConfig{ + MaxNestedDepth: DefaultMaxNestedDepth, + MaxRenderIterations: DefaultMaxRenderIterations, + Stderr: os.Stderr, + Logger: logging.NewNopLogger(), + } + + // Apply all provided options + for _, option := range opts { + option(&config) + } + + if config.Advisories == nil { + config.Advisories = NewAdvisoryCollector(config.Stderr) + } + + // Default the render function to an engine-backed implementation if the + // caller didn't override it. It is safe for concurrent renders; see EngineRenderFn. + // We retain the pointer so Cleanup can release the Docker network and + // function runtimes owned by the engine. + var defaultEngineFn *EngineRenderFn + if config.RenderFunc == nil { + defaultEngineFn = NewEngineRenderFn(config.Logger, config.Advisories, config.CrossplaneRenderBinary, config.CrossplaneVersion, config.CrossplaneImage) + config.RenderFunc = defaultEngineFn.Render + } + + // Set default factory functions if not provided + config.SetDefaultFactories() + + // Create the diff options based on configuration + diffOpts := config.GetDiffOptions() + + // Create components using factories + resourceManager := config.Factories.ResourceManager(k8cs.Resource, xpcs.Definition, xpcs.ResourceTree, config.Logger, config.Advisories) + schemaValidator := config.Factories.SchemaValidator(k8cs.Schema, k8cs.Resource, xpcs.Definition, config.Logger) + requirementsProvider := config.Factories.RequirementsProvider(k8cs.Resource, xpcs.Environment, config.Logger) + renderDefaulter := config.Factories.Defaulter(k8cs.Schema, xpcs.Definition, StrictDefaulting) + predictor := config.Factories.Defaulter(k8cs.Schema, xpcs.Definition, LenientDefaulting) + diffCalculator := config.Factories.DiffCalculator(k8cs.Apply, k8cs.Access, xpcs.ResourceTree, resourceManager, config.Logger, diffOpts, config.DryRunOn, predictor) + diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) + + functionProvider := config.Factories.FunctionProvider(xpcs.Function, config.Logger) + if config.FunctionRegistryOverride != "" { + functionProvider = NewRegistryOverrideFunctionProvider(functionProvider, config.FunctionRegistryOverride, config.Logger) + } + + if config.MaxRecvMessageSize > 0 { + functionProvider = NewEnvInjectingFunctionProvider(functionProvider, + map[string]string{EnvMaxRecvMessageSize: strconv.Itoa(config.MaxRecvMessageSize)}, config.Logger) + } + + processor := &DefaultDiffProcessor{ + credentialClient: xpcs.Credential, + defClient: xpcs.Definition, + schemaClient: k8cs.Schema, + resourceClient: k8cs.Resource, + resourceManager: resourceManager, + compositeResolver: NewCompositeResolver(resourceManager, xpcs.Definition, config.Logger, config.Advisories), + config: config, + functionProvider: functionProvider, + schemaValidator: schemaValidator, + renderDefaulter: renderDefaulter, + diffCalculator: diffCalculator, + diffRenderer: diffRenderer, + requirementsProvider: requirementsProvider, + engineFn: defaultEngineFn, + } + + return processor +} + +// Initialize loads required resources like CRDs and environment configs. +func (p *DefaultDiffProcessor) Initialize(ctx context.Context) error { + p.config.Logger.Debug("Initializing diff processor") + + // Load CRDs (handled by the schema validator) + err := p.initializeSchemaValidator(ctx) + if err != nil { + return err + } + + // Init requirements provider + err = p.requirementsProvider.Initialize(ctx) + if err != nil { + return err + } + + p.config.Logger.Debug("Diff processor initialized") + + return nil +} + +// Cleanup releases any resources held by the processor. +// This includes stopping and removing any Docker containers created for function execution. +func (p *DefaultDiffProcessor) Cleanup(ctx context.Context) error { + var errs []error + if err := p.functionProvider.Cleanup(ctx); err != nil { + errs = append(errs, err) + } + + if p.engineFn != nil { + if err := p.engineFn.Cleanup(ctx); err != nil { + errs = append(errs, err) + } + } + + return errors.Join(errs...) +} + +// CleanupTimeout bounds how long releasing the processor's Docker resources may take. Cleanup is +// attempted on a context detached from the run's cancellation (see CleanupDetached), so this is what +// stops a slow or hung Docker daemon from blocking a run indefinitely. +const CleanupTimeout = 30 * time.Second + +// Cleaner is anything holding resources that CleanupDetached can release. DiffProcessor, +// CompDiffProcessor and FunctionProvider all embed it. +type Cleaner interface { + Cleanup(ctx context.Context) error +} + +// CleanupDetached releases c's resources on a context derived from ctx that keeps ctx's values but not +// its cancellation or deadline, bounded instead by CleanupTimeout. Cleanup must still run when the +// run's own context has expired (--timeout), since Docker calls on a dead context fail and leave +// containers running; and it must not hang on a slow or wedged Docker daemon. A failure is logged, +// not returned: releasing resources is best-effort and must not change the run's result. +// +// This is also what lets cleanup finish after Ctrl+C or SIGTERM: the first signal cancels the run's +// context (see the command layer's run context) rather than killing the process. A second signal +// exits immediately and may leave containers behind, which the next run reclaims (see dockerrun). +func CleanupDetached(ctx context.Context, c Cleaner, logger logging.Logger) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), CleanupTimeout) + defer cancel() + + if err := c.Cleanup(cleanupCtx); err != nil { + logger.Debug("Failed to release processor resources", "error", err) + } +} + +// initializeSchemaValidator initializes the schema validator with CRDs. +func (p *DefaultDiffProcessor) initializeSchemaValidator(ctx context.Context) error { + // If the schema validator implements our interface with LoadCRDs, use it + if validator, ok := p.schemaValidator.(*DefaultSchemaValidator); ok { + err := validator.LoadCRDs(ctx) + if err != nil { + return errors.Wrap(err, "cannot load CRDs") + } + + p.config.Logger.Debug("Schema validator initialized with CRDs", + "crdCount", len(validator.GetCRDs())) + } + + return nil +} + +// PerformDiff processes resources using a composition provider function. +// Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. +func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { + p.config.Logger.Debug("Processing resources with composition provider", "count", len(resources)) + + if len(resources) == 0 { + p.config.Logger.Debug("No resources to process") + return false, nil + } + + // Build one diff group per input XR/claim, in input order, so the renderer + // can group structured output (xrs[]) and human output (per-XR sections) by + // the input that produced each change. See renderer/types.XRDiffGroup. + groups := make([]dt.XRDiffGroup, 0, len(resources)) + + // Collect errors for the top-level/global (union) error list, preserved for + // back-compat alongside the per-group errors. + var outputErrors []dt.OutputError + + var errs []error + + // Overlapping inputs are settled in three stages by a per-run InputValidator; see input_validator.go. + // First, before rendering: the validator says what to render, and which inputs it already rejected. + validator := p.config.Factories.InputValidator(p.config.Advisories, resources) + toRender := validator.ToRender() + + for i, in := range toRender { + res := in.Resource + + // Attribute the group to the name the XR is rendered under. A synthesized one (generateName + // only) is shown the way the diff formatter shows it, "(generated)", so the + // grouped and flat views agree and no unpredictable hash is published (issue #477). + name, generated := renderName(res) + if generated { + name = dt.GeneratedDisplayName(name, res.GetGenerateName()) + } + + group := dt.XRDiffGroup{ + XR: corev1.ObjectReference{ + APIVersion: res.GetAPIVersion(), + Kind: res.GetKind(), + Name: name, + Namespace: res.GetNamespace(), + }, + NameGenerated: generated, + } + + if in.Err == nil { + diffs, rendered, err := p.diffSingleResourceInternal(ctx, res, compositionProvider, nil, nil, true, 0) + validator.RecordRender(i, rendered.Keys(), err) + + if err == nil { + // We don't emit partial results for a single XR: on success the + // whole diff tree is attached, on failure none of it. + group.Diffs = diffs + } + } + + groups = append(groups, group) + } + + // Second: each group's final error, including any input another input manages. Errors are only + // reported now, because the validator's verdict may supersede a render failure. + for i, err := range validator.Verdicts(groups) { + if err == nil { + continue + } + + groups[i].Diffs = nil + + res := toRender[i].Resource + resourceID := fmt.Sprintf("%s/%s", groups[i].XR.Kind, groups[i].XR.Name) + + // Debug, not Info: this failure is already surfaced as an OutputError, which goes to + // stderr and into structured output. Raising it as a warning too would double-report it. + p.config.Logger.Debug("Failed to process resource", + "resource", resourceID, + "namespace", res.GetNamespace(), + "error", err) + errs = append(errs, errors.Wrapf(err, "unable to process resource %s", resourceID)) + + // Collect error for structured output. NewOutputError + // surfaces typed validation failures via + // OutputError.ValidationFailures when err wraps a + // SchemaValidationError that carries a structured Result. + // The same converted error goes to both the top-level union + // (outputErrors) and the per-group entry so consumers of + // either view see it. + outErr := NewOutputError(resourceID, err) + outputErrors = append(outputErrors, outErr) + groups[i].Err = &outErr + } + + // Third: renders that reach one resource in ways no single diff can represent. Collected before the + // output is rendered so they reach errors[] and stderr like any other error; the output is still + // rendered, so structured output stays valid. + for _, err := range validator.RenderOverlaps(groups) { + errs = append(errs, err) + outputErrors = append(outputErrors, dt.OutputError{Message: err.Error()}) + } + + // Release resources before rendering, so an advisory raised during teardown (leftover function + // containers) is still in warnings[] when the renderer reads it; the command's deferred Cleanup + // would run only after output has been emitted. + CleanupDetached(ctx, p, p.config.Logger) + + outputErrors = withInterruption(ctx, outputErrors) + + // Always render (even if only errors exist) to ensure valid structured output + // The renderer will include errors in the structured output and write them to stderr + err := p.diffRenderer.RenderDiffs(groups, outputErrors, p.config.Advisories.Advisories()) + if err != nil { + p.config.Logger.Debug("Failed to render diffs", "error", err) + errs = append(errs, errors.Wrap(err, "failed to render diffs")) + } + + // Count only non-equal diffs as "having diffs". + // The diffs map may contain DiffTypeEqual entries (e.g., XR stored for removal detection). + hasDiffs := false + totalDiffs := 0 + + for _, group := range groups { + for _, diff := range group.Diffs { + totalDiffs++ + + if diff.DiffType != dt.DiffTypeEqual { + hasDiffs = true + } + } + } + + p.config.Logger.Debug("Processing complete", + "resourceCount", len(resources), + "totalDiffs", totalDiffs, + "hasDiffs", hasDiffs, + "errorCount", len(errs)) + + if len(errs) > 0 { + return hasDiffs, errors.Join(errs...) + } + + return hasDiffs, nil +} + +// DiffSingleResource handles one resource at a time and returns its diffs. +// The compositionProvider function is called to obtain the composition to use for rendering. +// This is the public method for top-level XR diffing, which enables removal detection. +func (p *DefaultDiffProcessor) DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + diffs, _, err := p.diffSingleResourceInternal(ctx, res, compositionProvider, nil, nil, true, 0) + return diffs, err +} + +// diffSingleResourceInternal is the internal implementation that allows control over removal detection. +// parentXR should be nil for root XRs, and the parent XR for nested XRs. +// existing is res's cluster copy when the caller already has it (a nested XR found among its parent's +// observed resources), and nil otherwise. +// detectRemovals should be true for top-level XRs and false for nested XRs (which don't own their composed resources). +// depth is the nesting depth of res itself: 0 for the XR the user named, 1 for an XR composed by it, +// and so on. It must be threaded through the recursion for MaxNestedDepth to bound it at all. +func (p *DefaultDiffProcessor) diffSingleResourceInternal(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider, parentXR *cmp.Unstructured, existing *un.Unstructured, detectRemovals bool, depth int) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + resourceID := fmt.Sprintf("%s/%s", res.GetKind(), res.GetName()) + p.config.Logger.Debug("Processing resource", "resource", resourceID, "namespace", res.GetNamespace()) + + // authoredXR is the input as written, plus a synthesized name for a generateName-only XR and, for + // a nested XR, the identity of its cluster copy (see ProcessNestedXRs). It is the XR's dry-run apply + // payload, so it is never modified: under server-side apply every field in a payload claims + // ownership of that field, and a locally derived one would take a value from whichever manager set + // it in the cluster (#503). What render consumes is a separate, defaulted copy; see renderXR. + authoredXR, done, err := p.SanitizeXR(res, resourceID) + if done { + return nil, types.RenderedResources{}, err + } + + // What Crossplane would actually reconcile: the input over its cluster copy or, for a claim, its + // backing XR with the claim synced in. Computed before anything reads the composite's composition, + // revision or update policy, so that resolving the composition and rendering agree on them. + composite, err := p.compositeResolver.Resolve(ctx, authoredXR, existing) + if err != nil { + p.config.Logger.Debug("Failed to resolve the effective composite", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot resolve the effective composite") + } + + p.warnIfDeleting(composite.Cluster, parentXR, resourceID) + + // Default the effective XR the way the apiserver would, so that composition resolution and render + // see the spec Crossplane would see. This follows inheriting the cluster's fields, so that a default + // never overrides a value the cluster already holds (a Manual policy, say). The policy is strict: + // rendering without those defaults could produce a wrong diff. The storage version it reports is + // only for describing a predicted addition (see predictLocally), so it is not wanted here. + defaultedXR, _, err := p.renderDefaulter.Default(ctx, composite.Effective) + if err != nil { + p.config.Logger.Debug("Failed to apply XRD defaults", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot apply XRD defaults") + } + + // Get the composition using the provided function. It is resolved from the defaulted effective XR, + // so that it sees the revision a composite is pinned to and the update policy an XRD defaults. + resolved, err := compositionProvider(ctx, defaultedXR) + if err != nil { + p.config.Logger.Debug("Failed to get composition", "resource", resourceID, "namespace", res.GetNamespace(), "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot get composition") + } + + comp := resolved.Composition + + p.config.Logger.Debug("Resource setup complete", "resource", resourceID, "composition", comp.GetName(), "revision", resolved.RevisionName) + + // Get functions for this composition (provider handles caching internally) + fns, err := p.functionProvider.GetFunctionsForComposition(comp) + if err != nil { + p.config.Logger.Debug("Failed to get functions", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot get functions for composition") + } + + renderXR := cmp.New() + renderXR.SetUnstructuredContent(defaultedXR.Object) + + // Crossplane's composite reconciler writes the revision it selects to compositionRevisionRef, creating + // the ref if there is none, and only then composes. So point the render input's ref at the revision + // it renders, and a template reading the revision name sees the one that rendered it. Only the render + // input: for a claim that is its backing XR, and the dry-run payload stays as authored. The ref is + // written where the schema render uses puts it (spec.crossplane.* or, for a legacy XR, spec.*). + if resolved.RevisionName != "" { + renderXR.Schema, _ = p.resolveSchemaAndXRDForRender(ctx, renderXR, resourceID) + renderXR.SetCompositionRevisionReference(&corev1.LocalObjectReference{Name: resolved.RevisionName}) + } + + // Fetch observed resources for use in rendering (needed for getComposedResource template function) + // and for function-sequencer to know which resources already exist in the cluster). They are the + // composed resources of the XR render consumes: for a claim, its backing XR's. + var observedResources []cpd.Unstructured + + if composed := composite.Composed(); composed != nil { + observedResources, err = p.fetchObservedResourcesFromClusterXR(ctx, composed, resourceID) + if err != nil { + return nil, types.RenderedResources{}, err + } + } + + // Perform iterative rendering with requirements resolution. + // When EventualState is enabled, also synthesizes Ready status between iterations + // to reveal all stages that function-sequencer would eventually render. + desired, err := p.RenderToStableState(ctx, renderXR, comp, fns, resourceID, observedResources, p.config.EventualState) + if err != nil { + p.config.Logger.Debug("Resource rendering failed", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot render resources with requirements") + } + + // Render stamps the XR's namespace onto every composed resource of a + // namespaced XR. Production rejects a cluster-scoped one at that point, but + // render's scope check is a stub that cannot, so undo the stamp here. See + // removeNamespacesFromClusterScopedResources for why. + if err := p.removeNamespacesFromClusterScopedResources(ctx, desired.ComposedResources); err != nil { + p.config.Logger.Debug("Failed to clean up namespaces from cluster-scoped resources", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot clean up namespaces from cluster-scoped resources") + } + + // The XR is validated and dry-run applied as authored: for a Claim, the Claim itself, never the + // backing XR render consumed. Nothing from render belongs in it. The only fields render adds to the + // XR it returns are spec.resourceRefs (spec.crossplane.resourceRefs for a v2 XR) and status, which + // Crossplane writes itself and cleanupForDiff strips from every diff; and the locally predicted + // defaults on the render input must stay out for the reason given on authoredXR. + xrPayload := authoredXR.DeepCopy() + + // Fields a manifest exported from the cluster may still carry, which a dry-run apply rejects. + xrPayload.SetManagedFields(nil) + xrPayload.SetResourceVersion("") + + // Validate the resources + if err := p.schemaValidator.ValidateResources(ctx, xrPayload.GetUnstructured(), desired.ComposedResources); err != nil { + p.config.Logger.Debug("Resource validation failed", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot validate resources") + } + + // Calculate diffs (without removal detection) + p.config.Logger.Debug("Calculating diffs", "resource", resourceID, "composedCount", len(desired.ComposedResources)) + + diffs, rendered, err := p.diffCalculator.CalculateNonRemovalDiffs(ctx, xrPayload, desired) + if err != nil { + // Fail completely rather than emit potentially incorrect partial results (design principle) + p.config.Logger.Debug("Error calculating diffs - failing XR", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot calculate diffs for composed resources") + } + + // Check for nested XRs in the composed resources and process them recursively + p.config.Logger.Debug("Checking for nested XRs", "resource", resourceID, "composedCount", len(desired.ComposedResources)) + + // Extract the existing XR from the cluster (if it exists) to pass to ProcessNestedXRs + // This is needed because ProcessNestedXRs fetches observed resources using the parent XR's UID, + // which is only available on the existing cluster XR, not the modified XR from the input file. + var existingXR *cmp.Unstructured + + xrDiffKey := dt.MakeDiffKeyFromResource(&authoredXR.Unstructured) + if xrDiff, ok := diffs[xrDiffKey]; ok && xrDiff.Current.Raw != nil { + // Convert from unstructured.Unstructured to composite.Unstructured + existingXR = cmp.New() + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrDiff.Current.Raw.Object, existingXR) + if err != nil { + p.config.Logger.Debug("Failed to convert existing XR to composite unstructured", + "resource", resourceID, + "error", err) + // Continue with nil existingXR - ProcessNestedXRs will handle this case + } + } + + // Anything composed by this XR sits one level deeper than it does. + nestedDiffs, nestedRendered, err := p.ProcessNestedXRs(ctx, desired.ComposedResources, compositionProvider, resourceID, existingXR, observedResources, depth+1) + if err != nil { + p.config.Logger.Debug("Error processing nested XRs", "resource", resourceID, "error", err) + return nil, types.RenderedResources{}, errors.Wrap(err, "cannot process nested XRs") + } + + p.config.Logger.Debug("Before merging nested resources", + "resource", resourceID, + "renderedResourcesCount", len(rendered.Keys()), + "nestedRenderedResourcesCount", len(nestedRendered.Keys())) + + // Merge nested diffs into our result + maps.Copy(diffs, nestedDiffs) + + // Merge what the nested XRs rendered into what this one did. Removal detection matches each nested + // XR's composed resources against the nested XR's own render, so without it every one of them, + // unchanged or not, would be flagged as removed. + rendered.Merge(nestedRendered) + + p.config.Logger.Debug("After merging nested resources", + "resource", resourceID, + "renderedResourcesCount", len(rendered.Keys())) + + // Now detect removals if requested (only for top-level XRs) + // This must happen after nested XR processing to avoid false positives + if detectRemovals && existingXR != nil { + p.config.Logger.Debug("Detecting removed resources", "resource", resourceID, "renderedCount", len(rendered.Keys())) + + removedDiffs, removalErr := p.diffCalculator.CalculateRemovedResourceDiffs(ctx, existingXR.GetUnstructured(), rendered) + if removalErr != nil { + // Fail completely rather than emit potentially incorrect partial results (design principle) + p.config.Logger.Debug("Error detecting removed resources - failing XR", "resource", resourceID, "error", removalErr) + return nil, types.RenderedResources{}, errors.Wrap(removalErr, "cannot detect removed resources") + } + + if len(removedDiffs) > 0 { + maps.Copy(diffs, removedDiffs) + p.config.Logger.Debug("Found removed resources", "resource", resourceID, "removedCount", len(removedDiffs)) + } + } + + p.config.Logger.Debug("Resource processing complete", + "resource", resourceID, + "diffCount", len(diffs), + "nestedDiffCount", len(nestedDiffs)) + + return diffs, rendered, nil +} + +// warnIfDeleting raises a warning when the cluster copy of a top-level XR is being deleted. +// +// Unlike `comp`, which excludes deleting XRs from impact analysis entirely (they can never adopt the +// diffed composition's revision — see classifyXR), `xr` diffs exactly the resource the user named, +// so suppressing it would leave them with no output at all. Instead we still show the diff and flag +// that it is being compared against a resource that is going away. +// +// Only top-level XRs are flagged (parentXR == nil); composed resources of a live XR churn through +// deletion routinely, and warning on each would be noise. In practice this only fires for `xr`, since +// `comp` filters deleting XRs before they reach this path. +func (p *DefaultDiffProcessor) warnIfDeleting(existingXRFromCluster *un.Unstructured, parentXR *cmp.Unstructured, resourceID string) { + if existingXRFromCluster == nil || parentXR != nil { + return + } + + deletedAt := existingXRFromCluster.GetDeletionTimestamp() + if deletedAt == nil { + return + } + + // Normalized to UTC/RFC3339 because metav1.Time renders machine-local by default, which would make + // the advisory's text differ between runs on differently-configured machines. + p.config.Advisories.Raise(dt.OutputWarning{ + Message: "The resource being diffed is being deleted in the cluster; the diff compares against a resource that is going away", + Context: map[string]string{ + "resource": resourceID, + "deletionTimestamp": deletedAt.UTC().Format(time.RFC3339), + }, + }) +} + +// fetchObservedResourcesFromClusterXR fetches observed resources using the cluster XR. +// We must use the cluster XR (not the input XR) because the XRM client uses spec.resourceRefs +// to find children. The input XR doesn't have resourceRefs, but the cluster XR does. +// This ensures that function-sequencer and other functions that check observed resources work correctly. +// +// A failure here is fatal. Downstream, an empty observed set is indistinguishable from "this XR +// genuinely has no composed resources yet", so continuing with one would report every existing +// composed resource as a creation — a silently wrong diff, which "Accuracy Above All Else" +// forbids. This holds for a Claim's backing XR as much as for an XR. +func (p *DefaultDiffProcessor) fetchObservedResourcesFromClusterXR(ctx context.Context, existingXRFromCluster *un.Unstructured, resourceID string) ([]cpd.Unstructured, error) { + clusterXR := cmp.New() + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(existingXRFromCluster.Object, clusterXR); err != nil { + p.config.Logger.Debug("Could not convert cluster XR for observed resources fetch", + "resource", resourceID, + "error", err) + + return nil, errors.Wrapf(err, "cannot convert cluster XR %q to composite", existingXRFromCluster.GetName()) + } + + observedResources, err := p.resourceManager.FetchObservedResources(ctx, clusterXR) + if err != nil { + p.config.Logger.Debug("Could not fetch observed resources", + "resource", resourceID, + "error", err) + + return nil, errors.Wrapf(err, "cannot fetch observed resources for XR %q", existingXRFromCluster.GetName()) + } + + return observedResources, nil +} + +// findExistingNestedXR locates an existing nested XR in the observed resources by matching +// the composition-resource-name annotation and kind. +func findExistingNestedXR(nestedXR *un.Unstructured, observedResources []cpd.Unstructured) *un.Unstructured { + compositionResourceName := nestedXR.GetAnnotations()["crossplane.io/composition-resource-name"] + if compositionResourceName == "" { + return nil + } + + for _, obs := range observedResources { + obsUnstructured := &un.Unstructured{Object: obs.UnstructuredContent()} + obsCompResName := obsUnstructured.GetAnnotations()["crossplane.io/composition-resource-name"] + + // Match by composition-resource-name annotation and kind + if obsCompResName == compositionResourceName && obsUnstructured.GetKind() == nestedXR.GetKind() { + return obsUnstructured + } + } + + return nil +} + +// preserveNestedXRIdentity gives a nested XR, as its parent rendered it, the identity of its existing +// cluster copy: its name, generateName and Crossplane labels. These belong on the nested XR's dry-run +// payload, which must address the existing object. Everything else the cluster copy contributes (its +// UID and the fields Crossplane writes) reaches only what render consumes; see CompositeResolver. +func preserveNestedXRIdentity(nestedXR, existingNestedXR *un.Unstructured) { + nestedXR.SetName(existingNestedXR.GetName()) + nestedXR.SetGenerateName(existingNestedXR.GetGenerateName()) + + // Preserve Crossplane labels so child resources get matched correctly + // These labels are needed for: + // - crossplane.io/composite: matching composed resources to their owner + // - crossplane.io/claim-name/namespace: detecting Claim context for label propagation + CopyLabels(existingNestedXR, nestedXR, LabelComposite, LabelClaimName, LabelClaimNamespace) +} + +// ProcessNestedXRs recursively processes composed resources that are themselves XRs. +// It checks each composed resource to see if it's an XR, and if so, processes it through +// its own composition pipeline to get the full tree of diffs. It preserves the identity +// of existing nested XRs to ensure accurate diff calculation. +// observedResources should contain the observed resources from the parent XR's resource tree. +// depth is the nesting depth of composedResources themselves: 1 for the resources composed by the +// XR the user named, 2 for their children, and so on. MaxNestedDepth is therefore the number of +// levels of nesting permitted below the root, and descending past it is an error rather than a +// silent truncation of the tree. +func (p *DefaultDiffProcessor) ProcessNestedXRs( + ctx context.Context, + composedResources []cpd.Unstructured, + compositionProvider types.CompositionProvider, + parentResourceID string, + parentXR *cmp.Unstructured, + observedResources []cpd.Unstructured, + depth int, +) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + p.config.Logger.Debug("Processing nested XRs", + "parentResource", parentResourceID, + "composedResourceCount", len(composedResources), + "observedResourcesCount", len(observedResources), + "depth", depth) + + allDiffs := make(map[string]*dt.ResourceDiff) + + var allRendered types.RenderedResources + + for _, composed := range composedResources { + nestedXR := &un.Unstructured{Object: composed.UnstructuredContent()} + + // Check if this composed resource is itself an XR + isXR, _ := p.getCompositeResourceXRD(ctx, nestedXR) + + if !isXR { + // Skip non-XR resources + continue + } + + nestedResourceID := fmt.Sprintf("%s/%s (nested depth %d)", nestedXR.GetKind(), nestedXR.GetName(), depth) + + // Enforce the bound only once we know there is genuinely an XR to descend + // into. Checking on entry instead would reject a tree that is exactly + // MaxNestedDepth levels deep, because the deepest XR still reaches here + // (with an empty or all-managed composedResources) one level too far down. + // + // A composition cycle (XR-A composes XR-B composes XR-A) has no natural + // stopping point, so this is the only thing standing between it and an + // exhausted goroutine stack. + if depth > p.config.MaxNestedDepth { + p.config.Logger.Debug("Maximum nesting depth exceeded", + "parentResource", parentResourceID, + "nestedXR", nestedResourceID, + "depth", depth, + "maxDepth", p.config.MaxNestedDepth) + + return nil, types.RenderedResources{}, errors.Errorf("maximum nesting depth exceeded: %s is nested %d levels deep, but --max-nested-depth is %d", nestedResourceID, depth, p.config.MaxNestedDepth) + } + + p.config.Logger.Debug("Found nested XR, processing recursively", + "nestedXR", nestedResourceID, + "parentXR", parentResourceID, + "depth", depth) + + // Find the matching existing nested XR in observed resources (if it exists) + // Match by composition-resource-name annotation to find the correct existing resource + existingNestedXR := findExistingNestedXR(nestedXR, observedResources) + + // If not found in observedResources (e.g., tree client returned empty), try FetchCurrentObject + // This handles cases where the tree client doesn't work (e.g., envtest) or the ownership model + // doesn't allow tree traversal from intermediate XRs. + if existingNestedXR == nil && parentXR != nil { + p.config.Logger.Debug("Nested XR not found in observedResources, trying FetchCurrentObject", + "nestedXR", nestedResourceID) + + // Use the composite label from the nested XR to build a lookup + existingNestedXRUn, isNew, fetchErr := p.resourceManager.FetchCurrentObject(ctx, parentXR.GetUnstructured(), nestedXR) + if fetchErr == nil && existingNestedXRUn != nil && !isNew { + existingNestedXR = existingNestedXRUn + p.config.Logger.Debug("Found existing nested XR via FetchCurrentObject", + "nestedXR", nestedResourceID, + "existingName", existingNestedXR.GetName()) + } + } + + if existingNestedXR != nil { + p.config.Logger.Debug("Found existing nested XR in cluster", + "nestedXR", nestedResourceID, + "existingName", existingNestedXR.GetName(), + "compositionResourceName", nestedXR.GetAnnotations()["crossplane.io/composition-resource-name"]) + + // Preserve its identity (name, composite label) so its managed resources can be matched correctly + preserveNestedXRIdentity(nestedXR, existingNestedXR) + + p.config.Logger.Debug("Preserved nested XR identity", + "nestedXR", nestedResourceID, + "preservedName", nestedXR.GetName()) + } + + // Recursively process this nested XR + // Pass parentXR so nested XR can have correct composite label, and its cluster copy (nil if it + // has none) so it is not fetched again + // Use detectRemovals=false for nested XRs since they don't own their composed resources + // (resources are owned by the top-level parent XR in Crossplane's ownership model) + nestedDiffs, nestedRendered, err := p.diffSingleResourceInternal(ctx, nestedXR, compositionProvider, parentXR, existingNestedXR, false, depth) + if err != nil { + // Check if the error is due to missing composition + // Note: It's valid to have an XRD in Crossplane without a composition attached to it. + // In such cases, we skip recursive processing of the nested XR but allow the overall + // diff operation to continue. The diff for the nested XR itself will still be shown. + if errors.Is(err, xp.ErrNoMatchingComposition) { + p.config.Advisories.Raise(dt.OutputWarning{ + Message: "Skipping nested XR processing due to missing composition", + Context: map[string]string{ + "nestedXR": nestedResourceID, + "parentXR": parentResourceID, + "gvk": nestedXR.GroupVersionKind().String(), + }, + }) + // Continue processing other nested XRs + continue + } + + // For other errors, fail per Guiding Principles: "never silently continue in the face of failures" + p.config.Logger.Debug("Error processing nested XR", + "nestedXR", nestedResourceID, + "parentXR", parentResourceID, + "error", err) + + return nil, types.RenderedResources{}, errors.Wrapf(err, "cannot process nested XR %s", nestedResourceID) + } + + // Merge diffs from nested XR + maps.Copy(allDiffs, nestedDiffs) + // Merge rendered resources from nested XR + allRendered.Merge(nestedRendered) + + p.config.Logger.Debug("Nested XR processed successfully", + "nestedXR", nestedResourceID, + "diffCount", len(nestedDiffs), + "nestedRenderedResourcesCount", len(nestedRendered.Keys()), + "allRenderedResourcesCount", len(allRendered.Keys())) + } + + p.config.Logger.Debug("Finished processing nested XRs", + "parentResource", parentResourceID, + "totalNestedDiffs", len(allDiffs), + "totalRenderedResourcesCount", len(allRendered.Keys()), + "depth", depth) + + return allDiffs, allRendered, nil +} + +// SanitizeXR makes an XR into a valid unstructured object that we can use in a dry-run apply. +func (p *DefaultDiffProcessor) SanitizeXR(res *un.Unstructured, resourceID string) (*cmp.Unstructured, bool, error) { + // Convert the unstructured resource to a composite unstructured for rendering + xr := cmp.New() + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(res.UnstructuredContent(), xr) + if err != nil { + p.config.Logger.Debug("Failed to convert resource", "resource", resourceID, "error", err) + return nil, true, errors.Wrap(err, "cannot convert XR to composite unstructured") + } + + // Handle XRs with generateName but no name + if name, generated := renderName(res); generated { + p.config.Logger.Debug("Setting synthesized name for XR with generateName", + "generateName", xr.GetGenerateName(), + "synthesizedName", name) + + xrCopy := xr.DeepCopy() + xrCopy.SetName(name) + xr = xrCopy + } + + return xr, false, nil +} + +// renderName returns the name an input XR is rendered under, and whether it was synthesized. That is +// its own name; or, for an XR with only a generateName, one synthesized in the shape upstream's +// nameGenerator produces — "<12 lowercase hex>" — so the binary's +// apiserver-style name validation accepts the XR AND the rendered XR name is shape-compatible with the +// composed-resource names the binary itself emits. The diff formatter then runs one detector +// (LooksLikeGeneratedName) over both to substitute "(generated)" for display. +func renderName(res *un.Unstructured) (string, bool) { + if name := res.GetName(); name != "" || res.GetGenerateName() == "" { + return name, false + } + + return dt.SynthesizeGeneratedName(res.GetGenerateName()), true +} + +// RenderToStableState performs iterative rendering until stable state is reached. +// It handles requirements resolution (environment configs, etc.) and optionally +// synthesizes Ready status on composed resources between iterations. +// +// When synthesizeReady is false (normal mode), the loop terminates once requirements are resolved. +// When synthesizeReady is true (eventual-state mode), the loop continues until every rendered +// composed resource is observed Ready and no new requirements appear, synthesizing Ready=True on +// the observed state between iterations to reveal stages gated on readiness (function-sequencer). +// Synthesized Ready reaches only the next render's observed state: observedResources itself, and +// the cluster state the diff is later computed against, are never modified. +func (p *DefaultDiffProcessor) RenderToStableState( + ctx context.Context, + xr *cmp.Unstructured, + comp *apiextensionsv1.Composition, + fns []pkgv1.Function, + resourceID string, + observedResources []cpd.Unstructured, + synthesizeReady bool, +) (render.CompositionOutputs, error) { + xrSchema, xrdForRender := p.resolveSchemaAndXRDForRender(ctx, xr, resourceID) + // Pin the schema on the input wrapper so the renderer writes canonical + // fields at the right path (spec.* for Legacy XRs, spec.crossplane.* for + // Modern), which is required for dry-run apply against the cluster CRD + // downstream. + xr.Schema = xrSchema + + functionCredentials, err := p.resolveFunctionCredentials(ctx, comp, resourceID) + if err != nil { + return render.CompositionOutputs{}, err + } + + // Track required resources with deduplication + requiredResources := make(map[string]un.Unstructured) + + // Track observed resources (modified when synthesizeReady=true) + observed := observedResources + + maxIterations := p.config.MaxRenderIterations + + var lastOutput render.CompositionOutputs + + for iteration := 1; iteration <= maxIterations; iteration++ { + p.config.Logger.Debug("Render iteration", + "resource", resourceID, + "iteration", iteration, + "requiredCount", len(requiredResources), + "observedCount", len(observed), + "synthesizeReady", synthesizeReady) + + // Perform render + output, renderErr := p.config.RenderFunc(ctx, p.config.Logger, RenderInputs{ + CompositeResource: xr, + Composition: comp, + Functions: fns, + FunctionCredentials: functionCredentials, + RequiredResources: slices.Collect(maps.Values(requiredResources)), + ObservedResources: observed, + XRD: xrdForRender, + }) + + lastOutput = output + + // Handle requirements (even if render had errors) + newReqCount := 0 + + if len(output.RequiredResources) > 0 { + additionalResources, err := p.requirementsProvider.ResolveSelectors(ctx, output.RequiredResources, xr.GetNamespace()) + if err != nil { + return render.CompositionOutputs{}, errors.Wrap(err, "failed to process requirements") + } + + for _, res := range additionalResources { + if addUniqueResource(requiredResources, res) { + newReqCount++ + } + } + } + + // Render error AND no new requirements means we have nothing left to try. + // Surface the error. + // + // With the v2.4+ partial-output-on-fatal contract (crossplane/crossplane#7455, + // backported in #7466), the binary populates output.RequiredResources even when + // a pipeline step FATALs. We resolve those above; if resolution turned up + // anything we hadn't already cached, newReqCount > 0 and we iterate. If every + // selector resolved to a resource we'd already supplied (or to nothing), the + // next iteration would be identical to this one — no point retrying. + if renderErr != nil && newReqCount == 0 { + return render.CompositionOutputs{}, errors.Wrap(renderErr, "cannot render resources") + } + + // If render failed but we got new requirements, continue to next iteration + if renderErr != nil && newReqCount > 0 { + p.config.Logger.Debug("Render failed but resolved new requirements, continuing", + "resource", resourceID, + "iteration", iteration, + "error", renderErr) + + continue + } + + // Check for stability + result := p.checkStability(output, observed, newReqCount, synthesizeReady, resourceID, iteration, len(requiredResources)) + if result.err != nil { + return render.CompositionOutputs{}, result.err + } + + if result.stable { + return lastOutput, nil + } + + observed = result.nextObserved + } + + return render.CompositionOutputs{}, errors.Errorf("did not stabilize after %d iterations; try increasing --max-iterations if your pipeline requires more cycles", maxIterations) +} + +// resolveSchemaAndXRDForRender returns the canonical composite Schema (Legacy +// vs Modern) and the XRD object the render binary uses when picking its +// internal wrapper schema. One XRD lookup serves both jobs: the spec.scope +// read for our schema decision (via xp.SchemaFromXRD) AND the XRD forwarded +// to the binary. Tries the XR path first; falls back to the claim path so +// claim-rooted diffs work too. Falls back to SchemaModern with no XRD when +// no XRD matches. +func (p *DefaultDiffProcessor) resolveSchemaAndXRDForRender(ctx context.Context, xr *cmp.Unstructured, resourceID string) (cmp.Schema, *un.Unstructured) { + xrd, err := p.defClient.GetXRDForXR(ctx, xr.GroupVersionKind()) + if err != nil { + xrd, err = p.defClient.GetXRDForClaim(ctx, xr.GroupVersionKind()) + } + + if err != nil || xrd == nil { + p.config.Logger.Debug("Cannot fetch XRD for render input; defaulting to SchemaModern", + "resource", resourceID, "gvk", xr.GroupVersionKind().String(), "error", err) + + return cmp.SchemaModern, nil + } + + return xp.SchemaFromXRD(xrd), xrd +} + +// resolveFunctionCredentials merges CLI-provided function credentials with any credentials +// auto-fetched from the composition pipeline, and raises the shortfall advisory for the credentials +// that are still missing once both sources have been combined. +// +// The advisory is raised here rather than in the credential client because only this layer can see +// both halves: the client knows which referenced secrets are absent from the cluster, and +// config.FunctionCredentials says which of those the user already supplied. Warning before the merge +// told a user who had correctly passed --function-credentials to go and do the thing they had just +// done. Its context is keyed on the composition and not on resourceID, which is deliberate — the +// condition is a property of the composition, so the identical advisory raised while processing each +// of a composition's XRs collapses to one entry in the warning channel. +func (p *DefaultDiffProcessor) resolveFunctionCredentials(ctx context.Context, comp *apiextensionsv1.Composition, resourceID string) ([]corev1.Secret, error) { + fetched, err := p.fetchCompositionCredentials(ctx, comp) + if err != nil { + return nil, err + } + + merged := mergeCredentials(p.config.FunctionCredentials, fetched.Secrets) + + if unsatisfied := unsatisfiedCredentials(fetched.Absent, merged); len(unsatisfied) > 0 { + // Every remaining secret is by construction one the user has not supplied, so the hint is + // actionable in every case that reaches here. + p.config.Advisories.Raise(dt.OutputWarning{ + Message: "Some function credential secrets could not be fetched from cluster", + Context: map[string]string{ + "composition": comp.GetName(), + "missing": strings.Join(unsatisfied, ","), + "hint": "Use --function-credentials to provide secrets that don't exist on cluster", + }, + }) + } + + if len(merged) > 0 { + p.config.Logger.Debug("Using function credentials for rendering", + "resource", resourceID, + "credentialCount", len(merged), + "cliProvided", len(p.config.FunctionCredentials), + "autoFetched", len(fetched.Secrets)) + } + + return merged, nil +} + +// unsatisfiedCredentials returns the namespace/name keys of the absent secrets that the merged +// credential set does not cover, sorted so the advisory's context is byte-stable across runs (which is +// what lets identical advisories dedup). Keys are formed the same way mergeCredentials keys its map, +// so a CLI-supplied secret matches an absent cluster secret exactly when they name the same object. +func unsatisfiedCredentials(absent []k8stypes.NamespacedName, merged []corev1.Secret) []string { + if len(absent) == 0 { + return nil + } + + supplied := make(map[string]struct{}, len(merged)) + for _, cred := range merged { + supplied[fmt.Sprintf("%s/%s", cred.Namespace, cred.Name)] = struct{}{} + } + + var unsatisfied []string + + for _, ref := range absent { + key := fmt.Sprintf("%s/%s", ref.Namespace, ref.Name) + if _, ok := supplied[key]; !ok { + unsatisfied = append(unsatisfied, key) + } + } + + sort.Strings(unsatisfied) + + return unsatisfied +} + +// stabilityResult holds the result of a stability check iteration. +type stabilityResult struct { + stable bool + nextObserved []cpd.Unstructured + err error +} + +// checkStability determines if the render loop has reached a stable state. +// For synthesizeReady mode, it checks for new composed resources and synthesizes Ready status. +// For normal mode, it checks if requirements have been resolved. +func (p *DefaultDiffProcessor) checkStability( + output render.CompositionOutputs, + observed []cpd.Unstructured, + newReqCount int, + synthesizeReady bool, + resourceID string, + iteration int, + totalRequired int, +) stabilityResult { + if synthesizeReady { + return p.checkStabilityWithReadySynthesis(output, observed, newReqCount, resourceID, iteration) + } + + return p.checkRequirementsResolved(newReqCount, resourceID, iteration, totalRequired, observed) +} + +// checkStabilityWithReadySynthesis checks stability in eventual-state mode, where stable means that +// nothing would change once every composed resource is Ready: the iteration resolved no new +// requirements, and every resource it rendered is already observed with Ready=True. Any other +// iteration makes the rendered resources Ready in the observed state the next render sees, so that a +// stage gated on an earlier stage's readiness (function-sequencer, or a template's own Ready check) +// renders. A resource that already exists in the cluster but is not Ready yet needs that as much as +// one this render composed for the first time: Crossplane creates the next stage once it is Ready, +// whichever it is (#564). +// +// This terminates within the caller's iteration cap for any composition with finitely many composed +// resources: an unstable iteration that resolves no new requirement makes at least one more rendered +// resource Ready, and synthesis never takes Ready away again. +func (p *DefaultDiffProcessor) checkStabilityWithReadySynthesis( + output render.CompositionOutputs, + observed []cpd.Unstructured, + newReqCount int, + resourceID string, + iteration int, +) stabilityResult { + nextObserved, readinessChanges, err := synthesizeReadyObserved(observed, output.ComposedResources) + if err != nil { + return stabilityResult{err: errors.Wrapf(err, "failed to synthesize Ready status at iteration %d", iteration)} + } + + if readinessChanges == 0 && newReqCount == 0 { + p.config.Logger.Debug("Reached stable state", + "resource", resourceID, + "iterations", iteration, + "composedCount", len(output.ComposedResources)) + + return stabilityResult{stable: true, nextObserved: observed} + } + + p.config.Logger.Debug("Synthesized Ready status for next iteration", + "resource", resourceID, + "iteration", iteration, + "readinessChanges", readinessChanges, + "newReqCount", newReqCount, + "totalObserved", len(nextObserved)) + + return stabilityResult{stable: false, nextObserved: nextObserved} +} + +// checkRequirementsResolved checks stability in normal mode. +func (p *DefaultDiffProcessor) checkRequirementsResolved( + newReqCount int, + resourceID string, + iteration int, + totalRequired int, + observed []cpd.Unstructured, +) stabilityResult { + if newReqCount == 0 { + p.config.Logger.Debug("Requirements resolved", + "resource", resourceID, + "iterations", iteration) + + return stabilityResult{stable: true, nextObserved: observed} + } + + p.config.Logger.Debug("Found new requirements, continuing", + "resource", resourceID, + "iteration", iteration, + "newReqCount", newReqCount, + "totalRequired", totalRequired) + + return stabilityResult{stable: false, nextObserved: observed} +} + +// composedResourceKey creates a unique key for a composed resource based on its +// composition-resource-name annotation and GVK. +func composedResourceKey(res *cpd.Unstructured) string { + compResName := res.GetAnnotations()["crossplane.io/composition-resource-name"] + gvk := res.GroupVersionKind() + + return compResName + "/" + gvk.Group + "/" + gvk.Version + "/" + gvk.Kind +} + +// synthesizeReadyObserved returns the observed state the next eventual-state render sees once every +// rendered composed resource is Ready, and how many rendered resources that changed the readiness of. +// +// A rendered resource that is already observed keeps its observed copy, with only its Ready condition +// set: the rest of its status (status.atProvider, say) is real cluster state a composition may read, +// and the rendered copy has none of it. A rendered resource that is not observed yet enters as +// rendered, plus Ready, as there is nothing else to go on. An observed resource that is already Ready +// is left exactly as it is, so synthesis is idempotent; and observed resources the render no longer +// produced are left untouched. Neither input is modified: synthesized Ready must reach only the next +// render's observed state, never the caller's copy of the cluster state. +func synthesizeReadyObserved(observed, rendered []cpd.Unstructured) ([]cpd.Unstructured, int, error) { + byKey := make(map[string]cpd.Unstructured, len(observed)+len(rendered)) + for _, obs := range observed { + byKey[composedResourceKey(&obs)] = obs + } + + now := metav1.Now() + changes := 0 + + for _, res := range rendered { + key := composedResourceKey(&res) + + base, ok := byKey[key] + if ok && base.GetCondition(xpv2.TypeReady).Status == corev1.ConditionTrue { + continue + } + + if !ok { + base = res + } + + ready := base.DeepCopy() + if err := setReadyCondition(ready, now); err != nil { + return nil, 0, errors.Wrapf(err, "cannot set Ready condition on resource %s", res.GetName()) + } + + byKey[key] = *ready + changes++ + } + + return slices.Collect(maps.Values(byKey)), changes, nil +} + +// setReadyCondition sets a Ready=True condition on the resource's status.conditions field. +func setReadyCondition(res *cpd.Unstructured, now metav1.Time) error { + conditionsRaw, _, _ := un.NestedSlice(res.Object, "status", "conditions") + conditions := make([]any, 0, len(conditionsRaw)+1) + + // Copy existing conditions, excluding any existing Ready condition + for _, c := range conditionsRaw { + cond, ok := c.(map[string]any) + if !ok { + continue + } + + condType, _, _ := un.NestedString(cond, "type") + if condType != "Ready" { + conditions = append(conditions, cond) + } + } + + // Add the Ready condition + conditions = append(conditions, map[string]any{ + "type": "Ready", + "status": "True", + "reason": "Available", + "lastTransitionTime": now.Format(time.RFC3339), + }) + + if err := un.SetNestedSlice(res.Object, conditions, "status", "conditions"); err != nil { + return errors.Wrap(err, "cannot set status.conditions") + } + + return nil +} + +// removeNamespacesFromClusterScopedResources removes namespaces from cluster-scoped resources. +// +// WORKAROUND for an upstream gap. A namespaced XR that composes a cluster-scoped +// resource comes back from render with the XR's namespace stamped onto that +// resource, which would otherwise show up as a spurious diff. +// +// Rendering runs through `crossplane internal render`, which builds the *same* +// composer production uses (`composite.NewFunctionComposer`, from +// crossplane's internal/render/composite). Two pieces of that shared path +// matter here: +// +// - `composite.RenderComposedResourceMetadata` propagates unconditionally: +// `if xr.GetNamespace() != "" { cd.SetNamespace(xr.GetNamespace()) }`. +// - `FunctionComposer.Compose` guards that separately — for a namespaced XR it +// calls `client.IsObjectNamespaced(cd)` and rejects a cluster-scoped composed +// resource outright (errFmtNamespacedXRClusterResource). +// +// Production is therefore correct: the guard fires against a real API server. +// Under render it cannot, because render's in-memory client +// (crossplane's internal/render.InMemoryClient) implements +// `IsObjectNamespaced` as an unconditional `return true, nil` — it has no +// cluster to ask. Every composed resource looks namespaced, so nothing is +// rejected and the namespace stamp stands. +// +// So the gap is not "render propagates namespaces blindly" — it's that render's +// scope oracle is a stub, leaving an existing upstream guard inert. Fixing it +// upstream needs a scope answer render can actually compute offline; the shape +// of that (an injectable resolver vs. passing XRDs through RenderInputs) is an +// upstream design decision, and `internal/render` is not importable from here +// in any case. Note the stub's `true` default may well be deliberate for the +// offline case. +// +// We can answer it, because we do have a cluster: resolveResourceScope consults +// the discovery API. Hence this post-pass, which reverses the stamp for +// resources discovery reports as cluster-scoped. +// +// Remove this function and its call site in diffSingleResourceInternal once +// render can determine scope itself. +func (p *DefaultDiffProcessor) removeNamespacesFromClusterScopedResources(ctx context.Context, composedResources []cpd.Unstructured) error { + for i := range composedResources { + resource := &un.Unstructured{Object: composedResources[i].UnstructuredContent()} + + // Skip if resource has no namespace + if resource.GetNamespace() == "" { + continue + } + + resourceID := fmt.Sprintf("%s/%s", resource.GetKind(), resource.GetName()) + if resource.GetName() == "" && resource.GetGenerateName() != "" { + resourceID = fmt.Sprintf("%s/%s*", resource.GetKind(), resource.GetGenerateName()) + } + + // Check if resource is cluster-scoped. Prefer discovery because built-in + // Kubernetes resources like Secret and Namespace do not have CRDs. + gvk := resource.GroupVersionKind() + + scope, err := resolveResourceScope(ctx, p.resourceClient, p.schemaClient, p.config.Logger, gvk) + if err != nil { + return errors.Wrapf(err, "cannot determine scope for resource %s (GVK %s)", resourceID, gvk.String()) + } + + if scope == extv1.ClusterScoped { + p.config.Logger.Debug("Removing namespace from cluster-scoped resource", + "resource", resourceID, + "gvk", gvk.String(), + "namespace", resource.GetNamespace()) + resource.SetNamespace("") + + // Update the composed resource with the modified content + composedResources[i].SetUnstructuredContent(resource.Object) + } + } + + return nil +} + +// getCompositeResourceXRD checks if a resource is a Composite Resource (XR) by looking it up in XRDs. +// Returns true if the resource is an XR, along with its XRD. +// Returns false if it's not an XR or if there's an error (errors are logged but not returned). +func (p *DefaultDiffProcessor) getCompositeResourceXRD(ctx context.Context, resource *un.Unstructured) (bool, *un.Unstructured) { + gvk := resource.GroupVersionKind() + + p.config.Logger.Debug("Checking if resource is a composite resource", + "resource", fmt.Sprintf("%s/%s", resource.GetKind(), resource.GetName()), + "gvk", gvk.String()) + + // Check if there's an XRD that defines this GVK as an XR + xrd, err := p.defClient.GetXRDForXR(ctx, gvk) + if err == nil && xrd != nil { + p.config.Logger.Debug("Resource is a composite resource (XR)", + "resource", fmt.Sprintf("%s/%s", resource.GetKind(), resource.GetName()), + "xrd", xrd.GetName()) + + return true, xrd + } + + // Check if there's an XRD that defines this GVK as a claim + xrd, err = p.defClient.GetXRDForClaim(ctx, gvk) + if err == nil && xrd != nil { + p.config.Logger.Debug("Resource is a composite resource (Claim)", + "resource", fmt.Sprintf("%s/%s", resource.GetKind(), resource.GetName()), + "xrd", xrd.GetName()) + + return true, xrd + } + + // Not a composite resource + p.config.Logger.Debug("Resource is not a composite resource", + "resource", fmt.Sprintf("%s/%s", resource.GetKind(), resource.GetName())) + + return false, nil +} + +// fetchCompositionCredentials delegates to the credential client to fetch credential secrets +// referenced in a composition's pipeline steps from the cluster. +func (p *DefaultDiffProcessor) fetchCompositionCredentials(ctx context.Context, comp *apiextensionsv1.Composition) (types.CredentialFetchResult, error) { + if comp == nil || p.credentialClient == nil { + return types.CredentialFetchResult{}, nil + } + + return p.credentialClient.FetchCompositionCredentials(ctx, comp) +} + +// mergeCredentials combines CLI-provided credentials with auto-fetched credentials. +// CLI-provided credentials take precedence (override) over auto-fetched ones. +// The result is sorted by namespace/name for deterministic ordering. +func mergeCredentials(cliCredentials, autoFetchedCredentials []corev1.Secret) []corev1.Secret { + if len(cliCredentials) == 0 && len(autoFetchedCredentials) == 0 { + return nil + } + + // Create a map to deduplicate by namespace/name, with CLI credentials taking precedence + credMap := make(map[string]corev1.Secret) + + // Add auto-fetched credentials first + for _, cred := range autoFetchedCredentials { + key := fmt.Sprintf("%s/%s", cred.Namespace, cred.Name) + credMap[key] = cred + } + + // Override with CLI-provided credentials + for _, cred := range cliCredentials { + key := fmt.Sprintf("%s/%s", cred.Namespace, cred.Name) + credMap[key] = cred + } + + // Collect and sort keys for deterministic ordering + keys := make([]string, 0, len(credMap)) + for key := range credMap { + keys = append(keys, key) + } + + sort.Strings(keys) + + // Convert map to slice in sorted order + result := make([]corev1.Secret, 0, len(credMap)) + for _, key := range keys { + result = append(result, credMap[key]) + } + + return result +} diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go new file mode 100644 index 00000000..b99b6ae7 --- /dev/null +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -0,0 +1,4255 @@ +package diffprocessor + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + v1 "github.com/crossplane/function-sdk-go/proto/v1" + gcmp "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/sergi/go-diff/diffmatchpatch" + corev1 "k8s.io/api/core/v1" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/diff/types" +) + +// Test constants to avoid duplication. +const ( + testGroup = "example.org" + testKind = "XR1" + testPlural = "xr1s" + testSingular = "xr1" + testCRDName = testPlural + "." + testGroup + testXRDName = testCRDName + testAPIVersion = "v1" +) + +// Ensure MockDiffProcessor implements the DiffProcessor interface. +var _ DiffProcessor = &tu.MockDiffProcessor{} + +func TestDefaultDiffProcessor_removeNamespacesFromClusterScopedResources(t *testing.T) { + secretGVK := schema.GroupVersionKind{Version: "v1", Kind: "Secret"} + namespaceGVK := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"} + + clusterCRD := makeCRD("clusterthings.example.org", "ClusterThing", "example.org", "v1") + clusterCRD.Spec.Scope = extv1.ClusterScoped + + // Built-in kinds have no CRD, so a GetCRD call means the discovery path + // was skipped. Cases relying on discovery use this to catch that. + noCRDs := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.Errorf("GetCRD should not be called for %s when discovery resolves scope", gvk.String()) + }). + Build() + } + + tests := map[string]struct { + reason string + setupResource func() *tu.MockResourceClient + setupSchema func() *tu.MockSchemaClient + resources []cpd.Unstructured + wantNamespace []string + wantErr bool + wantErrMsg string + }{ + "BuiltInNamespacedResourceKeepsNamespace": { + reason: "A namespaced built-in has no CRD; discovery reports it namespaced so its namespace is preserved.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().WithNamespacedResource(secretGVK).Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{"default"}, + }, + "BuiltInClusterScopedResourceLosesNamespace": { + reason: "A cluster-scoped built-in has no CRD; discovery reports it cluster-scoped so render's namespace is stripped.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().WithClusterScopedResource(namespaceGVK).Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{""}, + }, + "MixedScopesResolveIndependently": { + reason: "Each resource's scope is resolved on its own; a cluster-scoped sibling does not affect a namespaced one.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(secretGVK). + WithClusterScopedResource(namespaceGVK). + Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), + *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{"default", ""}, + }, + "FallsBackToCRDWhenDiscoveryFails": { + reason: "When discovery cannot resolve a custom kind, the CRD supplies the scope.", + setupResource: func() *tu.MockResourceClient { + // No scopes configured, so IsNamespacedResource errors. + return tu.NewMockResourceClient().Build() + }, + setupSchema: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD("example.org", "ClusterThing", clusterCRD). + Build() + }, + resources: []cpd.Unstructured{ + *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{""}, + }, + "ErrorsWhenNeitherDiscoveryNorCRDResolvesScope": { + reason: "Scope must be known to proceed; an unresolvable kind fails the diff rather than guessing.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().Build() + }, + setupSchema: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.New("CRD not found") + }). + Build() + }, + resources: []cpd.Unstructured{ + *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + wantErr: true, + wantErrMsg: "cannot determine scope for resource ClusterThing/thing", + }, + "ResourceWithoutNamespaceIsSkipped": { + reason: "A resource render left unnamespaced needs no scope lookup at all.", + setupResource: func() *tu.MockResourceClient { + // Any scope lookup would error, proving none happened. + return tu.NewMockResourceClient().Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("example.org/v1", "ClusterThing", "thing").BuildUComposed(), + }, + wantNamespace: []string{""}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := &DefaultDiffProcessor{ + resourceClient: tt.setupResource(), + schemaClient: tt.setupSchema(), + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + }, + } + + err := processor.removeNamespacesFromClusterScopedResources(t.Context(), tt.resources) + + if tt.wantErr { + if err == nil { + t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): expected error but got none", tt.reason) + } + + if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): error %q doesn't contain %q", + tt.reason, err.Error(), tt.wantErrMsg) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): unexpected error: %v", tt.reason, err) + } + + got := make([]string, len(tt.resources)) + for i := range tt.resources { + got[i] = (&un.Unstructured{Object: tt.resources[i].UnstructuredContent()}).GetNamespace() + } + + if diff := gcmp.Diff(tt.wantNamespace, got); diff != "" { + t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): -want namespaces, +got:\n%s", tt.reason, diff) + } + }) + } +} + +// testProcessorOptions returns sensible default options for tests. +// Tests can append additional options or override these as needed. +// +// The default WithRenderFunc is a no-op that returns an empty CompositionOutputs. +// The production default would spin up a Docker engine, which unit tests cannot rely on. +// Tests that need specific render behavior should override via WithRenderFunc. +func testProcessorOptions(t *testing.T) []ProcessorOption { + t.Helper() + + return []ProcessorOption{ + WithColorize(false), + WithCompact(false), + WithLogger(tu.TestLogger(t, false)), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil + }), + } +} + +func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { + // Setup test context + ctx := t.Context() + + // Create test resources + resource1 := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + resource2 := tu.NewResource("example.org/v1", "XR1", "my-xr-2"). + WithSpecField("coolField", "test-value-2"). + Build() + + // Create a composition for testing + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + // The XR's XRD and CRD. The effective XR is defaulted before its composition is resolved, so even + // the cases that fail resolving it need them. + testXRD := tu.NewXRD(testXRDName, testGroup, testKind).WithVersion("v1", true, true).BuildAsUnstructured() + testCRD := makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion) + + // Create a composed resource for testing + composedResource := tu.NewResource("cpd.org/v1", "ComposedResource", "resource1"). + WithCompositeOwner("my-xr-1"). + WithCompositionResourceName("resA"). + WithSpecField("param", "value"). + Build() + + // Test cases + tests := map[string]struct { + setupMocks func() (k8.Clients, xp.Clients) + resources []*un.Unstructured + processorOpts []ProcessorOption + verifyOutput func(t *testing.T, output string) + want error + validationError bool + }{ + "NoResources": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{}, + processorOpts: testProcessorOptions(t), + want: nil, + }, + "DiffSingleResourceError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + // Note: Error output now goes to stderr (see TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput) + // This test verifies the error return value, not stderr output + verifyOutput: nil, + want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), + }, + "MultipleResourceErrors": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1, resource2}, + processorOpts: testProcessorOptions(t), + // Note: Error output now goes to stderr (see TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput) + // This test verifies the error return value, not stderr output + verifyOutput: nil, + want: errors.New("[unable to process resource XR1/my-xr-1: cannot get composition: composition not found, " + + "unable to process resource XR1/my-xr-2: cannot get composition: composition not found]"), + }, + "CompositionNotFound": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), + }, + "GetFunctionsError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithFailedFunctionsFetch("function not found"). + Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + want: errors.New("unable to process resource XR1/my-xr-1: cannot get functions for composition: cannot get functions from pipeline: function not found"), + }, + "SuccessfulDiff": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create mock functions that render will call successfully + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-test", + }, + }, + } + + // Create CRDs upfront to avoid recreating them in closures + mainCRD := makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion) + composedCRD := makeTestCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1") + + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(resource1, composedResource). // Add resources to existing resources + WithResourcesFoundByLabel([]*un.Unstructured{composedResource}, "crossplane.io/composite", "test-xr"). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testGroup && gvk.Kind == testKind { + return mainCRD, nil + } + + if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { + return composedCRD, nil + } + + return nil, errors.New("CRD not found") + }). + WithGetCRDByName(func(name string) (*extv1.CustomResourceDefinition, error) { + if name == testCRDName { + return mainCRD, nil + } + + if name == "composedresources.cpd.org" { + return composedCRD, nil + } + + return nil, errors.Errorf("CRD with name %s not found", name) + }). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create XRD for composed resource + composedXRD := tu.NewXRD("composedresources.cpd.org", "cpd.org", "ComposedResource"). + WithPlural("composedresources"). + WithSingular("composedresource"). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "param": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create main XRD + mainXRD := tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "field": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithEmptyXRDsFetch(). + WithXRDForGVK(schema.GroupVersionKind{Group: testGroup, Version: "v1", Kind: testKind}, mainXRD). + WithXRDForGVK(schema.GroupVersionKind{Group: "cpd.org", Version: "v1", Kind: "ComposedResource"}, composedXRD). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: append(testProcessorOptions(t), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Only return composed resources for the main XR, not for nested XRs + // to avoid infinite recursion + if in.CompositeResource.GetKind() == testKind { + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + { + Unstructured: un.Unstructured{ + Object: composedResource.Object, + }, + }, + }, + }, nil + } + // For nested XRs, just return the XR itself with no composed resources + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + }, nil + }), + // Override the schema validator factory to use a simple validator + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return nil + }, + } + }), + // Override the diff calculator factory to return actual diffs + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator { + return &tu.MockDiffCalculator{ + CalculateNonRemovalDiffsFn: func(context.Context, *cmp.Unstructured, render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + diffs := make(map[string]*dt.ResourceDiff) + + var rendered types.RenderedResources + + // Add a modified diff (not just equal) + lineDiffs := []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffDelete, Text: " field: old-value"}, + {Type: diffmatchpatch.DiffInsert, Text: " field: new-value"}, + } + + diffKey1 := "example.org/v1/XR1/test-xr" + diffs[diffKey1] = &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XR1"}, + ResourceName: "test-xr", + DiffType: dt.DiffTypeModified, + LineDiffs: lineDiffs, // Add line diffs + Current: dt.ResourceViews{Raw: resource1, Clean: resource1}, // for completeness + Desired: dt.ResourceViews{Raw: resource1, Clean: resource1}, // for completeness + } + rendered.AddKey(diffKey1) + + // Add a composed resource diff that's also modified + diffKey2 := "example.org/v1/ComposedResource/resource-a" + diffs[diffKey2] = &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ComposedResource"}, + ResourceName: "resource-a", + DiffType: dt.DiffTypeModified, + LineDiffs: lineDiffs, + Current: dt.ResourceViews{Raw: composedResource, Clean: composedResource}, + Desired: dt.ResourceViews{Raw: composedResource, Clean: composedResource}, + } + rendered.AddKey(diffKey2) + + return diffs, rendered, nil + }, + } + }), + // Override the diff renderer factory to produce actual output + // The factory receives DiffOptions which contains Stdout where output should be written + WithDiffRendererFactory(func(_ logging.Logger, opts renderer.DiffOptions) renderer.DiffRenderer { + return &tu.MockDiffRenderer{ + RenderDiffsFn: func(_ []dt.XRDiffGroup, _ []dt.OutputError, _ []dt.OutputWarning) error { + // Write a simple summary to the output via opts.Stdout + w := opts.Stdout + + _, err := fmt.Fprintln(w, "Changes will be applied to 2 resources:") + if err != nil { + return err + } + + _, err = fmt.Fprintln(w, "- example.org/v1/XR1/test-xr will be modified") + if err != nil { + return err + } + + _, err = fmt.Fprintln(w, "- example.org/v1/ComposedResource/resource-a will be modified") + if err != nil { + return err + } + + _, err = fmt.Fprintln(w, "\nSummary: 0 to create, 2 to modify, 0 to delete") + + return err + }, + } + }), + ), + verifyOutput: func(t *testing.T, output string) { + t.Helper() + // We should have some output from the diff + if output == "" { + t.Errorf("Expected non-empty diff output") + } + + // Simple check for expected output format + if !strings.Contains(output, "Summary:") { + t.Errorf("Expected diff output to contain a Summary section") + } + }, + want: nil, + }, + "ValidationError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create mock functions that render will call successfully + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-test", + }, + }, + } + + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(resource1). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testGroup && gvk.Kind == testKind { + return makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion), nil + } + + if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { + return makeTestCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1"), nil + } + + return nil, errors.New("CRD not found") + }). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + BuildAsUnstructured()). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + // resource1 exists in the cluster, so its observed resources + // are fetched before validation runs. That fetch has to + // succeed for this case to reach the validation failure it is + // actually about. + ResourceTree: tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: append(testProcessorOptions(t), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Return valid render outputs + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + { + Unstructured: un.Unstructured{ + Object: composedResource.Object, + }, + }, + }, + }, nil + }), + // Override with a validator that fails + WithSchemaValidatorFactory(func(_ k8.SchemaClient, _ k8.ResourceClient, _ xp.DefinitionClient, _ logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return errors.New("validation error") + }, + } + }), + ), + want: errors.New("unable to process resource XR1/my-xr-1: cannot validate resources: validation error"), + validationError: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create components for testing + k8sClients, xpClients := tt.setupMocks() + + // Create stdout buffer and add it to processor options so renderers can access it + var stdout bytes.Buffer + + opts := append([]ProcessorOption{}, tt.processorOpts...) + opts = append(opts, WithStdout(&stdout)) + + // Create the diff processor + processor := NewDiffProcessor(k8sClients, xpClients, opts...) + + // Create a mock composition provider that uses the same mock composition client + compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + } + _, err := processor.PerformDiff(ctx, tt.resources, compositionProvider) + + // Check output if verification function is provided (do this first, before error checks) + if tt.verifyOutput != nil { + tt.verifyOutput(t, stdout.String()) + } + + if tt.want != nil { + if err == nil { + t.Errorf("PerformDiff(...): expected error but got none") + return + } + + if diff := gcmp.Diff(tt.want.Error(), err.Error()); diff != "" { + t.Errorf("PerformDiff(...): -want error, +got error:\n%s", diff) + } + + return + } + + if err != nil { + t.Errorf("PerformDiff(...): unexpected error: %v", err) + } + }) + } +} + +// Note: PerformDiff's per-XR grouping structure is verified end-to-end (real +// renderer, real JSON) by TestDiffIntegration/MultipleXRsGroupedByInputXR, and +// the per-group xrs[] shape — including errored groups — by +// TestStructuredDiffRenderer_GroupsByXR. The identity each group carries, and +// how the input validator's verdicts land, are asserted on the intermediate +// []XRDiffGroup handoff by TestDefaultDiffProcessor_PerformDiff_Groups. + +// TestDefaultDiffProcessor_PerformDiff_Groups pins how PerformDiff hands its groups to the renderer. With +// a mock InputValidator it pins the wiring: only what ToRender returns is rendered, and an input it +// rejected is not; every render is recorded; and each Verdicts and RenderOverlaps error lands as a group +// error (replacing that group's diffs), in errors[], and on the returned error. With the real default +// validator it pins what PerformDiff itself decides: the group identity of a generateName-only XR (issue +// #477), and the NameGenerated mark the overlap check relies on. The validation rules themselves are +// tested directly in input_validator_test.go and end to end in diff_integration_test.go; add rule cases +// there, not here. +func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { + ctx := t.Context() + + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef(testGroup+"/"+testAPIVersion, testKind). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + functions := []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "function-test"}}} + + xrd := tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + BuildAsUnstructured() + + // sharedKey is returned by the diff calculator for every input XR, so two + // inputs collide on it. + const sharedKey = "example.org/v1/Bucket/default/shared" + + // bucket is the shared resource as one input XR's render produces it, controlled by the XR named + // controller, with spec.value set to value. renderedBy only varies the controller reference's UID, + // the way a render synthesizes a fresh UID for an XR that does not exist yet. Clean mirrors + // cleanupForDiff, which strips ownerReferences (and uid) before anything is compared or displayed. + bucket := func(controller, value, renderedBy string) *dt.ResourceDiff { + desired := tu.NewResource("example.org/v1", "Bucket", "shared"). + InNamespace("default"). + WithSpecField("value", value). + WithControllerReference(testKind, controller, testGroup+"/"+testAPIVersion, "uid-rendered-by-"+renderedBy). + Build() + clean := desired.DeepCopy() + clean.SetOwnerReferences(nil) + + return &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + Namespace: "default", + ResourceName: "shared", + DiffType: dt.DiffTypeModified, + Desired: dt.ResourceViews{Raw: desired, Clean: clean}, + } + } + + xr := func(name string) *un.Unstructured { + return tu.NewResource(testGroup+"/"+testAPIVersion, testKind, name).WithSpecField("coolField", name).Build() + } + + ref := func(name string) corev1.ObjectReference { + return corev1.ObjectReference{APIVersion: testGroup + "/" + testAPIVersion, Kind: testKind, Name: name} + } + + // recorded is one RecordRender call. + type recorded struct { + I int + Rendered map[string]bool + Err string + } + + // got is the whole handoff, asserted as one value: the group identities in + // input order, each errored group's message by name, the global (union) + // error list the renderer was given, the error PerformDiff returned, and + // (with a mock validator) the renders it recorded. + type got struct { + XRs []corev1.ObjectReference + GroupErrs map[string]string + GlobalErrs []string + Err string + Recorded []recorded + } + + const ( + generatedErr = `cannot combine diffs: inputs XR1/gen-xr-(generated), XR1/gen-xr-(generated) both produce resource ` + + `"example.org/v1/Bucket/default/shared" only because crossplane-diff gives XRs that share a generateName the ` + + `same placeholder name; the API server would name them differently, so they cannot be told apart here — diff ` + + `them separately` + + // renderFailedErr is how a render failure reaches RecordRender: wrapped by the render path. + renderFailedErr = "cannot calculate diffs for composed resources: render failed" + ) + + genXR := tu.NewResource(testGroup+"/"+testAPIVersion, testKind, ""). + WithGenerateName("gen-xr-"). + WithSpecField("coolField", "value"). + Build() + + // validation is what a mock validator returns: ToRender's list, and Verdicts and RenderOverlaps + // errors. A nil validation means the real default validator. + type validation struct { + toRender []types.ValidatedInput + verdicts []error + overlaps []error + } + + tests := map[string]struct { + resources []*un.Unstructured + validator *validation + // failRender names an input XR whose render fails. + failRender string + want got + }{ + // An input ToRender already rejected is not rendered; its verdict is a group error, in errors[], + // and on the returned error. + "InputRejectedBeforeRenderingIsNotRendered": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + validator: &validation{ + toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}, {Resource: xr("my-xr-2"), Err: errors.New("rejected")}}, + verdicts: []error{nil, errors.New("rejected")}, + }, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + GroupErrs: map[string]string{"my-xr-2": "rejected"}, + GlobalErrs: []string{"rejected"}, + Err: "unable to process resource XR1/my-xr-2: rejected", + Recorded: []recorded{{I: 0, Rendered: map[string]bool{sharedKey: true}}}, + }, + }, + // A Verdicts error on a group that rendered fine replaces its diffs. + "VerdictReplacesAGroupsDiffs": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + validator: &validation{ + toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}, {Resource: xr("my-xr-2")}}, + verdicts: []error{nil, errors.New("verdict")}, + }, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + GroupErrs: map[string]string{"my-xr-2": "verdict"}, + GlobalErrs: []string{"verdict"}, + Err: "unable to process resource XR1/my-xr-2: verdict", + Recorded: []recorded{ + {I: 0, Rendered: map[string]bool{sharedKey: true}}, + {I: 1, Rendered: map[string]bool{sharedKey: true}}, + }, + }, + }, + "RenderOverlapsReachErrorsAndTheReturnedError": { + resources: []*un.Unstructured{xr("my-xr-1")}, + validator: &validation{ + toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}}, + overlaps: []error{errors.New("overlap")}, + }, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1")}, + GlobalErrs: []string{"overlap"}, + Err: "overlap", + Recorded: []recorded{{I: 0, Rendered: map[string]bool{sharedKey: true}}}, + }, + }, + // Only what ToRender returns is rendered: a duplicate it dropped yields no group. + "OnlyWhatToRenderReturnsIsRendered": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-1")}, + validator: &validation{toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}}}, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1")}, + Recorded: []recorded{{I: 0, Rendered: map[string]bool{sharedKey: true}}}, + }, + }, + // RecordRender sees each render's keys and its error; the error is reported only if Verdicts + // returns it, which this mock does not. + "RecordRenderReceivesEachRender": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + validator: &validation{toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}, {Resource: xr("my-xr-2")}}}, + failRender: "my-xr-2", + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + Recorded: []recorded{ + {I: 0, Rendered: map[string]bool{sharedKey: true}}, + {I: 1, Err: renderFailedErr}, + }, + }, + }, + // Issue #477: the name used for rendering is synthesized from + // generateName inside SanitizeXR, so the identity must carry that + // effective name rather than the input's empty metadata.name. + "GenerateNameOnlyXR": { + resources: []*un.Unstructured{genXR}, + want: got{XRs: []corev1.ObjectReference{ref("gen-xr-(generated)")}}, + }, + // PerformDiff marks a group whose name it synthesized, which is the only way the overlap check + // can tell two generateName-only inputs apart from one XR reached twice. Without it these + // identical renderings would merge, reporting one XR's changes for two. + "SharedGenerateNameReachesTheOverlapCheck": { + resources: []*un.Unstructured{genXR, genXR.DeepCopy()}, + want: got{ + XRs: []corev1.ObjectReference{ref("gen-xr-(generated)"), ref("gen-xr-(generated)")}, + GlobalErrs: []string{generatedErr}, + Err: generatedErr, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().WithSuccessfulCompositionMatch(composition).Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + Environment: tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + Function: tu.NewMockFunctionClient().WithSuccessfulFunctionsFetch(functions).Build(), + ResourceTree: tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + } + + var ( + gotGroups []dt.XRDiffGroup + gotErrs []dt.OutputError + gotRecorded []recorded + ) + + opts := append(testProcessorOptions(t), + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return nil + }, + } + }), + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator { + return &tu.MockDiffCalculator{ + CalculateNonRemovalDiffsFn: func(_ context.Context, rendered *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + if rendered.GetName() == tt.failRender { + return nil, types.RenderedResources{}, errors.New("render failed") + } + + diff := bucket("parent", "same", rendered.GetName()) + + var keys types.RenderedResources + keys.AddKey(sharedKey) + + return map[string]*dt.ResourceDiff{sharedKey: diff}, keys, nil + }, + } + }), + WithDiffRendererFactory(func(logging.Logger, renderer.DiffOptions) renderer.DiffRenderer { + return &tu.MockDiffRenderer{ + RenderDiffsFn: func(groups []dt.XRDiffGroup, errs []dt.OutputError, _ []dt.OutputWarning) error { + gotGroups = groups + gotErrs = errs + + return nil + }, + } + }), + ) + + if v := tt.validator; v != nil { + opts = append(opts, WithInputValidatorFactory(func(dt.Advisories, []*un.Unstructured) InputValidator { + return &tu.MockInputValidator{ + ToRenderFn: func() []types.ValidatedInput { return v.toRender }, + RecordRenderFn: func(i int, rendered map[string]bool, err error) { + gotRecorded = append(gotRecorded, recorded{I: i, Rendered: rendered, Err: errMessage(err)}) + }, + VerdictsFn: func(groups []dt.XRDiffGroup) []error { + if v.verdicts == nil { + return make([]error, len(groups)) + } + + return v.verdicts + }, + RenderOverlapsFn: func([]dt.XRDiffGroup) []error { return v.overlaps }, + } + })) + } + + processor := NewDiffProcessor(k8sClients, xpClients, opts...) + + _, err := processor.PerformDiff(ctx, tt.resources, func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + }) + + result := got{Recorded: gotRecorded} + + for _, g := range gotGroups { + result.XRs = append(result.XRs, g.XR) + + if g.Err == nil { + continue + } + + if g.Diffs != nil { + t.Errorf("group %s/%s carries an error and diffs; an errored input must emit no partial result", g.XR.Kind, g.XR.Name) + } + + if result.GroupErrs == nil { + result.GroupErrs = map[string]string{} + } + + result.GroupErrs[g.XR.Name] = g.Err.Message + } + + for _, e := range gotErrs { + result.GlobalErrs = append(result.GlobalErrs, e.Message) + } + + if err != nil { + result.Err = err.Error() + } + + if diff := gcmp.Diff(tt.want, result); diff != "" { + t.Errorf("PerformDiff(...) group handoff: -want, +got:\n%s", diff) + } + }) + } +} + +// TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput pins the warning channel's +// contract for advisories that can only be discovered during teardown. The leftover-function-container +// advisory is raised from FunctionProvider.Cleanup, which used to run in the command layer's defer — +// i.e. after the renderer had already consumed and serialized the warning slice — so it reached stderr +// but was structurally absent from warnings[], contradicting the README. PerformDiff therefore tears +// down before rendering, and this test fails if that ordering is lost. +func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *testing.T) { + ctx := t.Context() + + const advisory = "Some containers could not be cleaned up" + + var stdout, stderr bytes.Buffer + + advisories := NewAdvisoryCollector(&stderr) + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // The XR's own diff fails (no matching composition), which additionally pins the README's claim + // that a warning is still reported when a later step fails — the advisory has to survive alongside + // errors[], not instead of it. + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().WithNoMatchingComposition().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().Build(), + Environment: tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, + append(testProcessorOptions(t), + WithAdvisories(advisories), + WithOutputFormat(renderer.OutputFormatJSON), + WithStdout(&stdout), + WithStderr(&stderr), + // Stands in for CachedFunctionProvider.Cleanup raising its advisory when a container could + // not be removed; the point under test is WHEN cleanup runs, not how it detects leftovers. + WithFunctionProviderFactory(func(xp.FunctionClient, logging.Logger) FunctionProvider { + return &tu.MockFunctionProvider{ + CleanupFn: func(context.Context) error { + advisories.Raise(dt.OutputWarning{Message: advisory, Context: map[string]string{"errors": "1"}}) + return nil + }, + } + }), + )..., + ) + + resource := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + if _, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, xpClients.Composition.FindMatchingComposition); err == nil { + t.Fatal("PerformDiff(): expected the XR's diff to fail, got nil") + } + + if !strings.Contains(stderr.String(), advisory) { + t.Errorf("teardown advisory should reach stderr, got:\n%s", stderr.String()) + } + + // Decoded via the wire contract rather than the renderer's own struct: what matters is that a + // machine consumer reading warnings[] out of the JSON sees the advisory. + var got struct { + Warnings []dt.OutputWarning `json:"warnings"` + Errors []dt.OutputError `json:"errors"` + } + + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("cannot parse structured output %q: %v", stdout.String(), err) + } + + want := []dt.OutputWarning{{Message: advisory, Context: map[string]string{"errors": "1"}}} + if diff := gcmp.Diff(want, got.Warnings); diff != "" { + t.Errorf("structured warnings[] mismatch (-want +got):\n%s", diff) + } + + if len(got.Errors) != 1 { + t.Errorf("expected the XR's failure to still be reported in errors[], got %v", got.Errors) + } +} + +// TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput verifies that when +// resource processing fails, detailed errors are written to stderr for human visibility. +// This tests the WithStderr option and the stderr error output path. +func TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput(t *testing.T) { + ctx := t.Context() + + // Create test resource + resource := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + // Create stderr buffer to capture error output + var stderrBuf bytes.Buffer + + // Create Kubernetes client mocks. The XR's CRD is needed because the effective XR is defaulted + // before its composition is resolved. + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks with a failing composition match + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(tu.NewXRD(testXRDName, testGroup, testKind).WithVersion("v1", true, true).BuildAsUnstructured()). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + // Create processor with custom stderr buffer + processor := NewDiffProcessor(k8sClients, xpClients, + append(testProcessorOptions(t), + WithStderr(&stderrBuf), // Inject test buffer to capture stderr + )..., + ) + + // Create composition provider using mock client + compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + } + + // Run the diff + _, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, compositionProvider) + + // Should return an error + if err == nil { + t.Fatal("PerformDiff() expected error but got none") + } + + // Verify stderr contains the error output + stderrOutput := stderrBuf.String() + + // The error should be formatted using FormatError() which produces: + // "ERROR: {ResourceID}: {Message}" + if !strings.Contains(stderrOutput, "ERROR: XR1/my-xr-1:") { + t.Errorf("Expected stderr to contain 'ERROR: XR1/my-xr-1:', got: %q", stderrOutput) + } + + if !strings.Contains(stderrOutput, "composition not found") { + t.Errorf("Expected stderr to contain 'composition not found' error detail, got: %q", stderrOutput) + } +} + +// TestDefaultDiffProcessor_warnIfDeleting covers the `xr`-side counterpart to comp's deleting-XR +// exclusion (issue #452). `xr` diffs exactly the resource the user named, so a deleting cluster copy +// is warned about rather than skipped. The warning travels through the advisory channel, so this +// asserts both halves of the dual emission: the stderr line and the collected structured warning. +// Only top-level XRs are flagged, and an explicitly-null deletionTimestamp is not mistaken for a +// deleting resource. +func TestDefaultDiffProcessor_warnIfDeleting(t *testing.T) { + deleting := tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithDeletionTimestamp("2026-09-07T11:25:03Z").Build() + + tests := map[string]struct { + existing *un.Unstructured + parentXR *cmp.Unstructured + wantStderr string + wantWarnings []dt.OutputWarning + }{ + "NotInCluster_NoWarning": { + existing: nil, + }, + "NotDeleting_NoWarning": { + existing: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + "NullDeletionTimestamp_NoWarning": { + existing: tu.NewResource("example.org/v1", "XR1", "my-xr").WithDeletionTimestamp(nil).Build(), + }, + "Deleting_Warns": { + existing: deleting, + wantStderr: "WARNING: The resource being diffed is being deleted in the cluster; the diff compares against a resource that is going away " + + "(deletionTimestamp=2026-09-07T11:25:03Z, resource=XR1/my-xr)\n", + wantWarnings: []dt.OutputWarning{{ + Message: "The resource being diffed is being deleted in the cluster; the diff compares against a resource that is going away", + Context: map[string]string{ + "resource": "XR1/my-xr", + "deletionTimestamp": "2026-09-07T11:25:03Z", + }, + }}, + }, + // Composed resources of a live XR churn through deletion routinely; warning per nested XR + // would be noise, so only the top-level XR (parentXR == nil) is flagged. + "DeletingNestedXR_NoWarning": { + existing: deleting, + parentXR: cmp.New(), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var stderrBuf bytes.Buffer + + advisories := NewAdvisoryCollector(&stderrBuf) + + processor := &DefaultDiffProcessor{ + config: ProcessorConfig{ + Stderr: &stderrBuf, + Logger: tu.TestLogger(t, false), + Advisories: advisories, + }, + } + + processor.warnIfDeleting(tt.existing, tt.parentXR, "XR1/my-xr") + + if diff := gcmp.Diff(tt.wantStderr, stderrBuf.String()); diff != "" { + t.Errorf("stderr mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.wantWarnings, advisories.Advisories()); diff != "" { + t.Errorf("collected warnings mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultDiffProcessor_Initialize(t *testing.T) { + // Setup test context + ctx := t.Context() + + // Create test resources + xrd1 := tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xrd1"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{ + "kind": "XExampleResource", + "plural": "xexampleresources", + "singular": "xexampleresource", + }). + Build() + + // Test cases + tests := map[string]struct { + setupMocks func() (k8.Clients, xp.Clients) + processorOpts []ProcessorOption + want error + }{ + "XRDsError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks with a failing Definition client + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithFailedXRDsFetch("XRD not found"). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + processorOpts: testProcessorOptions(t), + want: errors.Wrap(errors.Wrap(errors.New("XRD not found"), "cannot get XRDs"), "cannot load CRDs"), + }, + "EnvConfigsError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks with a failing Environment client + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithEmptyXRDsFetch(). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithGetEnvironmentConfigs(func(_ context.Context) ([]*un.Unstructured, error) { + return nil, errors.New("env configs not found") + }). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + processorOpts: testProcessorOptions(t), + want: errors.Wrap(errors.New("env configs not found"), "cannot get environment configs"), + }, + "Success": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter(). + WithDefaultGVKToGVR(). + Build(), + } + + // Create Crossplane client mocks with successful initialization + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithSuccessfulXRDsFetch([]*un.Unstructured{xrd1}). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + processorOpts: testProcessorOptions(t), + want: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // Get the clients for this test + k8sClients, xpClients := tc.setupMocks() + + // Build processor options + options := tc.processorOpts + + // Create the processor + processor := NewDiffProcessor(k8sClients, xpClients, options...) + + // Call the Initialize method + err := processor.Initialize(ctx) + + // Verify error expectations + if tc.want != nil { + if err == nil { + t.Errorf("Initialize(...): expected error but got none") + return + } + + if diff := gcmp.Diff(tc.want.Error(), err.Error()); diff != "" { + t.Errorf("Initialize(...): -want error, +got error:\n%s", diff) + } + + return + } + + if err != nil { + t.Errorf("Initialize(...): unexpected error: %v", err) + } + }) + } +} + +func TestDefaultDiffProcessor_RenderToStableState(t *testing.T) { + ctx := t.Context() + + // Create test resources + xr := tu.NewResource("example.org/v1", "XR", "test-xr").BuildUComposite() + + // Create a composition with pipeline mode + pipelineMode := apiextensionsv1.CompositionModePipeline + composition := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-composition", + }, + Spec: apiextensionsv1.CompositionSpec{ + Mode: pipelineMode, + }, + } + + // Create test functions + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test-function", + }, + }, + } + + // Create test resources for requirements + const ( + ConfigMap = "ConfigMap" + ConfigMapName = "config1" + ) + + configMap := tu.NewResource("v1", ConfigMap, ConfigMapName).Build() + secret := tu.NewResource("v1", "Secret", "secret1").Build() + + tests := map[string]struct { + xr *cmp.Unstructured + composition *apiextensionsv1.Composition + functions []pkgv1.Function + resourceID string + observedResources []cpd.Unstructured + setupResourceClient func() *tu.MockResourceClient + setupEnvironmentClient func() *tu.MockEnvironmentClient + setupRenderFunc func() RenderFn + wantComposedCount int + wantRenderIterations int + wantErr bool + }{ + "NoRequirements": { + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + iteration := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + iteration++ + // Return a simple output with no requirements + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "composed1", + }, + }}}, + }, + }, nil + } + }, + wantComposedCount: 1, + wantRenderIterations: 1, // Only renders once when no requirements + wantErr: false, + }, + "SingleIterationWithRequirements": { + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Kind == ConfigMap && name == ConfigMapName { + return configMap, nil + } + + return nil, errors.New("resource not found") + }). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + iteration := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + iteration++ + + // First render includes requirements, second should have no requirements + var reqs []*v1.ResourceSelector + if iteration == 1 { + reqs = []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: ConfigMapName, + }, + }, + } + } + + // Return a simple output + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "composed1", + }, + }}}, + }, + RequiredResources: reqs, + }, nil + } + }, + wantComposedCount: 1, + wantRenderIterations: 2, // Renders once with requirements, then once more to confirm no new requirements + wantErr: false, + }, + "MultipleIterationsWithRequirements": { + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Kind == ConfigMap && name == ConfigMapName { + return configMap, nil + } + + if gvk.Kind == "Secret" && name == "secret1" { + return secret, nil + } + + return nil, errors.New("resource not found") + }). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + iteration := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + iteration++ + + // Track existing resources to simulate dependencies + hasConfig := false + hasSecret := false + + for _, res := range in.RequiredResources { + if res.GetKind() == ConfigMap && res.GetName() == ConfigMapName { + hasConfig = true + } + + if res.GetKind() == "Secret" && res.GetName() == "secret1" { + hasSecret = true + } + } + + // Build requirements based on what we already have + var requirements []*v1.ResourceSelector + + if !hasConfig { + // First iteration - request ConfigMap + requirements = []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: ConfigMapName, + }, + }, + } + } else if !hasSecret { + // Second iteration - request Secret + requirements = []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: "Secret", + Match: &v1.ResourceSelector_MatchName{ + MatchName: "secret1", + }, + }, + } + } + + // Return a simple output + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "composed1", + }, + }}}, + }, + RequiredResources: requirements, + }, nil + } + }, + wantComposedCount: 1, + wantRenderIterations: 3, // Iterations: 1. Request ConfigMap, 2. Request Secret, 3. No more requirements + wantErr: false, + }, + "RenderError": { + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + return func(context.Context, logging.Logger, RenderInputs) (render.CompositionOutputs, error) { + return render.CompositionOutputs{}, errors.New("render error") + } + }, + wantComposedCount: 0, + wantRenderIterations: 1, + wantErr: true, + }, + "RenderErrorWithRequirements": { + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Kind == ConfigMap && name == ConfigMapName { + return configMap, nil + } + + return nil, errors.New("resource not found") + }). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + iteration := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + iteration++ + + // First render has requirements but errors + if iteration == 1 { + reqs := []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: ConfigMapName, + }, + }, + } + + return render.CompositionOutputs{ + RequiredResources: reqs, + }, errors.New("render error with requirements") + } + + // Second render succeeds + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "composed1", + }, + }}}, + }, + }, nil + } + }, + wantComposedCount: 1, + wantRenderIterations: 2, // Renders once with error but requirements, then once more successfully + wantErr: false, // Should not error as the second render succeeds + }, + "RenderErrorWithCachedRequirements": { + // Regression test: render fails with a fatal error and returns requirements, + // but all requirements are already cached (newReqCount==0). The error must be + // returned instead of silently dropped. Previously the condition also required + // len(output.Requirements)==0, which let errors fall through to checkStability + // and return an output with nil CompositeResource, causing a SIGSEGV. + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Kind == ConfigMap && name == ConfigMapName { + return configMap, nil + } + + return nil, errors.New("resource not found") + }). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + reqs := []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: ConfigMapName, + }, + }, + } + + iteration := 0 + + return func(_ context.Context, _ logging.Logger, _ RenderInputs) (render.CompositionOutputs, error) { + iteration++ + + // Every iteration returns the same requirements AND the same error. + // After iteration 1, the requirement is already cached so newReqCount==0. + return render.CompositionOutputs{ + RequiredResources: reqs, + }, errors.New("fatal template error: assignment to entry in nil map") + } + }, + wantComposedCount: 0, + wantRenderIterations: 2, // First render resolves the requirement, second sees no new requirements + wantErr: true, // Must return error, not silently swallow it + }, + "RequirementsProcessingError": { + // A transport-level / non-NotFound failure during requirement + // resolution must still propagate (e.g. RBAC denial, API server + // unreachable). The NotFound case is exercised in + // RequirementsNotFoundConverges below. + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithGetResourceError(errors.New("forbidden: user cannot get configmaps")). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + reqs := []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: "missing-config", + }, + }, + } + + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + RequiredResources: reqs, + }, nil + } + }, + wantComposedCount: 0, + wantRenderIterations: 1, + wantErr: true, // Non-NotFound errors still surface. + }, + "RequirementsNotFoundConverges": { + // Regression test for crossplane-contrib/crossplane-diff#355: a + // matchName selector whose target does not exist must NOT abort + // the diff. The render loop converges on iteration 1 — render + // emits the selector, ResolveSelectors silently skips the + // NotFound (mirrors upstream xfn.required_resources.go), no new + // requirements are accumulated, the stability check fires. + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithResourceNotFound(). + Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + reqs := []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: ConfigMap, + Match: &v1.ResourceSelector_MatchName{ + MatchName: "missing-config", + }, + }, + } + + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + RequiredResources: reqs, + }, nil + } + }, + wantComposedCount: 0, + wantRenderIterations: 1, + wantErr: false, + }, + "ObservedResourcesPassedToRenderFunc": { + xr: xr, + composition: composition, + functions: functions, + resourceID: "XR/test-xr", + observedResources: []cpd.Unstructured{ + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "s3.aws.crossplane.io/v1", + "kind": "Bucket", + "metadata": map[string]any{ + "name": "observed-bucket", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "bucket", + }, + }, + }}}, + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "iam.aws.crossplane.io/v1", + "kind": "User", + "metadata": map[string]any{ + "name": "observed-user", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "user", + }, + }, + }}}, + }, + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().Build() + }, + setupEnvironmentClient: func() *tu.MockEnvironmentClient { + return tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build() + }, + setupRenderFunc: func() RenderFn { + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Verify observed resources were passed through + if len(in.ObservedResources) != 2 { + return render.CompositionOutputs{}, errors.Errorf("expected 2 observed resources, got %d", len(in.ObservedResources)) + } + + // Verify the observed resources have the expected kinds + observedKinds := make(map[string]bool) + for _, obs := range in.ObservedResources { + observedKinds[obs.GetKind()] = true + } + + if !observedKinds["Bucket"] || !observedKinds["User"] { + return render.CompositionOutputs{}, errors.New("expected observed resources to include Bucket and User") + } + + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + {Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "composed1", + }, + }}}, + }, + }, nil + } + }, + wantComposedCount: 1, + wantRenderIterations: 1, + wantErr: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Set up mock clients + resourceClient := tt.setupResourceClient() + environmentClient := tt.setupEnvironmentClient() + + // Create a logger + logger := tu.TestLogger(t, false) + renderFunc := tt.setupRenderFunc() + + // Create a render iteration counter to verify + renderCount := 0 + countingRenderFunc := func(ctx context.Context, log logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + renderCount++ + return renderFunc(ctx, log, in) + } + + // Create the requirements provider + requirementsProvider := NewRequirementsProvider( + resourceClient, + environmentClient, + logger, + ) + + // Build processor options + baseOpts := testProcessorOptions(t) + customOpts := []ProcessorOption{ + WithLogger(logger), + WithRenderFunc(countingRenderFunc), + WithRequirementsProviderFactory(func(k8.ResourceClient, xp.EnvironmentClient, logging.Logger) *RequirementsProvider { + return requirementsProvider + }), + } + baseOpts = append(baseOpts, customOpts...) + processor := NewDiffProcessor(k8.Clients{}, xp.Clients{Definition: tu.NewMockDefinitionClient().Build()}, baseOpts...) + + // Call the method under test + output, err := processor.(*DefaultDiffProcessor).RenderToStableState(ctx, tt.xr, tt.composition, tt.functions, tt.resourceID, tt.observedResources, false) + + // Check error expectations + if tt.wantErr { + if err == nil { + t.Errorf("RenderToStableState() expected error but got none") + } + + return + } + + if err != nil { + t.Errorf("RenderToStableState() unexpected error: %v", err) + return + } + + // Check render iterations + if renderCount != tt.wantRenderIterations { + t.Errorf("RenderToStableState() called render func %d times, want %d", + renderCount, tt.wantRenderIterations) + } + + // Check composed resource count + if len(output.ComposedResources) != tt.wantComposedCount { + t.Errorf("RenderToStableState() returned %d composed resources, want %d", + len(output.ComposedResources), tt.wantComposedCount) + } + }) + } +} + +func TestDefaultDiffProcessor_RenderToStableState_SynthesizeReady(t *testing.T) { + ctx := t.Context() + + // Create test resources + xr := tu.NewResource("example.org/v1", "XR", "test-xr").BuildUComposite() + + // Create a composition with pipeline mode + pipelineMode := apiextensionsv1.CompositionModePipeline + composition := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + Spec: apiextensionsv1.CompositionSpec{Mode: pipelineMode}, + } + + functions := []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "test-function"}}} + + // Fixtures for an XR that already exists: its stage0 is observed in the cluster, and stage1 + // renders only once observed stage0 is Ready, the way function-sequencer or a template's own + // Ready check gates it (#564). + stage0 := func() *tu.ResourceBuilder { + return tu.NewResource("example.org/v1", "StagedResource", "stage0").WithCompositionResourceName("stage0") + } + stage1 := func() *tu.ResourceBuilder { + return tu.NewResource("example.org/v1", "StagedResource", "stage1").WithCompositionResourceName("stage1") + } + + // stage0 as a provider leaves it while it is still provisioning: it has real status, but is not Ready. + observedStage0NotReady := *stage0(). + WithStatusField("atProvider", map[string]any{"id": "real-id"}). + WithCondition("Ready", "False", "Creating"). + BuildUComposed() + observedStage0Ready := *stage0().WithCondition("Ready", "True", "Available").BuildUComposed() + observedStage1Ready := *stage1().WithCondition("Ready", "True", "Available").BuildUComposed() + + // gatedRender renders stage0, and stage1 too once observed stage0 is Ready and passes unlocks. + gatedRender := func(unlocks func(observedStage0 *cpd.Unstructured) bool) func() RenderFn { + return func() RenderFn { + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + resources := []cpd.Unstructured{*stage0().BuildUComposed()} + + for i := range in.ObservedResources { + obs := &in.ObservedResources[i] + if obs.GetName() == "stage0" && hasReadyCondition(&obs.Unstructured) && unlocks(obs) { + resources = append(resources, *stage1().BuildUComposed()) + } + } + + return render.CompositionOutputs{CompositeResource: in.CompositeResource, ComposedResources: resources}, nil + } + } + } + anyReadyStage0 := func(*cpd.Unstructured) bool { return true } + // A composition can read an observed resource's status (a template wiring stage1 to + // status.atProvider.id, say), so synthesis must leave it as the cluster has it. + stage0KeepsItsObservedID := func(obs *cpd.Unstructured) bool { + id, _, _ := un.NestedString(obs.Object, "status", "atProvider", "id") + return id == "real-id" + } + + tests := map[string]struct { + setupRenderFunc func() RenderFn + observed []cpd.Unstructured + wantComposedCount int + wantRenderIterations int + wantErr bool + wantErrContains string + }{ + "AlreadyStable": { + setupRenderFunc: func() RenderFn { + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Return same resource every time - already stable + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{{ + Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{"name": "composed1"}, + }}, + }}, + }, nil + } + }, + wantComposedCount: 1, + wantRenderIterations: 2, // First render + verification render + wantErr: false, + }, + "MultiStageProgression": { + setupRenderFunc: func() RenderFn { + iteration := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + iteration++ + + // Count how many observed resources have Ready=True + readyCount := 0 + + for _, obs := range in.ObservedResources { + if hasReadyCondition(&obs.Unstructured) { + readyCount++ + } + } + + // Stage 1: No ready resources -> produce resource1 + // Stage 2: resource1 is ready -> produce resource1 + resource2 + // Stage 3: resource1,2 ready -> produce resource1 + resource2 + resource3 + // Stage 4: All ready, stable + resources := []cpd.Unstructured{{ + Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Stage1Resource", + "metadata": map[string]any{"name": "resource1"}, + }}, + }} + + if readyCount >= 1 { + resources = append(resources, cpd.Unstructured{ + Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Stage2Resource", + "metadata": map[string]any{"name": "resource2"}, + }}, + }) + } + + if readyCount >= 2 { + resources = append(resources, cpd.Unstructured{ + Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Stage3Resource", + "metadata": map[string]any{"name": "resource3"}, + }}, + }) + } + + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: resources, + }, nil + } + }, + wantComposedCount: 3, // All three stages rendered + wantRenderIterations: 4, // Stage1 -> Stage2 -> Stage3 -> verify stable + wantErr: false, + }, + "ObservedNotReadyResourceIsMadeReady": { + // stage0 exists but is not Ready, and nothing new renders on the first pass. Stability + // still requires synthesizing its Ready, which unlocks stage1. + setupRenderFunc: gatedRender(anyReadyStage0), + observed: []cpd.Unstructured{observedStage0NotReady}, + wantComposedCount: 2, // stage0 + stage1 + wantRenderIterations: 3, // stage0 made Ready -> stage1 new, made Ready -> verify stable + wantErr: false, + }, + "SynthesisKeepsObservedResourceStatus": { + // stage1 also needs stage0's observed status.atProvider.id, which the rendered copy of + // stage0 lacks: synthesis must flip Ready on the observed copy, not replace it. + setupRenderFunc: gatedRender(stage0KeepsItsObservedID), + observed: []cpd.Unstructured{observedStage0NotReady}, + wantComposedCount: 2, + wantRenderIterations: 3, + wantErr: false, + }, + "ObservedReadyResourcesAreStableAtOnce": { + // Every rendered resource is already observed Ready, so synthesis would change nothing and + // the first render is the eventual state. + setupRenderFunc: gatedRender(anyReadyStage0), + observed: []cpd.Unstructured{observedStage0Ready, observedStage1Ready}, + wantComposedCount: 2, + wantRenderIterations: 1, + wantErr: false, + }, + "MaxIterationsExceeded": { + setupRenderFunc: func() RenderFn { + resourceNum := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Always produce a new resource - never stabilizes + // Key uses crossplane.io/composition-resource-name annotation + resourceNum++ + + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{{ + Unstructured: un.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "InfiniteResource", + "metadata": map[string]any{ + "name": fmt.Sprintf("resource%d", resourceNum), + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": fmt.Sprintf("res%d", resourceNum), + }, + }, + }}, + }}, + }, nil + } + }, + wantComposedCount: 0, + wantRenderIterations: 20, // maxIterations + wantErr: true, + wantErrContains: "did not stabilize", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + renderFunc := tt.setupRenderFunc() + + renderCount := 0 + countingRenderFunc := func(ctx context.Context, log logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + renderCount++ + return renderFunc(ctx, log, in) + } + + resourceClient := tu.NewMockResourceClient().Build() + environmentClient := tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build() + + requirementsProvider := NewRequirementsProvider(resourceClient, environmentClient, logger) + + baseOpts := testProcessorOptions(t) + customOpts := []ProcessorOption{ + WithLogger(logger), + WithRenderFunc(countingRenderFunc), + WithRequirementsProviderFactory(func(k8.ResourceClient, xp.EnvironmentClient, logging.Logger) *RequirementsProvider { + return requirementsProvider + }), + } + baseOpts = append(baseOpts, customOpts...) + processor := NewDiffProcessor(k8.Clients{}, xp.Clients{Definition: tu.NewMockDefinitionClient().Build()}, baseOpts...) + + // Synthesized Ready is for the next render only: the caller's observed state must come back + // exactly as it went in. + var observedBefore []cpd.Unstructured + for i := range tt.observed { + observedBefore = append(observedBefore, *tt.observed[i].DeepCopy()) + } + + // Call with synthesizeReady=true + output, err := processor.(*DefaultDiffProcessor).RenderToStableState(ctx, xr, composition, functions, "XR/test-xr", tt.observed, true) + + if diff := gcmp.Diff(observedBefore, tt.observed); diff != "" { + t.Errorf("RenderToStableState() modified the observed resources it was given (-before +after):\n%s", diff) + } + + if tt.wantErr { + if err == nil { + t.Errorf("expected error but got none") + return + } + + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("error %q should contain %q", err.Error(), tt.wantErrContains) + } + + return + } + + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + if renderCount != tt.wantRenderIterations { + t.Errorf("render called %d times, want %d", renderCount, tt.wantRenderIterations) + } + + if len(output.ComposedResources) != tt.wantComposedCount { + t.Errorf("got %d composed resources, want %d", len(output.ComposedResources), tt.wantComposedCount) + } + }) + } +} + +// hasReadyCondition checks if a resource has a Ready=True condition. +func hasReadyCondition(res *un.Unstructured) bool { + conditions, found, _ := un.NestedSlice(res.Object, "status", "conditions") + if !found { + return false + } + + for _, c := range conditions { + cond, ok := c.(map[string]any) + if !ok { + continue + } + + if cond["type"] == "Ready" && cond["status"] == "True" { + return true + } + } + + return false +} + +// Helper function to create a test CRD for the given GVK. +func makeTestCRD(name string, kind string, group string, version string) *extv1.CustomResourceDefinition { + return tu.NewCRD(name, group, kind). + WithListKind(kind+"List"). + WithPlural(strings.ToLower(kind)+"s"). + WithSingular(strings.ToLower(kind)). + WithVersion(version, true, true). + WithStandardSchema("coolField"). + Build() +} + +func TestDefaultDiffProcessor_getCompositeResourceXRD(t *testing.T) { + ctx := t.Context() + + // Create test XRD for parent resources + parentXRD := tu.NewXRD("xparentresources.nested.example.org", "nested.example.org", "XParentResource"). + WithVersion("v1alpha1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "parentField": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + Build() + + // Create test XRD for child resources + childXRD := tu.NewXRD("xchildresources.nested.example.org", "nested.example.org", "XChildResource"). + WithVersion("v1alpha1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "childField": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + Build() + + tests := map[string]struct { + defClient xp.DefinitionClient + resource *un.Unstructured + wantIsXR bool + wantXRDName string + }{ + "ManagedResourceIsNotXR": { + defClient: tu.NewMockDefinitionClient(). + WithXRDForXRNotFound(). + Build(), + resource: tu.NewResource("nop.example.org/v1alpha1", "NopResource", "test-managed"). + WithSpecField("forProvider", map[string]any{ + "configData": "test-value", + }). + Build(), + wantIsXR: false, + wantXRDName: "", + }, + "ParentXRCorrectlyIdentified": { + defClient: tu.NewMockDefinitionClient(). + WithXRD(parentXRD). + Build(), + resource: tu.NewResource("nested.example.org/v1alpha1", "XParentResource", "test-parent"). + WithSpecField("parentField", "parent-value"). + Build(), + wantIsXR: true, + wantXRDName: "xparentresources.nested.example.org", + }, + "ChildXRCorrectlyIdentified": { + defClient: tu.NewMockDefinitionClient(). + WithXRD(childXRD). + Build(), + resource: tu.NewResource("nested.example.org/v1alpha1", "XChildResource", "test-child"). + WithSpecField("childField", "child-value"). + Build(), + wantIsXR: true, + wantXRDName: "xchildresources.nested.example.org", + }, + "ErrorFromDefinitionClientHandled": { + defClient: tu.NewMockDefinitionClient(). + WithXRDForXRError(errors.New("cluster connection error")). + Build(), + resource: tu.NewResource("nested.example.org/v1alpha1", "XParentResource", "test-parent"). + WithSpecField("parentField", "parent-value"). + Build(), + wantIsXR: false, + wantXRDName: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create processor with mocked definition client + defClient := tt.defClient + + processor := &DefaultDiffProcessor{ + defClient: defClient, + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + }, + } + + // Call the method under test + isXR, xrd := processor.getCompositeResourceXRD(ctx, tt.resource) + + // Check isXR result + if diff := gcmp.Diff(tt.wantIsXR, isXR); diff != "" { + t.Errorf("getCompositeResourceXRD() isXR mismatch (-want +got):\n%s", diff) + } + + // Check XRD result + var gotXRDName string + if xrd != nil { + gotXRDName = xrd.GetName() + } + + if diff := gcmp.Diff(tt.wantXRDName, gotXRDName); diff != "" { + t.Errorf("getCompositeResourceXRD() XRD name mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultDiffProcessor_ProcessNestedXRs(t *testing.T) { + ctx := t.Context() + + // Create test resources + childXR := tu.NewResource("nested.example.org/v1alpha1", "XChildResource", "test-parent-child"). + WithSpecField("childField", "parent-value"). + WithCompositionResourceName("child-xr"). + Build() + + managedResource := tu.NewResource("nop.example.org/v1alpha1", "NopResource", "test-managed"). + WithSpecField("forProvider", map[string]any{ + "configData": "test-value", + }). + WithCompositionResourceName("managed-resource"). + Build() + + childXRD := tu.NewXRD("xchildresources.nested.example.org", "nested.example.org", "XChildResource"). + WithVersion("v1alpha1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "childField": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + Build() + + childComposition := tu.NewComposition("child-composition"). + WithCompositeTypeRef("nested.example.org/v1alpha1", "XChildResource"). + WithPipelineMode(). + WithPipelineStep("generate-managed", "function-go-templating", map[string]any{ + "apiVersion": "template.fn.crossplane.io/v1beta1", + "kind": "GoTemplate", + "source": "Inline", + "inline": map[string]any{ + "template": "apiVersion: nop.example.org/v1alpha1\nkind: NopResource\nmetadata:\n name: test\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: managed-resource\nspec:\n forProvider:\n configData: test", + }, + }). + Build() + + // Fixtures for the nesting-depth cases below. They form a composition cycle: + // an XChildResource composes an XCycleResource, which composes an + // XChildResource, and so on without end. + const cycleGroup = "nested.example.org" + + cycleBXR := tu.NewResource(cycleGroup+"/v1alpha1", "XCycleResource", "test-cycle-b"). + WithSpecField("childField", "cycle-value"). + WithCompositionResourceName("cycle-xr"). + Build() + + cycleAGVK := schema.GroupVersionKind{Group: cycleGroup, Version: "v1alpha1", Kind: "XChildResource"} + cycleBGVK := schema.GroupVersionKind{Group: cycleGroup, Version: "v1alpha1", Kind: "XCycleResource"} + + cycleAXRDUn := tu.NewXRD("xchildresources."+cycleGroup, cycleGroup, "XChildResource"). + WithVersion("v1alpha1", true, true). + BuildAsUnstructured() + cycleBXRDUn := tu.NewXRD("xcycleresources."+cycleGroup, cycleGroup, "XCycleResource"). + WithVersion("v1alpha1", true, true). + BuildAsUnstructured() + + cycleACRD := tu.NewCRD("xchildresources."+cycleGroup, cycleGroup, "XChildResource"). + WithListKind("XChildResourceList"). + WithPlural("xchildresources"). + WithSingular("xchildresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("childField"). + Build() + cycleBCRD := tu.NewCRD("xcycleresources."+cycleGroup, cycleGroup, "XCycleResource"). + WithListKind("XCycleResourceList"). + WithPlural("xcycleresources"). + WithSingular("xcycleresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("childField"). + Build() + + // cycleRenderFunc renders the cycle described above. renderCap breaks the + // cycle after that many renders so that a processor which fails to bound + // recursion reports a failed assertion instead of exhausting the goroutine + // stack and taking down the whole test binary. Passing a renderCap of 0 + // yields a tree exactly one level deep (the XR renders no nested XR). + cycleRenderFunc := func(renderCap int) func(context.Context, logging.Logger, RenderInputs) (render.CompositionOutputs, error) { + renders := 0 + + return func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + renders++ + out := render.CompositionOutputs{CompositeResource: in.CompositeResource} + + if renders > renderCap { + return out, nil + } + + next := cycleBXR + if in.CompositeResource.GetKind() == cycleBGVK.Kind { + next = childXR + } + + out.ComposedResources = []cpd.Unstructured{{Unstructured: *next}} + + return out, nil + } + } + + // cycleClients wires both XR kinds of the cycle so each is recognised as an + // XR and can have its XRD defaults applied. + cycleClients := func() (xp.Clients, k8.Clients) { + functions := []pkgv1.Function{ + {ObjectMeta: metav1.ObjectMeta{Name: "function-go-templating"}}, + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithComposition(childComposition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForGVK(cycleAGVK, cycleAXRDUn). + WithXRDForGVK(cycleBGVK, cycleBXRDUn). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithFoundCRD(cycleGroup, cycleAGVK.Kind, cycleACRD). + WithFoundCRD(cycleGroup, cycleBGVK.Kind, cycleBCRD). + WithGetCRDByName(func(name string) (*extv1.CustomResourceDefinition, error) { + switch name { + case cycleACRD.Name: + return cycleACRD, nil + case cycleBCRD.Name: + return cycleBCRD, nil + default: + return nil, errors.Errorf("CRD with name %s not found", name) + } + }). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + return xpClients, k8sClients + } + + tests := map[string]struct { + setupMocks func() (xp.Clients, k8.Clients) + extraOpts []ProcessorOption + composedResources []cpd.Unstructured + parentResourceID string + depth int + wantDiffCount int + wantErr bool + wantErrContain string + }{ + "NoComposedResourcesReturnsEmpty": { + setupMocks: func() (xp.Clients, k8.Clients) { + xpClients := xp.Clients{ + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().Build(), + } + k8sClients := k8.Clients{} + + return xpClients, k8sClients + }, + composedResources: []cpd.Unstructured{}, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 0, + wantErr: false, + }, + "OnlyManagedResourcesReturnsEmpty": { + setupMocks: func() (xp.Clients, k8.Clients) { + xpClients := xp.Clients{ + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXRNotFound(). + Build(), + } + k8sClients := k8.Clients{} + + return xpClients, k8sClients + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *managedResource}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 0, + wantErr: false, + }, + "ChildXRProcessedRecursively": { + setupMocks: func() (xp.Clients, k8.Clients) { + // Create functions that the composition references + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-go-templating", + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", + }, + }, + }, + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithComposition(childComposition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRD(childXRD). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + // Create a child CRD with proper schema for childField + childCRD := tu.NewCRD("xchildresources.nested.example.org", "nested.example.org", "XChildResource"). + WithListKind("XChildResourceList"). + WithPlural("xchildresources"). + WithSingular("xchildresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("childField"). + Build() + + // Create a CRD for the managed NopResource that the composition creates + nopCRD := tu.NewCRD("nopresources.nop.example.org", "nop.example.org", "NopResource"). + WithListKind("NopResourceList"). + WithPlural("nopresources"). + WithSingular("nopresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("configData"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithFoundCRD("nested.example.org", "XChildResource", childCRD). + WithFoundCRD("nop.example.org", "NopResource", nopCRD). + WithSuccessfulCRDByNameFetch("xchildresources.nested.example.org", childCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + return xpClients, k8sClients + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *childXR}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 1, // Should have diff for the child XR itself + wantErr: false, + }, + "MaxDepthExceededReturnsError": { + setupMocks: func() (xp.Clients, k8.Clients) { + xpClients := xp.Clients{ + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRD(childXRD). + Build(), + } + k8sClients := k8.Clients{} + + return xpClients, k8sClients + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *childXR}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 11, // Exceeds default maxDepth of 10 + wantDiffCount: 0, + wantErr: true, + wantErrContain: "maximum nesting depth exceeded", + }, + // A composition cycle must be bounded by MaxNestedDepth rather than by + // running out of nested XRs (which never happens). Regression test for + // the depth counter never being incremented across the recursive + // re-entry through diffSingleResourceInternal. + "CyclicCompositionTerminatesWithDepthError": { + setupMocks: cycleClients, + extraOpts: []ProcessorOption{ + WithRenderFunc(cycleRenderFunc(64)), + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *childXR}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 0, + wantErr: true, + wantErrContain: "maximum nesting depth exceeded", + }, + // --max-nested-depth N means "N levels of nesting below the root", so + // with N=1 a second level of nesting must be refused. + "MaxNestedDepthOneRefusesSecondLevel": { + setupMocks: cycleClients, + extraOpts: []ProcessorOption{ + WithMaxNestedDepth(1), + WithRenderFunc(cycleRenderFunc(64)), + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *childXR}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 0, + wantErr: true, + wantErrContain: "maximum nesting depth exceeded", + }, + // ...and the boundary is inclusive: N=1 must still process the first + // level of nesting without complaining that the bound was exceeded. + "MaxNestedDepthOneAllowsFirstLevel": { + setupMocks: cycleClients, + extraOpts: []ProcessorOption{ + WithMaxNestedDepth(1), + WithRenderFunc(cycleRenderFunc(0)), + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *childXR}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 1, + wantErr: false, + }, + "MixedXRAndManagedResourcesProcessesOnlyXRs": { + setupMocks: func() (xp.Clients, k8.Clients) { + // Create functions that the composition references + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-go-templating", + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", + }, + }, + }, + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithComposition(childComposition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRD(childXRD). + WithXRDForXRNotFoundForGVK(schema.GroupVersionKind{ + Group: "nop.example.org", + Version: "v1alpha1", + Kind: "NopResource", + }). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + // Create a child CRD with proper schema for childField + childCRD := tu.NewCRD("xchildresources.nested.example.org", "nested.example.org", "XChildResource"). + WithListKind("XChildResourceList"). + WithPlural("xchildresources"). + WithSingular("xchildresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("childField"). + Build() + + // Create a CRD for the managed NopResource that the composition creates + nopCRD := tu.NewCRD("nopresources.nop.example.org", "nop.example.org", "NopResource"). + WithListKind("NopResourceList"). + WithPlural("nopresources"). + WithSingular("nopresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("configData"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithFoundCRD("nested.example.org", "XChildResource", childCRD). + WithFoundCRD("nop.example.org", "NopResource", nopCRD). + WithSuccessfulCRDByNameFetch("xchildresources.nested.example.org", childCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + return xpClients, k8sClients + }, + composedResources: []cpd.Unstructured{ + {Unstructured: *childXR}, + {Unstructured: *managedResource}, + }, + parentResourceID: "XParentResource/test-parent", + depth: 1, + wantDiffCount: 1, // Only the child XR should be processed + wantErr: false, + }, + "NestedXRWithExistingResourcesPreservesIdentity": { + setupMocks: func() (xp.Clients, k8.Clients) { + // This test reproduces the bug where existing nested XR identity is not preserved + // resulting in all managed resources showing as removed/added instead of modified + + // Create an EXISTING nested XR with actual cluster name (not generateName) + existingChildXR := tu.NewResource("nested.example.org/v1alpha1", "XChildResource", "parent-xr-child-abc123"). + WithGenerateName("parent-xr-"). + WithSpecField("childField", "existing-value"). + WithCompositionResourceName("child-xr"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr-abc", // Existing composite label + }). + Build() + + // Create an existing managed resource owned by the nested XR + existingManagedResource := tu.NewResource("nop.example.org/v1alpha1", "NopResource", "parent-xr-child-abc123-managed-xyz"). + WithGenerateName("parent-xr-child-abc123-"). + WithSpecField("forProvider", map[string]any{ + "configData": "existing-data", + }). + WithCompositionResourceName("managed-resource"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr-child-abc123", // Points to existing nested XR + }). + Build() + + // Create a parent XR that owns the nested XR + parentXR := tu.NewResource("parent.example.org/v1alpha1", "XParentResource", "parent-xr-abc"). + WithGenerateName("parent-xr-"). + Build() + + // Create functions + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-go-templating", + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", + }, + }, + }, + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithComposition(childComposition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRD(childXRD). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + // Mock resource tree to return existing nested XR and its managed resources + ResourceTree: tu.NewMockResourceTreeClient(). + WithResourceTreeFromXRAndComposed( + parentXR, + []*un.Unstructured{existingChildXR, existingManagedResource}, + ). + Build(), + } + + // Create CRDs + childCRD := tu.NewCRD("xchildresources.nested.example.org", "nested.example.org", "XChildResource"). + WithListKind("XChildResourceList"). + WithPlural("xchildresources"). + WithSingular("xchildresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("childField"). + Build() + + nopCRD := tu.NewCRD("nopresources.nop.example.org", "nop.example.org", "NopResource"). + WithListKind("NopResourceList"). + WithPlural("nopresources"). + WithSingular("nopresource"). + WithVersion("v1alpha1", true, true). + WithStandardSchema("configData"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithFoundCRD("nested.example.org", "XChildResource", childCRD). + WithFoundCRD("nop.example.org", "NopResource", nopCRD). + WithSuccessfulCRDByNameFetch("xchildresources.nested.example.org", childCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + return xpClients, k8sClients + }, + composedResources: []cpd.Unstructured{ + // The RENDERED nested XR (from parent composition) with generateName but no name + {Unstructured: *childXR}, + }, + parentResourceID: "XParentResource/parent-xr-abc", + depth: 1, + // With identity preservation fix, nested XR maintains its cluster identity + // so managed resources show as modified rather than removed/added + wantDiffCount: 1, // Just the nested XR diff, not its managed resources as separate remove/add + wantErr: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Setup mocks + xpClients, k8sClients := tt.setupMocks() + + // Create processor with behavior defaults + custom options + baseOpts := testProcessorOptions(t) + customOpts := []ProcessorOption{ + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return nil + }, + } + }), + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator { + return &tu.MockDiffCalculator{ + CalculateNonRemovalDiffsFn: func(_ context.Context, xr *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + // Return a simple diff for the XR to make the test pass + diffs := make(map[string]*dt.ResourceDiff) + + var rendered types.RenderedResources + gvk := xr.GroupVersionKind() + resourceID := gvk.Kind + "/" + xr.GetName() + diffs[resourceID] = &dt.ResourceDiff{ + Gvk: gvk, + ResourceName: xr.GetName(), + DiffType: dt.DiffTypeAdded, + } + rendered.AddKey(resourceID) + + return diffs, rendered, nil + }, + } + }), + } + baseOpts = append(baseOpts, customOpts...) + baseOpts = append(baseOpts, tt.extraOpts...) + processor := NewDiffProcessor(k8sClients, xpClients, baseOpts...).(*DefaultDiffProcessor) + + // Initialize if needed + if len(tt.composedResources) > 0 { + // Mock composition provider that returns a composition + compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + } + + // Create a mock parent XR (nil is acceptable for tests that don't need observed resources) + var parentXR *cmp.Unstructured + + // Call the method under test + var observedResources []cpd.Unstructured + + diffs, _, err := processor.ProcessNestedXRs(ctx, tt.composedResources, compositionProvider, tt.parentResourceID, parentXR, observedResources, tt.depth) + + // Check error + if (err != nil) != tt.wantErr { + t.Errorf("ProcessNestedXRs() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("ProcessNestedXRs() error = %v, want error containing %v", err, tt.wantErrContain) + return + } + + // Check diff count + if diff := gcmp.Diff(tt.wantDiffCount, len(diffs)); diff != "" { + t.Errorf("ProcessNestedXRs() diff count mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestDefaultDiffProcessor_DiffSingleResource_WithObservedResources(t *testing.T) { + ctx := t.Context() + + // Create test XR + xr := tu.NewResource("example.org/v1", "XR", "test-xr").Build() + + // Create test observed composed resources + observedBucket := tu.NewResource("s3.aws.crossplane.io/v1", "Bucket", "observed-bucket"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "bucket", + }). + WithSpecField("bucketName", "my-bucket"). + Build() + + observedUser := tu.NewResource("iam.aws.crossplane.io/v1", "User", "observed-user"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "user", + }). + WithSpecField("userName", "my-user"). + Build() + + // Create a composition with pipeline mode + composition := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + // Create test functions + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-test", + }, + }, + } + + tests := map[string]struct { + setupMocks func() (k8.Clients, xp.Clients) + wantObservedInRender bool + wantObservedCount int + wantErr bool + wantErrContain string + verifyObservedPassed bool + }{ + "ObservedResourcesFetchedAndPassedToRender": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create resource tree with observed composed resources + resourceTree := tu.NewTreeNode(xr).WithChildren( + tu.NewTreeNode(observedBucket), + tu.NewTreeNode(observedUser), + ).Build() + + // Create XRD + xrdUnstructured := tu.NewXRD("xrs.example.org", "example.org", "XR"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": {Type: "object"}, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create CRDs + xrCRD := tu.NewCRD("xrs.example.org", "example.org", "XR"). + WithListKind("XRList"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithStandardSchema("field"). + Build() + + bucketCRD := tu.NewCRD("buckets.s3.aws.crossplane.io", "s3.aws.crossplane.io", "Bucket"). + WithListKind("BucketList"). + WithPlural("buckets"). + WithSingular("bucket"). + WithVersion("v1", true, true). + WithStandardSchema("bucketName"). + Build() + + userCRD := tu.NewCRD("users.iam.aws.crossplane.io", "iam.aws.crossplane.io", "User"). + WithListKind("UserList"). + WithPlural("users"). + WithSingular("user"). + WithVersion("v1", true, true). + WithStandardSchema("userName"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(xr). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + switch { + case gvk.Group == "example.org" && gvk.Kind == "XR": + return xrCRD, nil + case gvk.Group == "s3.aws.crossplane.io" && gvk.Kind == "Bucket": + return bucketCRD, nil + case gvk.Group == "iam.aws.crossplane.io" && gvk.Kind == "User": + return userCRD, nil + default: + return nil, errors.Errorf("CRD not found for %v", gvk) + } + }). + WithSuccessfulCRDByNameFetch("xrs.example.org", xrCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(xrdUnstructured). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(_ context.Context, _ *un.Unstructured) (*resource.Resource, error) { + return resourceTree, nil + }). + Build(), + } + + return k8sClients, xpClients + }, + wantObservedInRender: true, + wantObservedCount: 2, + verifyObservedPassed: true, + wantErr: false, + }, + "EmptyObservedResourcesWhenTreeEmpty": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create empty resource tree + emptyTree := tu.NewTreeNode(xr).Build() + + // Create XRD + xrdUnstructured := tu.NewXRD("xrs.example.org", "example.org", "XR"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": {Type: "object"}, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create CRD + xrCRD := tu.NewCRD("xrs.example.org", "example.org", "XR"). + WithListKind("XRList"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithStandardSchema("field"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(xr). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == "example.org" && gvk.Kind == "XR" { + return xrCRD, nil + } + + return nil, errors.Errorf("CRD not found for %v", gvk) + }). + WithSuccessfulCRDByNameFetch("xrs.example.org", xrCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(xrdUnstructured). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(_ context.Context, _ *un.Unstructured) (*resource.Resource, error) { + return emptyTree, nil + }). + Build(), + } + + return k8sClients, xpClients + }, + wantObservedInRender: true, + wantObservedCount: 0, + verifyObservedPassed: true, + wantErr: false, + }, + "FailedObservedResourceFetchIsFatal": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create XRD + xrdUnstructured := tu.NewXRD("xrs.example.org", "example.org", "XR"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": {Type: "object"}, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create CRD + xrCRD := tu.NewCRD("xrs.example.org", "example.org", "XR"). + WithListKind("XRList"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithStandardSchema("field"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(xr). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == "example.org" && gvk.Kind == "XR" { + return xrCRD, nil + } + + return nil, errors.Errorf("CRD not found for %v", gvk) + }). + WithSuccessfulCRDByNameFetch("xrs.example.org", xrCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(xrdUnstructured). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(_ context.Context, _ *un.Unstructured) (*resource.Resource, error) { + return nil, errors.New("failed to get resource tree") + }). + Build(), + } + + return k8sClients, xpClients + }, + wantObservedInRender: true, + wantObservedCount: 0, + verifyObservedPassed: true, + wantErr: true, + // The observed-resource fetch is the first thing to touch the tree + // client, so it is what surfaces the failure. + wantErrContain: "cannot fetch observed resources for XR", + }, + // A *transient* failure must be fatal too. Removal detection queries the + // resource tree a second time, so a failure confined to the observed + // fetch used to be swallowed entirely: the diff was computed as though + // the cluster held nothing, reporting every existing composed resource + // as an addition, with no error and no warning. + "TransientObservedResourceFetchFailureIsFatal": { + setupMocks: func() (k8.Clients, xp.Clients) { + resourceTree := tu.NewTreeNode(xr).WithChildren( + tu.NewTreeNode(observedBucket), + tu.NewTreeNode(observedUser), + ).Build() + + xrdUnstructured := tu.NewXRD("xrs.example.org", "example.org", "XR"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": {Type: "object"}, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + xrCRD := tu.NewCRD("xrs.example.org", "example.org", "XR"). + WithListKind("XRList"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithStandardSchema("field"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(xr). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == "example.org" && gvk.Kind == "XR" { + return xrCRD, nil + } + + return nil, errors.Errorf("CRD not found for %v", gvk) + }). + WithSuccessfulCRDByNameFetch("xrs.example.org", xrCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Fail only the first tree lookup (the observed-resource fetch); + // every later one succeeds, as a transient API error would. + treeCalls := 0 + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(xrdUnstructured). + Build(), + Environment: tu.NewMockEnvironmentClient(). + WithNoEnvironmentConfigs(). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(_ context.Context, _ *un.Unstructured) (*resource.Resource, error) { + treeCalls++ + if treeCalls == 1 { + return nil, errors.New("etcdserver: request timed out") + } + + return resourceTree, nil + }). + Build(), + } + + return k8sClients, xpClients + }, + wantObservedInRender: true, + wantObservedCount: 0, + verifyObservedPassed: true, + wantErr: true, + wantErrContain: "cannot fetch observed resources for XR", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + k8sClients, xpClients := tt.setupMocks() + + // Track whether observed resources were passed to render + var ( + capturedObservedCount int + capturedObserved []cpd.Unstructured + ) + + // Create processor with custom render function that captures observed resources + baseOpts := testProcessorOptions(t) + customOpts := []ProcessorOption{ + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + capturedObserved = in.ObservedResources + capturedObservedCount = len(in.ObservedResources) + + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{}, + }, nil + }), + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return nil + }, + } + }), + WithDiffCalculatorFactory(NewDiffCalculator), + } + baseOpts = append(baseOpts, customOpts...) + processor := NewDiffProcessor(k8sClients, xpClients, baseOpts...) + + // Initialize processor + err := processor.Initialize(ctx) + if err != nil { + t.Fatalf("Failed to initialize processor: %v", err) + } + + // Call DiffSingleResource + compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + } + + diffs, err := processor.(*DefaultDiffProcessor).DiffSingleResource(ctx, xr, compositionProvider) + + // Check error expectations + if (err != nil) != tt.wantErr { + t.Errorf("DiffSingleResource() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr && tt.wantErrContain != "" && !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("DiffSingleResource() error = %v, want error containing %v", err, tt.wantErrContain) + return + } + + if err != nil { + return + } + + // Verify observed resources were passed to render if expected + if tt.verifyObservedPassed { + if capturedObservedCount != tt.wantObservedCount { + t.Errorf("DiffSingleResource() passed %d observed resources to render, want %d", + capturedObservedCount, tt.wantObservedCount) + } + + // If we expected observed resources, verify they have the composition annotation + if tt.wantObservedCount > 0 { + for i, obs := range capturedObserved { + if _, hasAnno := obs.GetAnnotations()["crossplane.io/composition-resource-name"]; !hasAnno { + t.Errorf("Observed resource %d missing composition-resource-name annotation", i) + } + } + } + } + + // Verify diffs were returned (even if empty) + if diffs == nil { + t.Errorf("DiffSingleResource() returned nil diffs, expected non-nil map") + } + }) + } +} + +// TestDefaultDiffProcessor_DiffSingleResource_RevisionRef covers how the revision the composition +// provider resolves reaches render. Crossplane's composite reconciler writes the revision it selects to +// compositionRevisionRef before it composes, creating the ref when there is none, so the render input +// carries that name, at the path the composite's schema puts it. An unknown revision leaves the ref as +// the composite has it. +func TestDefaultDiffProcessor_DiffSingleResource_RevisionRef(t *testing.T) { + composition := tu.NewComposition("test-composition"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + functions := []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "function-test"}}} + + staleRef := map[string]any{"name": "test-composition-0ld0001"} + + tests := map[string]struct { + reason string + // scope is the XRD's spec.scope, which decides the composite's schema. + scope string + // spec is the composite's spec, in the cluster and as supplied. + spec map[string]any + revisionName string + // wantSpec is the spec of the composite render is handed. + wantSpec map[string]any + }{ + "ModernRefIsOverwritten": { + reason: "A ref naming another revision is pointed at the resolved one.", + scope: "Namespaced", + spec: map[string]any{"crossplane": map[string]any{"compositionRevisionRef": staleRef}}, + revisionName: "test-composition-abc1234", + wantSpec: map[string]any{"crossplane": map[string]any{ + "compositionRevisionRef": map[string]any{"name": "test-composition-abc1234"}, + }}, + }, + "ModernRefIsCreated": { + reason: "A composite with no ref gets one, as the reconciler writes it, under spec.crossplane.", + scope: "Cluster", + spec: map[string]any{"field": "value"}, + revisionName: "test-composition-abc1234", + wantSpec: map[string]any{ + "field": "value", + "crossplane": map[string]any{"compositionRevisionRef": map[string]any{"name": "test-composition-abc1234"}}, + }, + }, + "LegacyRefIsCreated": { + reason: "A legacy composite keeps its Crossplane fields directly under spec, so the ref is created there.", + scope: "LegacyCluster", + spec: map[string]any{"field": "value"}, + revisionName: "test-composition-abc1234", + wantSpec: map[string]any{ + "field": "value", + "compositionRevisionRef": map[string]any{"name": "test-composition-abc1234"}, + }, + }, + "UnknownRevisionLeavesTheRefAsItIs": { + reason: "With no revision resolved, the composite keeps the ref it has; no name is made up.", + scope: "Namespaced", + spec: map[string]any{"crossplane": map[string]any{"compositionRevisionRef": staleRef}}, + wantSpec: map[string]any{"crossplane": map[string]any{"compositionRevisionRef": staleRef}}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + xr := tu.NewResource("example.org/v1", "XR", "test-xr").WithSpec(tt.spec).Build() + + xrd := tu.NewXRD("xrs.example.org", "example.org", "XR"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + BuildAsUnstructured() + _ = un.SetNestedField(xrd.Object, tt.scope, "spec", "scope") + + xrCRD := tu.NewCRD("xrs.example.org", "example.org", "XR"). + WithListKind("XRList"). + WithPlural("xrs"). + WithSingular("xr"). + WithVersion("v1", true, true). + WithStandardSchema("field"). + Build() + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + Resource: tu.NewMockResourceClient().WithResourcesExist(xr).Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithSuccessfulCRDFetch(xrCRD). + WithSuccessfulCRDByNameFetch("xrs.example.org", xrCRD). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + Environment: tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + Function: tu.NewMockFunctionClient().WithSuccessfulFunctionsFetch(functions).Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(context.Context, *un.Unstructured) (*resource.Resource, error) { + return tu.NewTreeNode(xr).Build(), nil + }). + Build(), + } + + var rendered map[string]any + + opts := append(testProcessorOptions(t), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + rendered, _, _ = un.NestedMap(in.CompositeResource.Object, "spec") + return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil + }), + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { return nil }, + } + }), + WithDiffCalculatorFactory(NewDiffCalculator), + ) + processor := NewDiffProcessor(k8sClients, xpClients, opts...) + + if err := processor.Initialize(t.Context()); err != nil { + t.Fatalf("Initialize(): %v", err) + } + + provider := func(context.Context, *un.Unstructured) (types.ResolvedComposition, error) { + return types.ResolvedComposition{Composition: composition, RevisionName: tt.revisionName}, nil + } + + if _, err := processor.DiffSingleResource(t.Context(), xr, provider); err != nil { + t.Fatalf("%s\nDiffSingleResource(): unexpected error: %v", tt.reason, err) + } + + if diff := gcmp.Diff(tt.wantSpec, rendered); diff != "" { + t.Errorf("%s\nDiffSingleResource(): render input spec (-want +got):\n%s", tt.reason, diff) + } + }) + } +} + +func TestMergeCredentials(t *testing.T) { + // Define common test secrets + var secret1NS1 corev1.Secret + tu.NewResource("v1", "Secret", "secret1").InNamespace("ns1").BuildTyped(&secret1NS1) + + var secret2NS2 corev1.Secret + tu.NewResource("v1", "Secret", "secret2").InNamespace("ns2").BuildTyped(&secret2NS2) + + var secret1CLIValue corev1.Secret + tu.NewResource("v1", "Secret", "secret1").InNamespace("ns1"). + WithData(map[string][]byte{"key": []byte("cli-value")}). + BuildTyped(&secret1CLIValue) + + var secret1AutoValue corev1.Secret + tu.NewResource("v1", "Secret", "secret1").InNamespace("ns1"). + WithData(map[string][]byte{"key": []byte("auto-value")}). + BuildTyped(&secret1AutoValue) + + var cliSecretNS1 corev1.Secret + tu.NewResource("v1", "Secret", "cli-secret").InNamespace("ns1").BuildTyped(&cliSecretNS1) + + var autoSecretNS2 corev1.Secret + tu.NewResource("v1", "Secret", "auto-secret").InNamespace("ns2").BuildTyped(&autoSecretNS2) + + var secret1First corev1.Secret + tu.NewResource("v1", "Secret", "secret1").InNamespace("ns1"). + WithData(map[string][]byte{"key": []byte("first")}). + BuildTyped(&secret1First) + + var secret1Second corev1.Secret + tu.NewResource("v1", "Secret", "secret1").InNamespace("ns1"). + WithData(map[string][]byte{"key": []byte("second")}). + BuildTyped(&secret1Second) + + tests := map[string]struct { + cliCredentials []corev1.Secret + autoFetchedCredentials []corev1.Secret + want map[string]bool // expected namespace/name keys in result + wantCount int + }{ + "EmptyBoth": { + cliCredentials: nil, + autoFetchedCredentials: nil, + want: map[string]bool{}, + wantCount: 0, + }, + "OnlyCLI": { + cliCredentials: []corev1.Secret{secret1NS1}, + autoFetchedCredentials: nil, + want: map[string]bool{"ns1/secret1": true}, + wantCount: 1, + }, + "OnlyAutoFetched": { + cliCredentials: nil, + autoFetchedCredentials: []corev1.Secret{secret2NS2}, + want: map[string]bool{"ns2/secret2": true}, + wantCount: 1, + }, + "CLIOverridesAutoFetched": { + cliCredentials: []corev1.Secret{secret1CLIValue}, + autoFetchedCredentials: []corev1.Secret{secret1AutoValue}, + want: map[string]bool{"ns1/secret1": true}, + wantCount: 1, + }, + "MergesDifferentSecrets": { + cliCredentials: []corev1.Secret{cliSecretNS1}, + autoFetchedCredentials: []corev1.Secret{autoSecretNS2}, + want: map[string]bool{"ns1/cli-secret": true, "ns2/auto-secret": true}, + wantCount: 2, + }, + "DuplicatesInCLIInputLastWins": { + cliCredentials: []corev1.Secret{secret1First, secret1Second}, + autoFetchedCredentials: nil, + want: map[string]bool{"ns1/secret1": true}, + wantCount: 1, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := mergeCredentials(tc.cliCredentials, tc.autoFetchedCredentials) + + if len(got) != tc.wantCount { + t.Errorf("mergeCredentials() returned %d secrets, want %d", len(got), tc.wantCount) + } + + for _, secret := range got { + key := fmt.Sprintf("%s/%s", secret.Namespace, secret.Name) + if !tc.want[key] { + t.Errorf("mergeCredentials() unexpected secret %s", key) + } + } + + // Test CLI override behavior specifically + if name == "CLIOverridesAutoFetched" && len(got) == 1 { + if string(got[0].Data["key"]) != "cli-value" { + t.Errorf("mergeCredentials() CLI credentials should override auto-fetched, got value %q", string(got[0].Data["key"])) + } + } + + // Test that duplicates in CLI input use last-write-wins + if name == "DuplicatesInCLIInputLastWins" && len(got) == 1 { + if string(got[0].Data["key"]) != "second" { + t.Errorf("mergeCredentials() duplicate CLI credentials should use last value, got %q want %q", string(got[0].Data["key"]), "second") + } + } + }) + } +} + +// TestResolveFunctionCredentials covers the credential resolution seam: delegation to the +// CredentialClient (whose own fetch logic is tested in credential_client_test.go), the merge with +// CLI-supplied credentials, and — the part that matters to a user — exactly when the shortfall advisory +// fires. The advisory must describe the state AFTER the merge: a user who supplied the absent secret +// with --function-credentials has already solved the problem and must not be told to solve it. +func TestResolveFunctionCredentials(t *testing.T) { + var clusterSecret corev1.Secret + tu.NewResource("v1", "Secret", "cluster-credentials"). + InNamespace("crossplane-system"). + BuildTyped(&clusterSecret) + + var suppliedSecret corev1.Secret + tu.NewResource("v1", "Secret", "absent-credentials"). + InNamespace("crossplane-system"). + BuildTyped(&suppliedSecret) + + absentRef := k8stypes.NamespacedName{Namespace: "crossplane-system", Name: "absent-credentials"} + otherAbsentRef := k8stypes.NamespacedName{Namespace: "ns2", Name: "other-credentials"} + + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithPipelineMode(). + WithPipelineStep("step1", "function-msgraph", nil, + tu.WithCredentials("azure-creds", "crossplane-system", "cluster-credentials")). + Build() + + const ( + advisoryMsg = "Some function credential secrets could not be fetched from cluster" + advisoryHint = "Use --function-credentials to provide secrets that don't exist on cluster" + ) + + tests := map[string]struct { + reason string + // composition is passed straight through; nil exercises the delegation guard. + composition *apiextensionsv1.Composition + // calls is how many times resolveFunctionCredentials runs, standing in for the per-XR repeat: + // RenderToStableState resolves credentials once per XR, so a composition affecting many XRs + // resolves many times against identical inputs. + calls int + cliCredentials []corev1.Secret + fetchResult types.CredentialFetchResult + fetchErr error + wantMerged []string + wantAdvisories []dt.OutputWarning + wantErr string + }{ + "NilCompositionResolvesNothing": { + reason: "A nil composition has no pipeline to inspect, so nothing is fetched or warned about", + composition: nil, + }, + "DelegatesToCredentialClient": { + reason: "Secrets the client fetched from the cluster are returned for rendering", + composition: composition, + fetchResult: types.CredentialFetchResult{Secrets: []corev1.Secret{clusterSecret}}, + wantMerged: []string{"crossplane-system/cluster-credentials"}, + }, + "AbsentAndNotSuppliedWarns": { + reason: "A shortfall the user has not covered is exactly what the advisory exists to report", + composition: composition, + fetchResult: types.CredentialFetchResult{ + Secrets: []corev1.Secret{clusterSecret}, + Absent: []k8stypes.NamespacedName{absentRef}, + }, + wantMerged: []string{"crossplane-system/cluster-credentials"}, + wantAdvisories: []dt.OutputWarning{{ + Message: advisoryMsg, + Context: map[string]string{ + "composition": "test-comp", + "missing": "crossplane-system/absent-credentials", + "hint": advisoryHint, + }, + }}, + }, + "AbsentButSuppliedViaCLIDoesNotWarn": { + reason: "The user passed --function-credentials for the very secret the cluster lacks; warning would tell them to do what they just did", + composition: composition, + cliCredentials: []corev1.Secret{suppliedSecret}, + fetchResult: types.CredentialFetchResult{ + Secrets: []corev1.Secret{clusterSecret}, + Absent: []k8stypes.NamespacedName{absentRef}, + }, + wantMerged: []string{"crossplane-system/absent-credentials", "crossplane-system/cluster-credentials"}, + }, + "PartiallySuppliedWarnsOnlyAboutTheRemainder": { + reason: "Naming the secrets that are still missing is what makes the advisory actionable", + composition: composition, + cliCredentials: []corev1.Secret{suppliedSecret}, + fetchResult: types.CredentialFetchResult{ + Absent: []k8stypes.NamespacedName{otherAbsentRef, absentRef}, + }, + wantMerged: []string{"crossplane-system/absent-credentials"}, + wantAdvisories: []dt.OutputWarning{{ + Message: advisoryMsg, + Context: map[string]string{ + "composition": "test-comp", + "missing": "ns2/other-credentials", + "hint": advisoryHint, + }, + }}, + }, + "RepeatedResolutionWarnsOnce": { + reason: "The shortfall is a property of the composition, so thirty affected XRs must not produce thirty identical warnings", + composition: composition, + calls: 3, + fetchResult: types.CredentialFetchResult{ + Absent: []k8stypes.NamespacedName{absentRef}, + }, + wantAdvisories: []dt.OutputWarning{{ + Message: advisoryMsg, + Context: map[string]string{ + "composition": "test-comp", + "missing": "crossplane-system/absent-credentials", + "hint": advisoryHint, + }, + }}, + }, + "FetchErrorPropagates": { + reason: "A credential that could not be read leaves the diff possibly wrong, so the failure must not be degraded to an advisory", + composition: composition, + fetchErr: errors.New("secrets is forbidden"), + wantErr: "secrets is forbidden", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + credentialClient := &tu.MockCredentialClient{ + FetchCompositionCredentialsFn: func(_ context.Context, _ *apiextensionsv1.Composition) (types.CredentialFetchResult, error) { + return tc.fetchResult, tc.fetchErr + }, + } + + // A real AdvisoryCollector, so the assertion covers the whole advisory channel — message, + // machine-readable context, and the collector's dedup. + advisories := NewAdvisoryCollector(nil) + + processor := &DefaultDiffProcessor{ + credentialClient: credentialClient, + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + Advisories: advisories, + FunctionCredentials: tc.cliCredentials, + }, + } + + calls := max(tc.calls, 1) + + var ( + merged []corev1.Secret + err error + ) + + for range calls { + merged, err = processor.resolveFunctionCredentials(t.Context(), tc.composition, "XR1/my-xr") + } + + if tc.wantErr != "" { + if err == nil { + t.Fatalf("\n%s\nresolveFunctionCredentials(): expected error containing %q, got nil", tc.reason, tc.wantErr) + } + + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("\n%s\nresolveFunctionCredentials(): error %q does not contain %q", tc.reason, err.Error(), tc.wantErr) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nresolveFunctionCredentials(): unexpected error: %v", tc.reason, err) + } + + gotMerged := make([]string, 0, len(merged)) + for _, s := range merged { + gotMerged = append(gotMerged, s.Namespace+"/"+s.Name) + } + + if diff := gcmp.Diff(tc.wantMerged, gotMerged, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("\n%s\nmerged credentials mismatch (-want +got):\n%s", tc.reason, diff) + } + + if diff := gcmp.Diff(tc.wantAdvisories, advisories.Advisories()); diff != "" { + t.Errorf("\n%s\nadvisories mismatch (-want +got):\n%s", tc.reason, diff) + } + }) + } +} + +// TestDefaultDiffProcessor_RenderToStableState_SchemaPlumbing asserts that +// resolveSchemaAndXRDForRender pins the right composite.Schema on the input +// *cmp.Unstructured the render function receives. Schema selection follows +// the XRD's spec.scope: LegacyCluster -> SchemaLegacy (canonical fields at +// spec.*); anything else -> SchemaModern (canonical fields at +// spec.crossplane.*). Pinning here is required so the renderer writes +// canonical fields at the path the cluster CRD declares. +func TestDefaultDiffProcessor_RenderToStableState_SchemaPlumbing(t *testing.T) { + ctx := t.Context() + + xr := tu.NewResource("example.org/v1", "XLegacy", "test-xr").BuildUComposite() + composition := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + Spec: apiextensionsv1.CompositionSpec{Mode: apiextensionsv1.CompositionModePipeline}, + } + + // resolveSchemaAndXRDForRender derives the schema from the XRD's + // spec.scope (LegacyCluster -> SchemaLegacy; anything else -> + // SchemaModern), so this test drives the schema decision via the + // XRD's scope field rather than mocking GetCompositeSchema. + tests := map[string]struct { + scope string + wantSchema cmp.Schema + }{ + "LegacyXRD_SchemaLegacy": { + scope: "LegacyCluster", + wantSchema: cmp.SchemaLegacy, + }, + "ModernXRD_SchemaModern": { + scope: "Cluster", + wantSchema: cmp.SchemaModern, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var capturedSchema cmp.Schema + + renderFn := func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + capturedSchema = in.CompositeResource.Schema + return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil + } + + xrd := tu.NewResource("apiextensions.crossplane.io/v2", "CompositeResourceDefinition", "xrd-test"). + WithSpecField("scope", tt.scope). + Build() + + defClient := tu.NewMockDefinitionClient().Build() + defClient.GetXRDForXRFn = func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return xrd, nil + } + + opts := append(testProcessorOptions(t), + WithRenderFunc(renderFn), + ) + processor := NewDiffProcessor(k8.Clients{}, xp.Clients{Definition: defClient}, opts...) + + out, err := processor.(*DefaultDiffProcessor).RenderToStableState( + ctx, xr, composition, nil, "XR/test-xr", nil, false, + ) + if err != nil { + t.Fatalf("RenderToStableState() unexpected error: %v", err) + } + + if capturedSchema != tt.wantSchema { + t.Errorf("input.CompositeResource.Schema = %v, want %v", capturedSchema, tt.wantSchema) + } + + if out.CompositeResource == nil { + t.Fatal("output CompositeResource is nil") + } + + if out.CompositeResource.Schema != tt.wantSchema { + t.Errorf("output.CompositeResource.Schema = %v, want %v", + out.CompositeResource.Schema, tt.wantSchema) + } + }) + } +} + +// recordingCleaner records the context Cleanup was called with, and that context's Err() at call time. +type recordingCleaner struct { + err error + called bool + ctx context.Context + ctxErrNow error +} + +func (r *recordingCleaner) Cleanup(ctx context.Context) error { + r.called = true + r.ctx = ctx + r.ctxErrNow = ctx.Err() + + return r.err +} + +type cleanupDetachedCtxKey struct{} + +func TestCleanupDetached(t *testing.T) { + type want struct { + CtxErr error + Value any + } + + tests := map[string]struct { + reason string + parent func() (context.Context, context.CancelFunc) + cleanErr error + want want + checkDeadline bool + }{ + "ParentCancelled": { + reason: "Cleanup must receive a live context even when the run context is already cancelled.", + parent: func() (context.Context, context.CancelFunc) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx, cancel + }, + }, + "ParentDeadlineExpired": { + reason: "Cleanup must receive a live context even when the run's --timeout has expired.", + parent: func() (context.Context, context.CancelFunc) { + return context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + }, + }, + "KeepsParentValues": { + reason: "Cleanup's context must keep the run context's values.", + parent: func() (context.Context, context.CancelFunc) { + return context.WithCancel(context.WithValue(context.Background(), cleanupDetachedCtxKey{}, "v")) + }, + want: want{Value: "v"}, + }, + "BoundedByCleanupTimeout": { + reason: "Cleanup's context must carry a deadline no later than CleanupTimeout from now.", + parent: func() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) + }, + checkDeadline: true, + }, + "ErrorIsSwallowed": { + reason: "A cleanup error must be logged, not panic or propagate.", + parent: func() (context.Context, context.CancelFunc) { + return context.WithCancel(context.Background()) + }, + cleanErr: errors.New("boom"), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + parent, cancel := tt.parent() + defer cancel() + + c := &recordingCleaner{err: tt.cleanErr} + + CleanupDetached(parent, c, logging.NewNopLogger()) + + if !c.called { + t.Fatalf("\n%s\nCleanupDetached(...): Cleanup was not called", tt.reason) + } + + got := want{CtxErr: c.ctxErrNow, Value: c.ctx.Value(cleanupDetachedCtxKey{})} + if diff := gcmp.Diff(tt.want, got, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nCleanupDetached(...): -want, +got:\n%s", tt.reason, diff) + } + + if tt.checkDeadline { + dl, ok := c.ctx.Deadline() + if !ok { + t.Fatalf("\n%s\nCleanupDetached(...): cleanup context has no deadline", tt.reason) + } + + if limit := time.Now().Add(CleanupTimeout); dl.After(limit) { + t.Errorf("\n%s\nCleanupDetached(...): deadline %v is later than %v", tt.reason, dl, limit) + } + } + }) + } +} diff --git a/internal/diff/diffprocessor/errors.go b/internal/diff/diffprocessor/errors.go new file mode 100644 index 00000000..c587a762 --- /dev/null +++ b/internal/diff/diffprocessor/errors.go @@ -0,0 +1,380 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + "errors" + "fmt" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" +) + +// Exit codes for crossplane-diff CLI. +const ( + // ExitCodeSuccess indicates no diff and no errors. + ExitCodeSuccess = 0 + + // ExitCodeToolError indicates a tool execution error (e.g., kube access, internal failure). + ExitCodeToolError = 1 + + // ExitCodeSchemaValidation indicates a schema validation error. + ExitCodeSchemaValidation = 2 + + // ExitCodeDiffDetected indicates that differences were detected. + ExitCodeDiffDetected = 3 + + // ExitCodeInterrupted indicates the run was cancelled by SIGINT (Ctrl+C) or SIGTERM. It is 130 by + // the shell convention for SIGINT (128 + 2); the signal handler does not report which signal + // arrived, so SIGTERM uses the same code. + ExitCodeInterrupted = 130 +) + +// ErrInterrupted is the error an interrupted run reports, in errors[] and on stderr, in place of the +// cancelled calls the interruption caused. +var ErrInterrupted = errors.New("run interrupted by signal; results are incomplete") + +// Interrupted reports whether the run context ctx was cancelled by a signal. The run context is a +// timeout context on the signal context, so an expired --timeout yields context.DeadlineExceeded and a +// signal yields context.Canceled. The run's own cancel also yields Canceled, but only fires once the run +// is over, so callers must evaluate this before it. +func Interrupted(ctx context.Context) bool { + return errors.Is(ctx.Err(), context.Canceled) +} + +// withInterruption appends an entry saying the run was interrupted when ctx was cancelled by a signal, +// so an interrupted run's output says so instead of listing only the cancelled calls it caused. +func withInterruption(ctx context.Context, errs []dt.OutputError) []dt.OutputError { + if Interrupted(ctx) { + return append(errs, dt.OutputError{Message: ErrInterrupted.Error()}) + } + + return errs +} + +// SchemaValidationError indicates schema validation failed. +// Used to distinguish validation errors from other tool errors for exit +// code handling. +// +// Result, when non-nil, carries the structured per-resource validation +// outcome that produced this error. Output renderers use it to surface +// typed FieldValidationError records under OutputError.ValidationFailures +// in JSON / YAML output. It is left nil for paths that fail validation +// without a *pkgvalidate.ValidationResult in hand — for example +// scope-validation errors raised after schema validation succeeded — so +// the absence of structured detail is observable rather than fabricated. +// +// Failures is the same information for rejections that did not come from +// pkg/validate at all — specifically, a resource the apiserver itself refused +// during a dry-run (a validating admission webhook, a ResourceQuota, a missing +// namespace). Those are the same kind of fact as a schema rejection ("the +// cluster will not accept this resource"), just detected by the apiserver +// instead of locally, so they belong in the same exit-code tier and the same +// output field. They are carried separately rather than synthesised into a +// pkgvalidate.ValidationResult because that would mean inventing a +// FieldErrorType value upstream does not define; ResourceValidationFailure is +// explicitly ours to populate (see its doc in renderer/types). +type SchemaValidationError struct { + ResourceID string + Message string + Err error + Result *pkgvalidate.ValidationResult + Failures []dt.ResourceValidationFailure +} + +// Error implements the error interface. +func (e *SchemaValidationError) Error() string { + if e.ResourceID != "" { + return fmt.Sprintf("schema validation error for %s: %s", e.ResourceID, e.Message) + } + + return e.Message +} + +// Unwrap returns the wrapped error for errors.Is/As compatibility. +func (e *SchemaValidationError) Unwrap() error { + return e.Err +} + +// NewSchemaValidationError creates a new SchemaValidationError without +// a structured Result. Use WithResult on the returned value to attach +// one when the failure originated from pkg/validate.SchemaValidate. +func NewSchemaValidationError(resourceID, message string, err error) *SchemaValidationError { + return &SchemaValidationError{ + ResourceID: resourceID, + Message: message, + Err: err, + } +} + +// WithResult attaches the structured *pkgvalidate.ValidationResult +// that produced this error so downstream renderers can emit typed +// per-resource failures alongside the human-readable Message. Returns +// the receiver for fluent chaining. +func (e *SchemaValidationError) WithResult(result *pkgvalidate.ValidationResult) *SchemaValidationError { + e.Result = result + return e +} + +// WithFailures attaches already-built per-resource failures for a rejection +// that did not originate from pkg/validate — an apiserver dry-run refusal. It +// is the sibling of WithResult; see the Failures field for why the two are +// separate. Returns the receiver for fluent chaining. +func (e *SchemaValidationError) WithFailures(failures []dt.ResourceValidationFailure) *SchemaValidationError { + e.Failures = failures + return e +} + +// FieldErrorTypeAdmission is the FieldValidationError.Type for a rejection +// issued by the apiserver's admission chain rather than by local schema +// validation. It deliberately sits alongside upstream's "schema" / "cel" / +// "unknownField" / "defaulting" values in the same field, because to a consumer +// asking "why won't the cluster accept this?" it is the same kind of answer. +const FieldErrorTypeAdmission = "admission" + +// NewOutputError builds a structured-output entry for err, tagged with +// resourceID. When err contains a *SchemaValidationError that carries either a +// pkgvalidate.ValidationResult or pre-built Failures, the returned OutputError +// also exposes a typed per-resource breakdown via ValidationFailures so machine +// consumers don't need to parse Message. Non-validation errors return +// an OutputError with only ResourceID and Message populated. +// +// Explicit Failures win over Result. The two are never both set in practice — +// one comes from pkg/validate, the other from an apiserver rejection — but +// preferring the explicit list means a caller that sets it cannot have it +// silently dropped. +func NewOutputError(resourceID string, err error) dt.OutputError { + out := dt.OutputError{ + ResourceID: resourceID, + Message: err.Error(), + } + + if sve, ok := errors.AsType[*SchemaValidationError](err); ok { + switch { + case len(sve.Failures) > 0: + out.ValidationFailures = sve.Failures + case sve.Result != nil: + out.ValidationFailures = validationFailuresFromResult(sve.Result) + } + } + + return out +} + +// NewAdmissionRejectionError wraps an apiserver dry-run rejection of obj as a +// SchemaValidationError, so it lands in the same exit-code tier and the same +// structured-output field as a local schema rejection. +// +// This is used for BOTH new and existing resources on purpose. Before this +// existed, a webhook rejection of an existing resource surfaced as a plain tool +// error (exit 1) while the identical rejection of an addition would have been a +// validation error (exit 2) — reporting the same cluster fact under two +// different exit codes depending only on whether the resource happened to exist +// already. See crossplane-diff#334. +// +// rejectedDesc describes the REJECTED resource (the calculator's per-resource +// id, e.g. "Bucket/my-bucket"), not the input XR. It is folded into the message +// because OutputError.ResourceID identifies the input the user supplied, which +// for a composed resource is the XR — without this, a human would be told their +// XR was rejected with no way to tell which composed resource the cluster +// actually refused. +// +// The inner SchemaValidationError deliberately gets an empty ResourceID: its +// Error() would otherwise prefix "schema validation error for : ", which +// both duplicates the id that OutputError.FormatError already prints and +// mislabels an admission rejection as a schema problem. +func NewAdmissionRejectionError(rejectedDesc string, obj *un.Unstructured, err error) *SchemaValidationError { + msg := fmt.Sprintf("the cluster rejected %s: %s", rejectedDesc, err.Error()) + + return NewSchemaValidationError("", msg, err).WithFailures([]dt.ResourceValidationFailure{{ + APIVersion: obj.GetAPIVersion(), + Kind: obj.GetKind(), + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + // Reuse upstream's status vocabulary rather than a literal, so this row is + // indistinguishable in shape from one produced by validationFailuresFromResult. + Status: string(pkgvalidate.ValidationStatusInvalid), + Errors: []dt.FieldValidationError{{ + Type: FieldErrorTypeAdmission, + Message: err.Error(), + }}, + }}) +} + +// validationFailuresFromResult maps a pkgvalidate.ValidationResult into +// the wire types crossplane-diff exposes through OutputError. Resources +// with status Valid are filtered out — ValidationFailures is "what went +// wrong", not "the full audit log". Resources with status +// DefaultingFailed are filtered out too: pkgvalidate.ResultError treats +// defaulting-only failures as success, so a SchemaValidationError +// reaching this code path should not advertise them as failures. +func validationFailuresFromResult(result *pkgvalidate.ValidationResult) []dt.ResourceValidationFailure { + if result == nil { + return nil + } + + var out []dt.ResourceValidationFailure + + for _, r := range result.Resources { + switch r.Status { + case pkgvalidate.ValidationStatusValid, + pkgvalidate.ValidationStatusDefaultingFailed: + continue + case pkgvalidate.ValidationStatusInvalid, + pkgvalidate.ValidationStatusMissingSchema: + // fall through + } + + out = append(out, dt.ResourceValidationFailure{ + APIVersion: r.APIVersion, + Kind: r.Kind, + Name: r.Name, + Namespace: r.Namespace, + Status: string(r.Status), + Errors: fieldValidationErrorsFromUpstream(r.Errors), + }) + } + + return out +} + +// fieldValidationErrorsFromUpstream converts the cli's per-field error +// slice into our wire shape. Defaulting entries are filtered out when +// at least one actionable (schema / CEL / unknown-field) error is +// present on the same resource, mirroring the suppression policy of +// formatValidationErrors so the typed and human-readable views agree. +func fieldValidationErrorsFromUpstream(errs []pkgvalidate.FieldValidationError) []dt.FieldValidationError { + if len(errs) == 0 { + return nil + } + + hasActionable := false + + for _, e := range errs { + if e.Type != pkgvalidate.FieldErrorTypeDefaulting { + hasActionable = true + break + } + } + + out := make([]dt.FieldValidationError, 0, len(errs)) + + for _, e := range errs { + if hasActionable && e.Type == pkgvalidate.FieldErrorTypeDefaulting { + continue + } + + out = append(out, dt.FieldValidationError{ + Type: string(e.Type), + Field: e.Field, + Message: e.Message, + Value: e.Value, + }) + } + + return out +} + +// IsSchemaValidationError checks if any error in the chain is a schema validation error. +func IsSchemaValidationError(err error) bool { + var sve *SchemaValidationError + return errors.As(err, &sve) +} + +// isOnlySchemaValidationErrors checks if ALL errors in the error tree are schema validation errors. +// Returns false if there are any non-schema-validation errors in the chain. +// This is used to determine exit code priority: tool errors take precedence over schema validation errors. +func isOnlySchemaValidationErrors(err error) bool { + if err == nil { + return true + } + + // Check if this is a join error (contains multiple errors) - check this FIRST + // before checking if it's a SchemaValidationError, because errors.As would + // traverse into the join and find schema validation errors even if there are + // non-schema-validation errors in the same join. + type unwrapMultiple interface { + Unwrap() []error + } + if joinErr, ok := err.(unwrapMultiple); ok { + for _, e := range joinErr.Unwrap() { + if !isOnlySchemaValidationErrors(e) { + return false + } + } + + return true + } + + // Check if this specific error (not its wrapped errors) is a SchemaValidationError + // Use type assertion instead of errors.As to avoid traversing into wrapped errors + schemaValidationError := &SchemaValidationError{} + if errors.As(err, &schemaValidationError) { + // A SchemaValidationError IS a schema validation error - its wrapped Err field + // contains the underlying cause/detail (e.g., the original validation library error), + // not a different error type. So we return true immediately. + return true + } + + // Check if this wraps another error using standard Go 1.13+ Unwrap() + if unwrapped := errors.Unwrap(err); unwrapped != nil { + return isOnlySchemaValidationErrors(unwrapped) + } + + // Check if this wraps another error using pkg/errors Cause() interface + // (used by github.com/crossplane/crossplane-runtime/v2/pkg/errors) + type causer interface { + Cause() error + } + if cause, ok := err.(causer); ok { + return isOnlySchemaValidationErrors(cause.Cause()) + } + + // This is a leaf error that's not a SchemaValidationError + return false +} + +// DetermineExitCode determines the appropriate exit code based on the error and diff status. +// Priority: interrupted (130) > tool error (1) > schema validation error (2) > diff detected (3) > +// success (0). An interruption outranks everything because the other errors of an interrupted run are +// mostly the cancelled calls it caused. +func DetermineExitCode(err error, hasDiffs bool) int { + if errors.Is(err, ErrInterrupted) { + return ExitCodeInterrupted + } + + if err != nil { + // If ALL errors are schema validation errors, return schema validation exit code. + // If there are ANY non-schema-validation errors (tool errors), return tool error exit code. + // Tool errors take priority because they indicate infrastructure/connectivity issues + // that prevent the tool from functioning correctly. + if isOnlySchemaValidationErrors(err) { + return ExitCodeSchemaValidation + } + + return ExitCodeToolError + } + + if hasDiffs { + return ExitCodeDiffDetected + } + + return ExitCodeSuccess +} diff --git a/internal/diff/diffprocessor/errors_test.go b/internal/diff/diffprocessor/errors_test.go new file mode 100644 index 00000000..7bb5a79c --- /dev/null +++ b/internal/diff/diffprocessor/errors_test.go @@ -0,0 +1,871 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + gcmp "github.com/google/go-cmp/cmp" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + xperrors "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" +) + +func TestSchemaValidationError_Error(t *testing.T) { + tests := map[string]struct { + err *SchemaValidationError + want string + }{ + "WithResourceID": { + err: &SchemaValidationError{ + ResourceID: "MyKind/my-resource", + Message: "field validation failed", + }, + want: "schema validation error for MyKind/my-resource: field validation failed", + }, + "WithoutResourceID": { + err: &SchemaValidationError{ + Message: "general validation failed", + }, + want: "general validation failed", + }, + "WithWrappedError": { + err: &SchemaValidationError{ + ResourceID: "MyKind/my-resource", + Message: "schema validation failed", + Err: errors.New("underlying error"), + }, + want: "schema validation error for MyKind/my-resource: schema validation failed", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := tc.err.Error() + if diff := gcmp.Diff(tc.want, got); diff != "" { + t.Errorf("Error() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestSchemaValidationError_Unwrap(t *testing.T) { + wrappedErr := errors.New("underlying error") + err := &SchemaValidationError{ + Message: "wrapper", + Err: wrappedErr, + } + + got := err.Unwrap() + if !errors.Is(got, wrappedErr) { + t.Errorf("Unwrap() = %v, want %v", got, wrappedErr) + } +} + +func TestIsSchemaValidationError(t *testing.T) { + tests := map[string]struct { + err error + want bool + }{ + "DirectSchemaValidationError": { + err: &SchemaValidationError{Message: "test"}, + want: true, + }, + "WrappedSchemaValidationError": { + err: errors.Join(errors.New("outer"), &SchemaValidationError{Message: "inner"}), + want: true, + }, + "NonSchemaValidationError": { + err: errors.New("regular error"), + want: false, + }, + "NilError": { + err: nil, + want: false, + }, + "DeeplyWrappedSchemaValidationError": { + err: errors.Join( + errors.New("level1"), + errors.Join( + errors.New("level2"), + &SchemaValidationError{Message: "deep"}, + ), + ), + want: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := IsSchemaValidationError(tc.err) + if got != tc.want { + t.Errorf("IsSchemaValidationError() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestNewSchemaValidationError(t *testing.T) { + wrappedErr := errors.New("wrapped") + err := NewSchemaValidationError("Kind/name", "message", wrappedErr) + + if err.ResourceID != "Kind/name" { + t.Errorf("ResourceID = %v, want Kind/name", err.ResourceID) + } + + if err.Message != "message" { + t.Errorf("Message = %v, want message", err.Message) + } + + if !errors.Is(err.Err, wrappedErr) { + t.Errorf("Err = %v, want %v", err.Err, wrappedErr) + } + + if err.Result != nil { + t.Errorf("Result = %v, want nil for the basic constructor", err.Result) + } +} + +func TestSchemaValidationError_WithResult(t *testing.T) { + result := &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "x", + Status: pkgvalidate.ValidationStatusInvalid, + }}, + } + + err := NewSchemaValidationError("", "msg", errors.New("inner")).WithResult(result) + if err.Result != result { + t.Errorf("Result = %v, want %v", err.Result, result) + } +} + +func TestNewOutputError(t *testing.T) { + tests := map[string]struct { + reason string + resourceID string + err error + want dt.OutputError + }{ + "NonValidationError_NoFailuresAttached": { + reason: "A plain error has no structured slot to populate; ValidationFailures stays nil and only Message is set.", + resourceID: "XR/my-xr", + err: errors.New("kube unreachable"), + want: dt.OutputError{ + ResourceID: "XR/my-xr", + Message: "kube unreachable", + }, + }, + "SchemaValidationError_WithoutResult_NoFailuresAttached": { + reason: "A SchemaValidationError without a Result (e.g. scope-validation path) contributes only Message; the typed slot stays nil so consumers can distinguish 'no failures' from 'no structured detail available'.", + resourceID: "XR/my-xr", + err: NewSchemaValidationError("", "scope failure", errors.New("inner")), + want: dt.OutputError{ + ResourceID: "XR/my-xr", + Message: "scope failure", + }, + }, + "SchemaValidationError_WithResult_ExposesTypedFailures": { + reason: "A SchemaValidationError carrying a Result populates ValidationFailures with the converted per-resource breakdown.", + resourceID: "XR/my-xr", + err: NewSchemaValidationError("", "msg", errors.New("inner")).WithResult( + &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Field: "spec.region", + Message: "spec.region: Required value", + }}, + }}, + }), + want: dt.OutputError{ + ResourceID: "XR/my-xr", + Message: "msg", + ValidationFailures: []dt.ResourceValidationFailure{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "schema", + Field: "spec.region", + Message: "spec.region: Required value", + }}, + }}, + }, + }, + "WrappedSchemaValidationError_StillSurfacesFailures": { + reason: "errors.As walks the chain, so a SchemaValidationError behind errors.Wrap still has its Result extracted into ValidationFailures.", + resourceID: "XR/my-xr", + err: xperrors.Wrap( + NewSchemaValidationError("", "msg", errors.New("inner")).WithResult( + &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "other.org/v1", + Kind: "Thing", + Name: "thing", + Status: pkgvalidate.ValidationStatusMissingSchema, + }}, + }), + "cannot validate resources"), + want: dt.OutputError{ + ResourceID: "XR/my-xr", + Message: "cannot validate resources: msg", + ValidationFailures: []dt.ResourceValidationFailure{{ + APIVersion: "other.org/v1", + Kind: "Thing", + Name: "thing", + Status: "missingSchema", + }}, + }, + }, + "AdmissionRejection_ExposesTypedFailureWithoutUpstreamResult": { + reason: "An apiserver dry-run rejection carries no pkgvalidate.ValidationResult, so it populates Failures directly. The row must be shape-identical to a locally-produced one (status 'invalid') so a consumer iterating validationFailures cannot tell which detector found it — only the 'admission' type distinguishes it. The message names the rejected composed resource, because ResourceID identifies the user's input (the XR) and would otherwise leave a human unable to tell which composed resource the cluster refused.", + resourceID: "XBucket/my-xr", + err: NewAdmissionRejectionError("Bucket/my-bucket", &un.Unstructured{Object: map[string]any{ + "apiVersion": "s3.aws.crossplane.io/v1beta1", + "kind": "Bucket", + "metadata": map[string]any{ + "name": "my-bucket", + "namespace": "prod", + }, + }}, errors.New(`admission webhook "policy.example.org" denied the request: region must be us-*`)), + want: dt.OutputError{ + ResourceID: "XBucket/my-xr", + Message: `the cluster rejected Bucket/my-bucket: admission webhook "policy.example.org" denied the request: region must be us-*`, + ValidationFailures: []dt.ResourceValidationFailure{{ + APIVersion: "s3.aws.crossplane.io/v1beta1", + Kind: "Bucket", + Name: "my-bucket", + Namespace: "prod", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "admission", + Message: `admission webhook "policy.example.org" denied the request: region must be us-*`, + }}, + }}, + }, + }, + "ExplicitFailuresWinOverResult": { + reason: "Failures and Result are never both set in practice, but preferring the explicit list means a caller that sets it cannot have it silently dropped in favour of a stale Result.", + resourceID: "XR/my-xr", + err: NewSchemaValidationError("", "msg", errors.New("inner")). + WithResult(&pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "from.result/v1", + Kind: "FromResult", + Status: pkgvalidate.ValidationStatusInvalid, + }}, + }). + WithFailures([]dt.ResourceValidationFailure{{ + APIVersion: "from.failures/v1", + Kind: "FromFailures", + Status: "invalid", + }}), + want: dt.OutputError{ + ResourceID: "XR/my-xr", + Message: "msg", + ValidationFailures: []dt.ResourceValidationFailure{{ + APIVersion: "from.failures/v1", + Kind: "FromFailures", + Status: "invalid", + }}, + }, + }, + "AdmissionRejection_UnnamedResourceStillProducesAUsableRow": { + reason: "A generateName-only addition has no metadata.name, which is exactly the case SSA apply could not handle and dry-run create exists to cover. The failure row must still carry enough identity (apiVersion/kind/namespace) to be actionable rather than emitting a bare empty name as if it were the whole story, and the message must fall back to the generateName-shaped description.", + resourceID: "XBucket/my-xr", + err: NewAdmissionRejectionError("Bucket/my-bucket-(generated)", &un.Unstructured{Object: map[string]any{ + "apiVersion": "s3.aws.crossplane.io/v1beta1", + "kind": "Bucket", + "metadata": map[string]any{ + "generateName": "my-bucket-", + "namespace": "prod", + }, + }}, errors.New("denied")), + want: dt.OutputError{ + ResourceID: "XBucket/my-xr", + Message: "the cluster rejected Bucket/my-bucket-(generated): denied", + ValidationFailures: []dt.ResourceValidationFailure{{ + APIVersion: "s3.aws.crossplane.io/v1beta1", + Kind: "Bucket", + Namespace: "prod", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "admission", + Message: "denied", + }}, + }}, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := NewOutputError(tc.resourceID, tc.err) + if diff := gcmp.Diff(tc.want, got); diff != "" { + t.Errorf("\n%s\nNewOutputError() mismatch (-want +got):\n%s", tc.reason, diff) + } + }) + } +} + +func TestValidationFailuresFromResult(t *testing.T) { + tests := map[string]struct { + reason string + result *pkgvalidate.ValidationResult + want []dt.ResourceValidationFailure + }{ + "NilResultReturnsNil": { + reason: "A nil ValidationResult yields nil; the converter never invents structure.", + result: nil, + want: nil, + }, + "OnlyValidResourcesReturnsNil": { + reason: "ValidationFailures lists 'what failed', not the full audit log; valid resources are filtered out.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "ok", + Status: pkgvalidate.ValidationStatusValid, + }}, + }, + want: nil, + }, + "DefaultingFailedFiltered": { + reason: "pkgvalidate.ResultError treats DefaultingFailed as success, so a SchemaValidationError reaching us shouldn't advertise these resources as failures via ValidationFailures.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "x", + Status: pkgvalidate.ValidationStatusDefaultingFailed, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeDefaulting, + Message: "cannot apply defaults", + }}, + }}, + }, + want: nil, + }, + "InvalidWithMixedErrorsSuppressesDefaulting": { + reason: "Mirrors formatValidationErrors: when actionable errors are present, defaulting entries are dropped so the typed and rendered views agree on the failure detail.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Namespace: "production", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{ + {Type: pkgvalidate.FieldErrorTypeDefaulting, Message: "cannot apply defaults"}, + {Type: pkgvalidate.FieldErrorTypeSchema, Field: "spec.region", Message: "spec.region: Required value"}, + }, + }}, + }, + want: []dt.ResourceValidationFailure{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Namespace: "production", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "schema", + Field: "spec.region", + Message: "spec.region: Required value", + }}, + }}, + }, + "InvalidWithOnlyDefaultingErrorsKeepsThem": { + reason: "Defensive: upstream's statusFromErrors won't produce Invalid+only-defaulting today, but if it ever did we surface the defaulting entries rather than emit an Invalid row with no errors.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeDefaulting, + Message: "cannot apply defaults", + }}, + }}, + }, + want: []dt.ResourceValidationFailure{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "defaulting", + Message: "cannot apply defaults", + }}, + }}, + }, + "MissingSchemaSurfacedWithoutErrors": { + reason: "Missing-schema resources surface with their GVK / name / namespace and Status, but no errors[] (the validator never ran).", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "other.org/v1", + Kind: "SomeResource", + Name: "thing", + Status: pkgvalidate.ValidationStatusMissingSchema, + }}, + }, + want: []dt.ResourceValidationFailure{{ + APIVersion: "other.org/v1", + Kind: "SomeResource", + Name: "thing", + Status: "missingSchema", + }}, + }, + "BadValuePropagated": { + reason: "FieldValidationError.Value travels through unchanged, preserving its Go type, so JSON output renders it without forcing callers to parse it back.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Field: "spec.replicas", + Message: `spec.replicas: Invalid value: "five"`, + Value: "five", + }}, + }}, + }, + want: []dt.ResourceValidationFailure{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "schema", + Field: "spec.replicas", + Message: `spec.replicas: Invalid value: "five"`, + Value: "five", + }}, + }}, + }, + "MultipleResourcesPreserveOrder": { + reason: "The output preserves input order across resources and skips valid ones, so consumers can rely on the position correspondence between input resources and emitted failures.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{ + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "first", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Message: "first error", + }}, + }, + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "ok-skipped", + Status: pkgvalidate.ValidationStatusValid, + }, + { + APIVersion: "other.org/v1", + Kind: "Thing", + Name: "third", + Status: pkgvalidate.ValidationStatusMissingSchema, + }, + }, + }, + want: []dt.ResourceValidationFailure{ + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "first", + Status: "invalid", + Errors: []dt.FieldValidationError{{ + Type: "schema", + Message: "first error", + }}, + }, + { + APIVersion: "other.org/v1", + Kind: "Thing", + Name: "third", + Status: "missingSchema", + }, + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := validationFailuresFromResult(tc.result) + if diff := gcmp.Diff(tc.want, got); diff != "" { + t.Errorf("\n%s\nvalidationFailuresFromResult() mismatch (-want +got):\n%s", tc.reason, diff) + } + }) + } +} + +func TestIsOnlySchemaValidationErrors(t *testing.T) { + tests := map[string]struct { + err error + want bool + }{ + "NilError": { + err: nil, + want: true, + }, + "SingleSchemaValidationError": { + err: &SchemaValidationError{Message: "test"}, + want: true, + }, + "SingleToolError": { + err: errors.New("tool error"), + want: false, + }, + "MultipleSchemaValidationErrors": { + err: errors.Join( + &SchemaValidationError{ResourceID: "Kind1/res1", Message: "error1"}, + &SchemaValidationError{ResourceID: "Kind2/res2", Message: "error2"}, + ), + want: true, + }, + "MixedErrors_ToolFirst": { + err: errors.Join( + errors.New("tool error"), + &SchemaValidationError{ResourceID: "Kind/res", Message: "validation"}, + ), + want: false, + }, + "MixedErrors_SchemaFirst": { + err: errors.Join( + &SchemaValidationError{ResourceID: "Kind/res", Message: "validation"}, + errors.New("tool error"), + ), + want: false, + }, + "NestedSchemaValidationError": { + err: &SchemaValidationError{ + ResourceID: "Kind/res", + Message: "outer", + Err: &SchemaValidationError{Message: "inner"}, + }, + want: true, + }, + "SchemaValidationWrappingUnderlyingError": { + // A SchemaValidationError wrapping an underlying error (from the validation library) + // is still a schema validation error as a whole. The wrapped error is just the cause/detail. + err: &SchemaValidationError{ + ResourceID: "Kind/res", + Message: "wrapper", + Err: errors.New("underlying validation error"), + }, + want: true, + }, + "DeeplyNestedAllSchemaValidation": { + err: errors.Join( + &SchemaValidationError{Message: "level1"}, + errors.Join( + &SchemaValidationError{Message: "level2a"}, + &SchemaValidationError{Message: "level2b"}, + ), + ), + want: true, + }, + "DeeplyNestedWithToolError": { + err: errors.Join( + &SchemaValidationError{Message: "level1"}, + errors.Join( + &SchemaValidationError{Message: "level2a"}, + errors.New("buried tool error"), + ), + ), + want: false, + }, + "PkgErrorsWrappedSchemaValidation": { + // Simulates errors.Wrap from pkg/errors wrapping a SchemaValidationError + err: &causedError{ + msg: "wrapped", + cause: &SchemaValidationError{Message: "inner"}, + }, + want: true, + }, + "PkgErrorsWrappedToolError": { + err: &causedError{ + msg: "wrapped", + cause: errors.New("tool error"), + }, + want: false, + }, + "PkgErrorsDoubleWrappedSchemaValidation": { + err: &causedError{ + msg: "outer", + cause: &causedError{ + msg: "inner", + cause: &SchemaValidationError{Message: "validation"}, + }, + }, + want: true, + }, + "CrossplaneRuntimeWrappedSchemaValidation": { + // This simulates the actual wrapping pattern used in diff_processor.go: + // errors.Wrap(schemaValidationError, "cannot validate resources") + err: xperrors.Wrap(&SchemaValidationError{Message: "inner"}, "wrapped"), + want: true, + }, + "CrossplaneRuntimeDoubleWrappedSchemaValidation": { + // errors.Wrap wrapping another errors.Wrap wrapping SchemaValidationError + err: xperrors.Wrap(xperrors.Wrap(&SchemaValidationError{Message: "inner"}, "inner wrap"), "outer wrap"), + want: true, + }, + "CrossplaneRuntimeWrappedToolError": { + err: xperrors.Wrap(errors.New("tool error"), "wrapped"), + want: false, + }, + "CrossplaneRuntimeJoinedSchemaValidationErrors": { + // This simulates the actual pattern in PerformDiff where errors are joined + err: xperrors.Join( + xperrors.Wrapf( + xperrors.Wrap(&SchemaValidationError{Message: "validation"}, "cannot validate resources"), + "unable to process resource %s", "Kind/name", + ), + ), + want: true, + }, + "CrossplaneRuntimeJoinedMixedErrors": { + // Mix of tool error and schema validation error via xperrors.Join + err: xperrors.Join( + xperrors.Wrap(errors.New("tool error"), "wrapped"), + xperrors.Wrap(&SchemaValidationError{Message: "validation"}, "cannot validate resources"), + ), + want: false, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := isOnlySchemaValidationErrors(tc.err) + if got != tc.want { + t.Errorf("isOnlySchemaValidationErrors() = %v, want %v", got, tc.want) + } + }) + } +} + +// causedError is a test helper that implements the Cause() interface used by pkg/errors. +type causedError struct { + msg string + cause error +} + +func (e *causedError) Error() string { return e.msg } +func (e *causedError) Cause() error { return e.cause } + +func TestDetermineExitCode(t *testing.T) { + tests := map[string]struct { + err error + hasDiffs bool + want int + }{ + "NoErrorNoDiff": { + err: nil, + hasDiffs: false, + want: ExitCodeSuccess, + }, + "NoErrorWithDiff": { + err: nil, + hasDiffs: true, + want: ExitCodeDiffDetected, + }, + "ToolError": { + err: errors.New("tool error"), + hasDiffs: false, + want: ExitCodeToolError, + }, + "ToolErrorWithDiff": { + err: errors.New("tool error"), + hasDiffs: true, + want: ExitCodeToolError, + }, + "SchemaValidationError": { + err: &SchemaValidationError{Message: "validation failed"}, + hasDiffs: false, + want: ExitCodeSchemaValidation, + }, + "SchemaValidationErrorWithDiff": { + err: &SchemaValidationError{Message: "validation failed"}, + hasDiffs: true, + want: ExitCodeSchemaValidation, + }, + "WrappedSchemaValidationError": { + // A schema validation error wrapped with fmt.Errorf %w should still be detected + // as a schema validation error (the wrapper doesn't introduce a new error type) + err: fmt.Errorf("context: %w", &SchemaValidationError{Message: "inner"}), + hasDiffs: false, + want: ExitCodeSchemaValidation, + }, + "Interrupted": { + // An interrupted run is neither a success, a diff, nor an ordinary tool failure. + err: ErrInterrupted, + hasDiffs: true, + want: ExitCodeInterrupted, + }, + "InterruptionOutranksOtherErrors": { + // Once interrupted, the per-resource failures are the interruption's + // fallout (cancelled calls), so they must not mask it. + err: errors.Join( + &SchemaValidationError{Message: "validation failed"}, + fmt.Errorf("wrapped: %w", ErrInterrupted), + ), + want: ExitCodeInterrupted, + }, + "AdmissionRejectionIsAValidationError": { + // An apiserver dry-run rejection routes to exit 2, not 1. This is the + // behaviour change from crossplane-diff#334: before it, the same webhook + // rejection produced exit 1 for an existing resource, so the exit code + // depended on whether the resource already existed rather than on what + // the cluster said. + err: NewAdmissionRejectionError("Bucket/my-bucket", &un.Unstructured{Object: map[string]any{ + "apiVersion": "s3.aws.crossplane.io/v1beta1", + "kind": "Bucket", + }}, errors.New(`admission webhook "x" denied the request`)), + hasDiffs: false, + want: ExitCodeSchemaValidation, + }, + "AdmissionRejectionMixedWithToolErrorStillExitsToolError": { + // Tool errors outrank validation errors, so a run that both failed to + // reach the cluster and saw a rejection must not be reported as a mere + // validation problem. + err: errors.Join( + errors.New("kube unreachable"), + NewAdmissionRejectionError("Bucket/my-bucket", &un.Unstructured{Object: map[string]any{ + "apiVersion": "s3.aws.crossplane.io/v1beta1", + "kind": "Bucket", + }}, errors.New("denied")), + ), + hasDiffs: false, + want: ExitCodeToolError, + }, + // Edge cases for batch processing with multiple errors + "MultipleSchemaValidationErrors": { + err: errors.Join( + &SchemaValidationError{ResourceID: "Kind1/res1", Message: "field1 invalid"}, + &SchemaValidationError{ResourceID: "Kind2/res2", Message: "field2 invalid"}, + &SchemaValidationError{ResourceID: "Kind3/res3", Message: "field3 invalid"}, + ), + hasDiffs: false, + want: ExitCodeSchemaValidation, + }, + "MultipleSchemaValidationErrorsWithDiffs": { + err: errors.Join( + &SchemaValidationError{ResourceID: "Kind1/res1", Message: "field1 invalid"}, + &SchemaValidationError{ResourceID: "Kind2/res2", Message: "field2 invalid"}, + ), + hasDiffs: true, + want: ExitCodeSchemaValidation, + }, + "MixedToolAndSchemaValidationErrors_ToolErrorTakesPriority": { + // When there's a mix of tool errors and schema validation errors, + // tool error (exit code 1) should take priority over schema validation (exit code 2) + err: errors.Join( + errors.New("connection refused"), + &SchemaValidationError{ResourceID: "Kind/res", Message: "invalid field"}, + ), + hasDiffs: false, + want: ExitCodeToolError, + }, + "MixedToolAndSchemaValidationErrors_SchemaFirst": { + // Order shouldn't matter - tool error still takes priority + err: errors.Join( + &SchemaValidationError{ResourceID: "Kind/res", Message: "invalid field"}, + errors.New("connection refused"), + ), + hasDiffs: false, + want: ExitCodeToolError, + }, + "MixedToolAndSchemaValidationErrorsWithDiffs": { + err: errors.Join( + errors.New("partial failure"), + &SchemaValidationError{ResourceID: "Kind/res", Message: "invalid"}, + ), + hasDiffs: true, + want: ExitCodeToolError, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := DetermineExitCode(tc.err, tc.hasDiffs) + if got != tc.want { + t.Errorf("DetermineExitCode() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestWithInterruption(t *testing.T) { + // A signal cancels the parent (the signal context); --timeout expires the run context's deadline. + parent, cancelParent := context.WithCancel(context.Background()) + interrupted, cancelInterrupted := context.WithTimeout(parent, time.Hour) + defer cancelInterrupted() + cancelParent() + + timedOut, cancelTimedOut := context.WithTimeout(context.Background(), 0) + defer cancelTimedOut() + + existing := []dt.OutputError{{ResourceID: "XR/a", Message: "context canceled"}} + + tests := map[string]struct { + ctx context.Context + errs []dt.OutputError + want []dt.OutputError + }{ + "InterruptedAppends": { + ctx: interrupted, + errs: existing, + want: append(append([]dt.OutputError{}, existing...), dt.OutputError{Message: "run interrupted by signal; results are incomplete"}), + }, + "InterruptedWithNoOtherErrors": { + ctx: interrupted, + want: []dt.OutputError{{Message: "run interrupted by signal; results are incomplete"}}, + }, + "TimedOutUnchanged": {ctx: timedOut, errs: existing, want: existing}, + "LiveUnchanged": {ctx: context.Background(), errs: existing, want: existing}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if diff := gcmp.Diff(tt.want, withInterruption(tt.ctx, tt.errs)); diff != "" { + t.Errorf("withInterruption() -want +got:\n%s", diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/function_provider.go b/internal/diff/diffprocessor/function_provider.go new file mode 100644 index 00000000..4b26ce7d --- /dev/null +++ b/internal/diff/diffprocessor/function_provider.go @@ -0,0 +1,285 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + "strings" + "sync" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" +) + +// FunctionProvider provides functions for rendering compositions. +// Different implementations can fetch functions on-demand or return cached functions. +type FunctionProvider interface { + // GetFunctionsForComposition returns the functions needed to render a composition. + GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) + + // Cleaner stops and removes any resources created during function execution. + // For providers that don't create resources (like the wrappers), this is a no-op. + Cleaner +} + +// EnvDockerNetwork is the environment variable that specifies which Docker +// network the crossplane-render container and function containers should +// join. NewEngineRenderFn reads this once and routes the value through +// render.EngineFlags.CrossplaneDockerNetwork; the upstream docker engine +// then both runs the render container on that network and annotates fns +// to join it at Setup time. This is needed when crossplane-diff runs +// inside a Docker container (e.g. a GitHub Actions container job). +const EnvDockerNetwork = "CROSSPLANE_DIFF_DOCKER_NETWORK" + +// CachedFunctionProvider lazy-loads functions and caches them by composition name. +// This is appropriate for the comp command, where many XRs use the same composition: +// the cluster is asked for a composition's functions once, however many XRs render it. +// The function containers themselves are started, reused across renders and removed +// by the render engine, which names them for the run that owns them (see EngineRenderFn). +// +// It is safe for concurrent use, and every call returns its own copy of the functions: the +// providers that wrap it (RegistryOverrideFunctionProvider, EnvInjectingFunctionProvider) and the +// render engine modify the functions they are given. +type CachedFunctionProvider struct { + fnClient xp.FunctionClient + mu sync.Mutex + cache map[string][]pkgv1.Function + logger logging.Logger +} + +// NewCachedFunctionProvider creates a new CachedFunctionProvider. +func NewCachedFunctionProvider(fnClient xp.FunctionClient, logger logging.Logger) FunctionProvider { + return &CachedFunctionProvider{ + fnClient: fnClient, + cache: make(map[string][]pkgv1.Function), + logger: logger, + } +} + +// GetFunctionsForComposition fetches and caches functions on first call per composition. +func (p *CachedFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + compName := comp.GetName() + + // Held across the fetch, which only reads the function client's in-memory cache, so each + // composition is fetched once. + p.mu.Lock() + defer p.mu.Unlock() + + if cached, ok := p.cache[compName]; ok { + p.logger.Debug("Using cached functions", "composition", compName, "count", len(cached)) + return deepCopyFunctions(cached), nil + } + + // Cache miss - fetch and cache functions + p.logger.Debug("Fetching functions for caching", "composition", compName) + + fns, err := p.fnClient.GetFunctionsFromPipeline(comp) + if err != nil { + return nil, errors.Wrap(err, "cannot get functions from pipeline") + } + + p.logger.Debug("Fetched functions for caching", "composition", compName, "count", len(fns)) + + p.cache[compName] = fns + + return deepCopyFunctions(fns), nil +} + +// deepCopyFunctions returns a copy of fns that shares no maps or slices with it. +func deepCopyFunctions(fns []pkgv1.Function) []pkgv1.Function { + out := make([]pkgv1.Function, len(fns)) + for i := range fns { + fns[i].DeepCopyInto(&out[i]) + } + + return out +} + +// Cleanup is a no-op for CachedFunctionProvider: the render engine owns the containers. +func (p *CachedFunctionProvider) Cleanup(_ context.Context) error { + return nil +} + +// RegistryOverrideFunctionProvider wraps another FunctionProvider and replaces +// the registry in each function's package reference before returning them. +type RegistryOverrideFunctionProvider struct { + inner FunctionProvider + registry string + logger logging.Logger +} + +// NewRegistryOverrideFunctionProvider wraps inner, replacing the registry +// portion of every function package ref with the given registry. +func NewRegistryOverrideFunctionProvider(inner FunctionProvider, registry string, logger logging.Logger) FunctionProvider { + return &RegistryOverrideFunctionProvider{ + inner: inner, + registry: registry, + logger: logger, + } +} + +// GetFunctionsForComposition delegates to the wrapped provider and rewrites +// the registry portion of each returned function's package ref. +func (p *RegistryOverrideFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns, err := p.inner.GetFunctionsForComposition(comp) + if err != nil { + return nil, err + } + + for i := range fns { + orig := fns[i].Spec.Package + + replaced := replaceRegistry(orig, p.registry) + if replaced != orig { + p.logger.Debug("Overriding function registry", + "function", fns[i].GetName(), + "from", orig, + "to", replaced) + fns[i].Spec.Package = replaced + } + } + + return fns, nil +} + +// Cleanup delegates to the wrapped provider. +func (p *RegistryOverrideFunctionProvider) Cleanup(ctx context.Context) error { + return p.inner.Cleanup(ctx) +} + +// EnvMaxRecvMessageSize is the container env var crossplane-diff injects to +// raise a function's gRPC max receive size. It is intentionally unprefixed and +// function-agnostic, mirroring the SDK-wide TLS_SERVER_CERTS_DIR convention +// shared by crossplane composition functions: functions that bind it honor it, +// functions that don't simply ignore it. +const EnvMaxRecvMessageSize = "MAX_RECV_MESSAGE_SIZE" + +// EnvInjectingFunctionProvider wraps another FunctionProvider and upserts a set +// of env pairs into each function's runtime-docker-env annotation before +// returning them. Used to raise the function gRPC max-recv-message-size so large +// XRs don't trip the function-sdk-go 4MB default under `crossplane render` +// (which, unlike the in-cluster runtime, does not apply DeploymentRuntimeConfig). +type EnvInjectingFunctionProvider struct { + inner FunctionProvider + envs map[string]string + logger logging.Logger +} + +// NewEnvInjectingFunctionProvider wraps inner, upserting the given env pairs +// into each returned function's runtime-docker-env annotation. +func NewEnvInjectingFunctionProvider(inner FunctionProvider, envs map[string]string, logger logging.Logger) FunctionProvider { + return &EnvInjectingFunctionProvider{ + inner: inner, + envs: envs, + logger: logger, + } +} + +// GetFunctionsForComposition delegates to the wrapped provider and upserts the +// configured env pairs into each function's runtime-docker-env annotation. The +// upsert is idempotent so repeated calls over a caching inner provider don't +// duplicate pairs. +func (p *EnvInjectingFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns, err := p.inner.GetFunctionsForComposition(comp) + if err != nil { + return nil, err + } + + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = make(map[string]string) + } + + cur := fns[i].Annotations[render.AnnotationKeyRuntimeEnvironmentVariables] + for k, v := range p.envs { + cur = upsertEnvPair(cur, k, v) + } + + fns[i].Annotations[render.AnnotationKeyRuntimeEnvironmentVariables] = cur + + p.logger.Debug("Injected function runtime env", + "function", fns[i].GetName(), + render.AnnotationKeyRuntimeEnvironmentVariables, cur) + } + + return fns, nil +} + +// Cleanup delegates to the wrapped provider. +func (p *EnvInjectingFunctionProvider) Cleanup(ctx context.Context) error { + return p.inner.Cleanup(ctx) +} + +// upsertEnvPair sets key=val in a comma-separated "k=v,k=v" list, replacing an +// existing entry for key (idempotent) or appending a new one. Blank segments +// are dropped. +func upsertEnvPair(cur, key, val string) string { + pairs := make([]string, 0, 4) + replaced := false + + for seg := range strings.SplitSeq(cur, ",") { + if seg == "" { + continue + } + + if k, _, ok := strings.Cut(seg, "="); ok && k == key { + pairs = append(pairs, key+"="+val) + replaced = true + + continue + } + + pairs = append(pairs, seg) + } + + if !replaced { + pairs = append(pairs, key+"="+val) + } + + return strings.Join(pairs, ",") +} + +// replaceRegistry replaces the registry portion of an OCI package reference, +// preserving the repository path, tag, and/or digest. A trailing slash on +// newRegistry is trimmed. +// +// The first path component is treated as a registry host only when it follows +// the standard OCI rule: it contains a '.' or ':', or it is exactly +// "localhost". Otherwise the ref has no explicit registry (e.g. +// "crossplane-contrib/function-auto-ready:v1.0.0") and newRegistry is prepended +// to the full ref instead of replacing the first path segment. +func replaceRegistry(pkg, newRegistry string) string { + newRegistry = strings.TrimRight(newRegistry, "/") + + idx := strings.Index(pkg, "/") + if idx < 0 { + return newRegistry + "/" + pkg + } + + first := pkg[:idx] + if !strings.ContainsAny(first, ".:") && first != "localhost" { + return newRegistry + "/" + pkg + } + + return newRegistry + pkg[idx:] +} diff --git a/internal/diff/diffprocessor/function_provider_test.go b/internal/diff/diffprocessor/function_provider_test.go new file mode 100644 index 00000000..d220d3ab --- /dev/null +++ b/internal/diff/diffprocessor/function_provider_test.go @@ -0,0 +1,505 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "strconv" + "strings" + "sync" + "sync/atomic" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func TestNewCachedFunctionProvider(t *testing.T) { + fnClient := tu.NewMockFunctionClient().Build() + logger := tu.TestLogger(t, false) + + provider := NewCachedFunctionProvider(fnClient, logger) + + if provider == nil { + t.Fatal("NewCachedFunctionProvider() returned nil") + } + + cached, ok := provider.(*CachedFunctionProvider) + if !ok { + t.Fatalf("NewCachedFunctionProvider() returned wrong type: %T", provider) + } + + if cached.cache == nil { + t.Error("NewCachedFunctionProvider() did not initialize cache") + } + + if len(cached.cache) != 0 { + t.Errorf("NewCachedFunctionProvider() cache not empty, got %d entries", len(cached.cache)) + } +} + +func TestCachedFunctionProvider_GetFunctionsForComposition_LazyLoading(t *testing.T) { + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-test"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.io/crossplane/function-go-templating:v0.11.0", + }, + }, + }, + } + + fnClient := tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build() + + logger := tu.TestLogger(t, false) + provider := NewCachedFunctionProvider(fnClient, logger) + + comp := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + } + + fns, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if len(fns) != 1 { + t.Fatalf("GetFunctionsForComposition() returned %d functions, want 1", len(fns)) + } + + // The provider only caches: naming and cleaning up function containers is the render engine's job, + // so the functions come back exactly as the cluster returned them. + if diff := gcmp.Diff(functions, fns); diff != "" { + t.Errorf("GetFunctionsForComposition() -want, +got:\n%s", diff) + } +} + +func TestCachedFunctionProvider_GetFunctionsForComposition_CacheHit(t *testing.T) { + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-test"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.io/test/function:v1.0.0", + }, + }, + }, + } + + fetchCount := 0 + fnClient := tu.NewMockFunctionClient(). + WithFunctionsFetchCallback(func() ([]pkgv1.Function, error) { + fetchCount++ + return functions, nil + }). + Build() + + logger := tu.TestLogger(t, false) + provider := NewCachedFunctionProvider(fnClient, logger) + + comp := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + } + + // First call should fetch + fns1, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("First GetFunctionsForComposition() error = %v", err) + } + + if fetchCount != 1 { + t.Errorf("First call: fetch count = %d, want 1", fetchCount) + } + + // Second call should use cache + fns2, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("Second GetFunctionsForComposition() error = %v", err) + } + + if fetchCount != 1 { + t.Errorf("Second call: fetch count = %d, want 1 (should use cache)", fetchCount) + } + + // Verify both calls return the same data + if len(fns1) != len(fns2) { + t.Errorf("Cache returned different number of functions: first=%d, second=%d", len(fns1), len(fns2)) + } + + if fns1[0].Name != fns2[0].Name { + t.Errorf("Cache returned different function: first=%s, second=%s", fns1[0].Name, fns2[0].Name) + } +} + +func TestCachedFunctionProvider_GetFunctionsForComposition_Error(t *testing.T) { + fnClient := tu.NewMockFunctionClient(). + WithFailedFunctionsFetch("fetch error"). + Build() + + logger := tu.TestLogger(t, false) + provider := NewCachedFunctionProvider(fnClient, logger) + + comp := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + } + + _, err := provider.GetFunctionsForComposition(comp) + if err == nil { + t.Fatal("GetFunctionsForComposition() expected error, got nil") + } + + if !strings.Contains(err.Error(), "cannot get functions from pipeline") { + t.Errorf("GetFunctionsForComposition() error = %v, want error containing 'cannot get functions from pipeline'", err) + } +} + +func TestCachedFunctionProvider_GetFunctionsForComposition_MultipleCompositions(t *testing.T) { + fetchCalls := make(map[string]int) + + fnClient := tu.NewMockFunctionClient(). + WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fetchCalls[comp.Name]++ + + if comp.Name == "composition-1" { + return []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-1"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.io/test/function-one:v1.0.0", + }, + }, + }, + }, nil + } + + return []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-2"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.io/test/function-two:v2.0.0", + }, + }, + }, + }, nil + }). + Build() + + logger := tu.TestLogger(t, false) + provider := NewCachedFunctionProvider(fnClient, logger) + + comp1 := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "composition-1"}, + } + comp2 := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "composition-2"}, + } + + // Fetch for composition 1 + fns1, err := provider.GetFunctionsForComposition(comp1) + if err != nil { + t.Fatalf("GetFunctionsForComposition(comp1) error = %v", err) + } + + // Fetch for composition 2 + fns2, err := provider.GetFunctionsForComposition(comp2) + if err != nil { + t.Fatalf("GetFunctionsForComposition(comp2) error = %v", err) + } + + // Fetch comp1 again (should hit cache) + fns1Again, err := provider.GetFunctionsForComposition(comp1) + if err != nil { + t.Fatalf("GetFunctionsForComposition(comp1 again) error = %v", err) + } + + // Verify each composition gets its own functions + if fns1[0].Name == fns2[0].Name { + t.Error("Different compositions should have different functions") + } + + // Verify cache works for comp1 second call + if fns1[0].Name != fns1Again[0].Name { + t.Error("Cache miss for composition-1 second call") + } + + // Verify we only fetched once per composition + if fetchCalls["composition-1"] != 1 { + t.Errorf("composition-1 fetched %d times, want 1", fetchCalls["composition-1"]) + } + + if fetchCalls["composition-2"] != 1 { + t.Errorf("composition-2 fetched %d times, want 1", fetchCalls["composition-2"]) + } +} + +func TestReplaceRegistry(t *testing.T) { + tests := map[string]struct { + pkg string + newRegistry string + want string + }{ + "StandardPackage": { + pkg: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", + newRegistry: "registry.example.com", + want: "registry.example.com/crossplane-contrib/function-go-templating:v0.11.0", + }, + "DifferentRegistry": { + pkg: "ghcr.io/crossplane/function-auto-ready:v1.2.3", + newRegistry: "registry.example.com", + want: "registry.example.com/crossplane/function-auto-ready:v1.2.3", + }, + "SHA256Digest": { + pkg: "old.registry.io/org/func@sha256:abc123", + newRegistry: "new.registry.io", + want: "new.registry.io/org/func@sha256:abc123", + }, + "NoExplicitRegistry": { + // First segment lacks '.', ':' or "localhost", so it is treated as + // part of the path (per OCI rules), and newRegistry is prepended. + pkg: "crossplane-contrib/function-auto-ready:v1.0.0", + newRegistry: "new.registry.io", + want: "new.registry.io/crossplane-contrib/function-auto-ready:v1.0.0", + }, + "NewRegistryWithTrailingSlash": { + pkg: "xpkg.crossplane.io/foo/bar:v1", + newRegistry: "new.registry.io/", + want: "new.registry.io/foo/bar:v1", + }, + "LocalhostWithPort": { + pkg: "localhost:5000/foo/bar:v1", + newRegistry: "new.registry.io", + want: "new.registry.io/foo/bar:v1", + }, + "NoSlash": { + pkg: "bare-image:v1", + newRegistry: "new.registry.io", + want: "new.registry.io/bare-image:v1", + }, + "DeepPath": { + pkg: "registry.io/a/b/c/func:v1", + newRegistry: "other.io", + want: "other.io/a/b/c/func:v1", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := replaceRegistry(tt.pkg, tt.newRegistry) + if got != tt.want { + t.Errorf("replaceRegistry(%q, %q) = %q, want %q", tt.pkg, tt.newRegistry, got, tt.want) + } + }) + } +} + +func TestRegistryOverrideFunctionProvider(t *testing.T) { + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "function-go-templating"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "original.registry.io/crossplane-contrib/function-go-templating:v0.11.0", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "function-auto-ready"}, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "other.registry.io/crossplane-contrib/function-auto-ready:v1.0.0", + }, + }, + }, + } + + fnClient := tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build() + + logger := tu.TestLogger(t, false) + inner := NewCachedFunctionProvider(fnClient, logger) + + provider := NewRegistryOverrideFunctionProvider(inner, "registry.example.com", logger) + + comp := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, + } + + fns, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if len(fns) != 2 { + t.Fatalf("GetFunctionsForComposition() returned %d functions, want 2", len(fns)) + } + + if fns[0].Spec.Package != "registry.example.com/crossplane-contrib/function-go-templating:v0.11.0" { + t.Errorf("function[0].Spec.Package = %q, want overridden registry", fns[0].Spec.Package) + } + + if fns[1].Spec.Package != "registry.example.com/crossplane-contrib/function-auto-ready:v1.0.0" { + t.Errorf("function[1].Spec.Package = %q, want overridden registry", fns[1].Spec.Package) + } +} + +func TestUpsertEnvPair(t *testing.T) { + tests := []struct { + name string + cur string + key string + val string + want string + }{ + {name: "empty", cur: "", key: "A", val: "1", want: "A=1"}, + {name: "append to other key", cur: "FOO=bar", key: "A", val: "1", want: "FOO=bar,A=1"}, + {name: "replace same key", cur: "A=1", key: "A", val: "2", want: "A=2"}, + {name: "replace same key among others", cur: "FOO=bar,A=1,BAZ=qux", key: "A", val: "2", want: "FOO=bar,A=2,BAZ=qux"}, + {name: "ignores blank segments", cur: "FOO=bar,", key: "A", val: "1", want: "FOO=bar,A=1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := upsertEnvPair(tt.cur, tt.key, tt.val); got != tt.want { + t.Errorf("upsertEnvPair(%q, %q, %q) = %q, want %q", tt.cur, tt.key, tt.val, got, tt.want) + } + }) + } +} + +// TestEnvInjectingFunctionProvider verifies the decorator injects the env pair +// into every function's runtime-docker-env annotation, appends to any existing +// value, and is idempotent across repeated calls (which matters when the inner +// provider caches and returns the same Function values). +func TestEnvInjectingFunctionProvider(t *testing.T) { + newInner := func(anns map[string]string) FunctionProvider { + fn := pkgv1.Function{ObjectMeta: metav1.ObjectMeta{Name: "function-go-templating"}} + if anns != nil { + fn.Annotations = anns + } + + fnClient := tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch([]pkgv1.Function{fn}). + Build() + // CachedFunctionProvider caches and returns the same values on repeat + // calls, so it exercises idempotency of the decorator. + return NewCachedFunctionProvider(fnClient, tu.TestLogger(t, false)) + } + + comp := &apiextensionsv1.Composition{ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}} + envs := map[string]string{EnvMaxRecvMessageSize: "16"} + + t.Run("injects when annotation absent", func(t *testing.T) { + p := NewEnvInjectingFunctionProvider(newInner(nil), envs, tu.TestLogger(t, false)) + + fns, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if got := fns[0].Annotations[render.AnnotationKeyRuntimeEnvironmentVariables]; got != EnvMaxRecvMessageSize+"=16" { + t.Errorf("annotation = %q, want %q", got, EnvMaxRecvMessageSize+"=16") + } + }) + + t.Run("appends to existing runtime-docker-env", func(t *testing.T) { + p := NewEnvInjectingFunctionProvider(newInner(map[string]string{render.AnnotationKeyRuntimeEnvironmentVariables: "FOO=bar"}), envs, tu.TestLogger(t, false)) + + fns, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition() error = %v", err) + } + + if got := fns[0].Annotations[render.AnnotationKeyRuntimeEnvironmentVariables]; got != "FOO=bar,"+EnvMaxRecvMessageSize+"=16" { + t.Errorf("annotation = %q, want %q", got, "FOO=bar,"+EnvMaxRecvMessageSize+"=16") + } + }) + + t.Run("idempotent across repeated calls", func(t *testing.T) { + p := NewEnvInjectingFunctionProvider(newInner(nil), envs, tu.TestLogger(t, false)) + if _, err := p.GetFunctionsForComposition(comp); err != nil { + t.Fatalf("first call error = %v", err) + } + + fns, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("second call error = %v", err) + } + + if got := fns[0].Annotations[render.AnnotationKeyRuntimeEnvironmentVariables]; got != EnvMaxRecvMessageSize+"=16" { + t.Errorf("annotation after repeat = %q, want single pair %q", got, EnvMaxRecvMessageSize+"=16") + } + }) +} + +// TestCachedFunctionProvider_ConcurrentUse has concurrent callers ask for one composition's +// functions and modify what they get, as the render engine does when it annotates functions it +// starts. The composition is fetched once, and no caller's change reaches another's or the cache. +// Run it with -race. +func TestCachedFunctionProvider_ConcurrentUse(t *testing.T) { + functions := []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "function-test", Annotations: map[string]string{}}}} + + var fetches atomic.Int32 + + fnClient := tu.NewMockFunctionClient(). + WithFunctionsFetchCallback(func() ([]pkgv1.Function, error) { + fetches.Add(1) + return functions, nil + }). + Build() + + provider := NewCachedFunctionProvider(fnClient, tu.TestLogger(t, false)) + comp := &apiextensionsv1.Composition{ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}} + + var wg sync.WaitGroup + for i := range 8 { + wg.Go(func() { + fns, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Errorf("GetFunctionsForComposition(): %v", err) + return + } + + fns[0].Annotations["caller"] = strconv.Itoa(i) + }) + } + + wg.Wait() + + if got := fetches.Load(); got != 1 { + t.Errorf("fetches = %d, want 1", got) + } + + fns, err := provider.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition(): %v", err) + } + + if diff := gcmp.Diff(map[string]string{}, fns[0].Annotations); diff != "" { + t.Errorf("a caller's change reached the cache (-want +got):\n%s", diff) + } +} diff --git a/internal/diff/diffprocessor/input_validator.go b/internal/diff/diffprocessor/input_validator.go new file mode 100644 index 00000000..a0deb88b --- /dev/null +++ b/internal/diff/diffprocessor/input_validator.go @@ -0,0 +1,522 @@ +package diffprocessor + +import ( + "fmt" + "sort" + "strconv" + "strings" + + "k8s.io/apimachinery/pkg/api/equality" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + "github.com/crossplane/cli/v2/internal/diff/types" +) + +// This file validates the xr command's input set. PerformDiff drives an InputValidator through three +// stages; the default, bundleInputValidator, treats the inputs as one change set, so any overlap among +// them is settled here: +// +// 1. checkInputs, before rendering (ToRender): a duplicate input, and the same object given twice differently. +// 2. rejectManagedInputs, after rendering (Verdicts): an input that another input manages. +// 3. renderOverlapErrors (RenderOverlaps): two inputs' renders reaching one resource. +// +// Identity throughout is group, kind, namespace and name, independent of API version, since one object +// served at two versions is still one object. An input with no name yet (generateName only) has none. + +// InputValidator validates one PerformDiff run's input set. It is created per run by an +// InputValidatorFactory, because it carries that run's state between stages. +type InputValidator interface { + // ToRender returns the inputs to render, in input order, with duplicates already dropped. + // An input with a non-nil Err was rejected before rendering and must not be rendered. + ToRender() []types.ValidatedInput + // RecordRender records the render of ToRender()[i]: every resource key it produced + // (including unchanged ones) and its error, if any. + RecordRender(i int, rendered map[string]bool, err error) + // Verdicts returns the final error for each group (indexed like ToRender()), or nil. + // It applies stage 2 and the precedence rule: a managed-input verdict replaces that + // group's own render failure. + Verdicts(groups []dt.XRDiffGroup) []error + // RenderOverlaps returns the stage-3 errors. + RenderOverlaps(groups []dt.XRDiffGroup) []error +} + +// InputValidatorFactory creates the InputValidator for one run. +type InputValidatorFactory func(advisories dt.Advisories, inputs []*un.Unstructured) InputValidator + +// bundleInputValidator is the default InputValidator. It is a "bundle" validator because it treats the +// inputs as one change set, to be applied together: any overlap among them — a duplicate, the same object +// twice differently, an input another input manages, two renders reaching one resource — is settled +// against the set as a whole, not per input. +type bundleInputValidator struct { + // checks are checkInputs' verdicts on the inputs to render (duplicates dropped), indexed like toRender. + checks []inputCheck + toRender []types.ValidatedInput + // rendered and failed are, by index, each render's resource keys and each input's own failure + // (its pre-render rejection, or else its render error). + rendered []map[string]bool + failed []error +} + +// NewBundleInputValidator returns the default InputValidator for one run over inputs. It has the +// InputValidatorFactory signature. Stage 1 (checkInputs) runs here, so duplicates are reported once. +func NewBundleInputValidator(advisories dt.Advisories, inputs []*un.Unstructured) InputValidator { + v := &bundleInputValidator{} + + for _, in := range checkInputs(advisories, inputs) { + if in.duplicate { + continue + } + + v.checks = append(v.checks, in) + v.toRender = append(v.toRender, types.ValidatedInput{Resource: in.res, Err: in.err}) + v.failed = append(v.failed, in.err) + } + + v.rendered = make([]map[string]bool, len(v.checks)) + + return v +} + +// ToRender returns the non-duplicate inputs in input order; see InputValidator. +func (v *bundleInputValidator) ToRender() []types.ValidatedInput { + return v.toRender +} + +// RecordRender records the render of ToRender()[i]; see InputValidator. +func (v *bundleInputValidator) RecordRender(i int, rendered map[string]bool, err error) { + v.rendered[i] = rendered + v.failed[i] = err +} + +// Verdicts returns each group's final error. A managed-input verdict replaces the group's own failure: +// that failure is moot, and often a symptom of the same mistake. See rejectManagedInputs. +func (v *bundleInputValidator) Verdicts(groups []dt.XRDiffGroup) []error { + verdicts := make([]error, len(v.failed)) + copy(verdicts, v.failed) + + for i, err := range rejectManagedInputs(groups, v.checks, v.rendered, v.failed) { + verdicts[i] = err + } + + return verdicts +} + +// RenderOverlaps returns the stage-3 errors; see renderOverlapErrors. +func (v *bundleInputValidator) RenderOverlaps(groups []dt.XRDiffGroup) []error { + return renderOverlapErrors(groups) +} + +// inputCheck is checkInputs' verdict on one input. +type inputCheck struct { + res *un.Unstructured + // duplicate is true for an input identical to an earlier one; it is not rendered. + duplicate bool + // err rejects the input before it is rendered. + err error +} + +// checkInputs judges the input set before anything is rendered. An input identical to an earlier one +// has one clear intent — a fat-fingered command line, or CI enumerating the same file twice — so it is +// dropped with a warning. The same object twice with different content fails both inputs: applying both +// would leave whichever is applied last, and the order inputs arrive in (often a glob's) is not intent. +func checkInputs(advisories dt.Advisories, resources []*un.Unstructured) []inputCheck { + checks := make([]inputCheck, len(resources)) + first := make(map[string]int, len(resources)) + + for i, res := range resources { + checks[i] = inputCheck{res: res} + + id := identityOf(res) + if id == "" { + continue + } + + j, seen := first[id] + if !seen { + first[id] = i + continue + } + + if equality.Semantic.DeepEqual(resources[j].Object, res.Object) { + checks[i].duplicate = true + advisories.Raise(dt.OutputWarning{ + Message: "Ignoring a duplicate input: it is identical to an earlier one", + Context: map[string]string{ + "resource": fmt.Sprintf("%s/%s", res.GetKind(), res.GetName()), + "input": strconv.Itoa(i + 1), + "duplicateOf": strconv.Itoa(j + 1), + }, + }) + + continue + } + + checks[i].err = conflictingInputError(i+1, j+1, res) + if checks[j].err == nil { + checks[j].err = conflictingInputError(j+1, i+1, res) + } + } + + return checks +} + +func conflictingInputError(position, other int, res *un.Unstructured) error { + return errors.Errorf("input %d defines %s/%s differently from input %d; applying both would leave whichever "+ + "is applied last, and the order inputs are given in is not a statement of intent — pass only one of them", + position, res.GetKind(), res.GetName(), other) +} + +// rejectManagedInputs returns, by group index, an error for each input that another input manages: an +// XR a parent input composes, and an XR a claim input is bound to. Crossplane writes such an object on +// the manager's behalf — the parent's composition, or the claim controller, applies it with forced +// ownership — so supplying it as an input too gives it a second writer. The managed input is the +// mistake, so it is the one rejected. +// +// inputs, rendered and failed are indexed like groups: each group's input, every resource key its +// render produced (including unchanged ones, which its diffs omit), and its own failure. A managed input +// is judged even if its own render failed, because the verdict supersedes that failure: it is moot, and +// often a symptom of the same mistake (an XR's cluster copy is rarely a valid standalone input). A +// manager must have rendered, since what it manages is read from its render. +func rejectManagedInputs(groups []dt.XRDiffGroup, inputs []inputCheck, rendered []map[string]bool, failed []error) map[int]error { + renderedIDs := make([]map[string]bool, len(groups)) + + for i := range groups { + renderedIDs[i] = make(map[string]bool, len(rendered[i])) + for key := range rendered[i] { + renderedIDs[i][diffKeyIdentity(key)] = true + } + } + + rejected := make(map[int]error) + + for b := range groups { + managed := inputs[b].res + if inputs[b].err != nil || identityOf(managed) == "" { + continue + } + + for a := range groups { + if a == b || failed[a] != nil { + continue + } + + manager := inputs[a].res + + switch { + case renderedIDs[a][identityOf(managed)]: + rejected[b] = errors.Errorf("%s is composed by input %s, whose composition writes it; supplying it "+ + "as an input too gives it a second writer — pass %s only", kindName(managed), kindName(manager), kindName(manager)) + case boundToClaim(groups[b], managed, groups[a], manager): + rejected[b] = errors.Errorf("%s is the XR bound to claim %s, which Crossplane's claim controller writes; "+ + "supplying it as an input too gives it a second writer — pass the claim, not its XR", kindName(managed), kindName(manager)) + } + + if rejected[b] != nil { + break + } + } + } + + return rejected +} + +// boundToClaim reports whether xr is the XR bound to claim. Either side of the binding is enough, read +// from the raw input and from the cluster copy its own diff carries: the claim's spec.resourceRef (which +// only claims have; XRs have resourceRefs) naming the XR, or the XR's spec.claimRef, or the +// claim-name/claim-namespace labels Crossplane stamps on it, naming the claim. +func boundToClaim(xrGroup dt.XRDiffGroup, xr *un.Unstructured, claimGroup dt.XRDiffGroup, claim *un.Unstructured) bool { + // A claim with no name yet binds nothing. Without this, an XR with no claimRef — whose reference + // identity is also "" — would match it. + if identityOf(claim) == "" { + return false + } + + for _, obj := range knownForms(claimGroup, claim) { + if refIdentity(obj, "spec", "resourceRef") == identityOf(xr) { + return true + } + } + + for _, obj := range knownForms(xrGroup, xr) { + if refIdentity(obj, "spec", "claimRef") == identityOf(claim) { + return true + } + + labels := obj.GetLabels() + if name, ok := labels[LabelClaimName]; ok && name == claim.GetName() && labels[LabelClaimNamespace] == claim.GetNamespace() { + return true + } + } + + return false +} + +// knownForms returns what is known of an input's object: the input as given and, from its own diff — +// which the calculator stores even when equal — its cluster copy and rendered form. +func knownForms(g dt.XRDiffGroup, res *un.Unstructured) []*un.Unstructured { + forms := []*un.Unstructured{res} + + if own := g.Diffs[dt.MakeDiffKeyFromResource(res)]; own != nil { + for _, obj := range []*un.Unstructured{own.Current.Raw, own.Desired.Raw} { + if obj != nil { + forms = append(forms, obj) + } + } + } + + return forms +} + +// renderOverlapErrors reports the resources produced by more than one input's render that no single +// diff can truthfully represent, most-significant cause first per resource and ordered by identity. +// +// Resources are matched on their version-independent identity (group, kind, namespace, name), not on the +// diff key: served versions are views of one stored object, so one object rendered at two API versions +// produces two keys yet is still contested. Renderings at different versions are never identical, so such +// an overlap lands on contention or disagreement. +// +// A diff key carries no owning-input component (see dt.MakeDiffKey), so any view that merges the groups +// into one map — the deprecated flat changes[] — keeps only one entry per key. Whether that loses +// anything is decided by who would control the resource, read from the rendered (or, for a removal, +// current) object's controller reference and compared by group, kind and name — never UID, which a +// render synthesizes afresh for an XR that does not exist yet: +// +// - Two inputs sharing a generateName, checked first: both render under one synthesized placeholder, +// so their resources collide here though the API server would name them apart, and merging even +// identical renderings would report one XR's changes for two. +// - Different controllers: contention. Crossplane's server-side apply refuses a second controller +// reference, so the first XR to create the object keeps it and every other fails to reconcile it. +// No diff predicts that, however alike the renderings. +// - One controller or none, identical renderings (diff type and Clean views, which cleanupForDiff has +// stripped of ownerReferences and uid): one change reached twice. Merged, not reported. +// - One controller or none, differing renderings: no single diff is right for both. Defensive — the +// ordinary causes are input errors, caught by the earlier stages. +// +// A key whose every entry is DiffTypeEqual is not reported: equal diffs appear in no rendered view. +func renderOverlapErrors(groups []dt.XRDiffGroup) []error { + byID := make(map[string][]overlapProducer) // identity -> its renderings, in input order + + for i, g := range groups { + keys := make([]string, 0, len(g.Diffs)) + for key := range g.Diffs { + keys = append(keys, key) + } + + sort.Strings(keys) // one input may render an identity under two keys; order them stably + + for _, key := range keys { + id := diffKeyIdentity(key) + if id == "" { + id = key // not a parseable key; fall back to comparing it exactly + } + + byID[id] = append(byID[id], overlapProducer{group: i, key: key}) + } + } + + ids := make([]string, 0, len(byID)) + for id, ps := range byID { + if len(ps) > 1 { + ids = append(ids, id) + } + } + + sort.Strings(ids) + + var errs []error + + for _, id := range ids { + if err := renderOverlapError(groups, byID[id]); err != nil { + errs = append(errs, err) + } + } + + return errs +} + +// overlapProducer is one rendering of an overlapping resource: the input group that produced it and the +// diff key it produced it under. One object reached at two API versions has two keys. +type overlapProducer struct { + group int + key string +} + +func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) error { + inputs := make([]string, 0, len(producers)) + + var ( + controllers []string + allEqual = true + identical = true + sharedPlaceholder = false + placeholders = make(map[string]bool) + seenController = make(map[string]bool) + seenGroup = make(map[int]bool) + keys []string + seenKey = make(map[string]bool) + first = groups[producers[0].group].Diffs[producers[0].key] + ) + + for _, p := range producers { + g, d := groups[p.group], groups[p.group].Diffs[p.key] + + if !seenKey[p.key] { + seenKey[p.key] = true + keys = append(keys, p.key) + } + + // The same input may reach the object again at another version. Its rendering still counts, but it + // is neither a second input nor a second placeholder. + if !seenGroup[p.group] { + seenGroup[p.group] = true + inputs = append(inputs, fmt.Sprintf("%s/%s", g.XR.Kind, g.XR.Name)) + + if g.NameGenerated { + sharedPlaceholder = sharedPlaceholder || placeholders[g.XR.Name] + placeholders[g.XR.Name] = true + } + } + + // A nil entry is not expected; treat it as observable and as matching nothing, so a malformed + // group errors loudly rather than being merged away. + if d == nil || d.DiffType != dt.DiffTypeEqual { + allEqual = false + } + + if !sameRendering(first, d) { + identical = false + } + + if id, display := controllerOf(d); id != "" && !seenController[id] { + seenController[id] = true + controllers = append(controllers, display) + } + } + + sort.Strings(controllers) + + key := fmt.Sprintf("%q", keys[0]) + if len(keys) > 1 { + others := make([]string, 0, len(keys)-1) + for _, k := range keys[1:] { + others = append(others, fmt.Sprintf("%q", k)) + } + + key += " (also rendered as " + strings.Join(others, ", ") + ")" + } + + switch { + case allEqual: + return nil + case sharedPlaceholder: + return errors.Errorf("cannot combine diffs: inputs %s both produce resource %s only because crossplane-diff "+ + "gives XRs that share a generateName the same placeholder name; the API server would name them "+ + "differently, so they cannot be told apart here — diff them separately", strings.Join(inputs, ", "), key) + case len(controllers) > 1: + return errors.Errorf("cannot combine diffs: resource %s would be controlled by more than one XR (%s); "+ + "Crossplane gives it to whichever of them creates it first, and the other fails to reconcile it, "+ + "so no diff predicts applying these inputs together — diff them separately", key, strings.Join(controllers, ", ")) + case identical: + return nil + default: + return errors.Errorf("cannot combine diffs: inputs %s both produce resource %s, but differently, so no "+ + "single diff is correct for both — diff them separately", strings.Join(inputs, ", "), key) + } +} + +// controllerOf returns the identity of the XR that would control the diffed resource, as a comparison +// key (group/kind/name) and a display form (Kind/Name), or empty strings if nothing controls it. It +// reads the rendered object, falling back to the current one for a removal. The UID is deliberately +// ignored; see renderOverlapErrors. +func controllerOf(d *dt.ResourceDiff) (string, string) { + if d == nil { + return "", "" + } + + obj := d.Desired.Raw + if obj == nil { + obj = d.Current.Raw + } + + if obj == nil { + return "", "" + } + + ref := metav1.GetControllerOf(obj) + if ref == nil { + return "", "" + } + + return identity(ref.APIVersion, ref.Kind, "", ref.Name), fmt.Sprintf("%s/%s", ref.Kind, ref.Name) +} + +// sameRendering reports whether two diffs describe the same change: the same diff type, and the same +// cleaned desired and current objects. +func sameRendering(a, b *dt.ResourceDiff) bool { + if a == nil || b == nil { + return false + } + + return a.DiffType == b.DiffType && + sameObject(a.Desired.Clean, b.Desired.Clean) && + sameObject(a.Current.Clean, b.Current.Clean) +} + +func sameObject(a, b *un.Unstructured) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + + return equality.Semantic.DeepEqual(a.Object, b.Object) +} + +// identity is an object's version-independent identity; "" for an object with no name yet. +func identity(apiVersion, kind, namespace, name string) string { + if name == "" || kind == "" { + return "" + } + + group := "" + if gv, err := schema.ParseGroupVersion(apiVersion); err == nil { + group = gv.Group + } + + return group + "/" + kind + "/" + namespace + "/" + name +} + +func identityOf(res *un.Unstructured) string { + return identity(res.GetAPIVersion(), res.GetKind(), res.GetNamespace(), res.GetName()) +} + +// diffKeyIdentity is identity for a key built by dt.MakeDiffKey ("///", +// where apiVersion may itself contain a slash). +func diffKeyIdentity(key string) string { + parts := strings.Split(key, "/") + n := len(parts) + + if n < 4 { + return "" + } + + return identity(strings.Join(parts[:n-3], "/"), parts[n-3], parts[n-2], parts[n-1]) +} + +// refIdentity is the identity of the object reference (apiVersion, kind, name, and optionally namespace) +// at path, or "" if there is none. +func refIdentity(obj *un.Unstructured, path ...string) string { + ref, found, err := un.NestedStringMap(obj.Object, path...) + if err != nil || !found { + return "" + } + + return identity(ref["apiVersion"], ref["kind"], ref["namespace"], ref["name"]) +} + +func kindName(res *un.Unstructured) string { + return fmt.Sprintf("%s/%s", res.GetKind(), res.GetName()) +} diff --git a/internal/diff/diffprocessor/input_validator_test.go b/internal/diff/diffprocessor/input_validator_test.go new file mode 100644 index 00000000..4234ce05 --- /dev/null +++ b/internal/diff/diffprocessor/input_validator_test.go @@ -0,0 +1,600 @@ +package diffprocessor + +import ( + "testing" + + gcmp "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +// errMessage is err's message, or "" for none, so verdicts compare as plain values. +func errMessage(err error) string { + if err == nil { + return "" + } + + return err.Error() +} + +func TestCheckInputs(t *testing.T) { + xr := func(name string) *tu.ResourceBuilder { return tu.NewResource("example.org/v1", "XR", name) } + + type verdict struct { + Duplicate bool + Err string + } + + conflict := func(position, other int, name string) string { + return errMessage(conflictingInputError(position, other, xr(name).Build())) + } + + tests := map[string]struct { + reason string + inputs []*un.Unstructured + want []verdict + wantWarnings []string + }{ + "DistinctInputsPass": { + reason: "Inputs naming different objects are each rendered.", + inputs: []*un.Unstructured{xr("a").Build(), xr("b").Build()}, + want: []verdict{{}, {}}, + }, + "IdenticalDuplicateIsDropped": { + reason: "An input identical to an earlier one has one clear intent, so it is dropped with a warning.", + inputs: []*un.Unstructured{xr("a").WithSpecField("f", "v").Build(), xr("a").WithSpecField("f", "v").Build()}, + want: []verdict{{}, {Duplicate: true}}, + wantWarnings: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, + }, + "SameObjectWithDifferentContentFailsBoth": { + reason: "Neither of two differing definitions can be preferred, so each fails, naming the other.", + inputs: []*un.Unstructured{xr("a").WithSpecField("f", "1").Build(), xr("a").WithSpecField("f", "2").Build()}, + want: []verdict{{Err: conflict(1, 2, "a")}, {Err: conflict(2, 1, "a")}}, + }, + "SameObjectAtAnotherAPIVersionIsStillTheSameObject": { + reason: "Identity is version-independent, so one object at two versions is a conflict, not two objects.", + inputs: []*un.Unstructured{xr("a").Build(), tu.NewResource("example.org/v2", "XR", "a").Build()}, + want: []verdict{{Err: conflict(1, 2, "a")}, {Err: conflict(2, 1, "a")}}, + }, + "SameNameInAnotherNamespaceIsAnotherObject": { + reason: "Namespace is part of identity.", + inputs: []*un.Unstructured{xr("a").InNamespace("one").Build(), xr("a").InNamespace("two").Build()}, + want: []verdict{{}, {}}, + }, + "GenerateNameOnlyInputsAreNotJudged": { + reason: "An input with no name yet has no identity: two identical ones are two XRs, not a duplicate.", + inputs: []*un.Unstructured{xr("").WithGenerateName("g-").Build(), xr("").WithGenerateName("g-").Build()}, + want: []verdict{{}, {}}, + }, + "LaterInputIsJudgedAgainstTheFirst": { + reason: "A third input identical to the first is a duplicate of it, even after a conflicting second.", + inputs: []*un.Unstructured{ + xr("a").WithSpecField("f", "1").Build(), + xr("a").WithSpecField("f", "2").Build(), + xr("a").WithSpecField("f", "1").Build(), + }, + want: []verdict{{Err: conflict(1, 2, "a")}, {Err: conflict(2, 1, "a")}, {Duplicate: true}}, + wantWarnings: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + advisories := NewAdvisoryCollector(nil) + + checks := checkInputs(advisories, tt.inputs) + + got := make([]verdict, 0, len(checks)) + for _, c := range checks { + got = append(got, verdict{Duplicate: c.duplicate, Err: errMessage(c.err)}) + } + + if diff := gcmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\ncheckInputs(...): -want, +got:\n%s", tt.reason, diff) + } + + warnings := advisories.Advisories() + + gotWarnings := make([]string, 0, len(warnings)) + for _, w := range warnings { + gotWarnings = append(gotWarnings, w.Message) + } + + if diff := gcmp.Diff(tt.wantWarnings, gotWarnings, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\ncheckInputs(...) warnings: -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestRejectManagedInputs(t *testing.T) { + xr := func(name string) *tu.ResourceBuilder { return tu.NewResource("example.org/v1", "XR", name) } + claim := func(name string) *tu.ResourceBuilder { + return tu.NewResource("example.org/v1", "Claim", name).InNamespace("default") + } + resourceRef := map[string]any{"apiVersion": "example.org/v1", "kind": "XR", "name": "backing"} + claimRef := map[string]any{"apiVersion": "example.org/v1", "kind": "Claim", "name": "my-claim", "namespace": "default"} + claimLabels := func(namespace string) map[string]string { + return map[string]string{LabelClaimName: "my-claim", LabelClaimNamespace: namespace} + } + + const ( + composedErr = "XR/child is composed by input XR/parent, whose composition writes it; supplying it as an " + + "input too gives it a second writer — pass XR/parent only" + claimErr = "XR/backing is the XR bound to claim Claim/my-claim, which Crossplane's claim controller writes; " + + "supplying it as an input too gives it a second writer — pass the claim, not its XR" + ) + + // input is one group as the processor would hand it over. + type input struct { + res *un.Unstructured + // cluster is the cluster copy carried by the input's own diff, if any. + cluster *un.Unstructured + // rendered is every resource key the input's render produced. + rendered []string + // failedRender marks the input's own render as failed; rejected marks it failed before rendering. + failedRender, rejected bool + } + + tests := map[string]struct { + reason string + inputs []input + want map[int]string + }{ + "UnrelatedInputsPass": { + reason: "Inputs that neither compose nor bind one another are all valid.", + inputs: []input{{res: xr("a").Build()}, {res: xr("b").Build()}}, + }, + "ChildComposedByAParentInputIsRejected": { + reason: "A child in the parent's rendered keys has a second writer if it is also an input.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}}, + {res: xr("child").Build()}, + }, + want: map[int]string{1: composedErr}, + }, + "ChildRenderedAtAnotherAPIVersionIsStillRejected": { + reason: "The parent may render the child at another version; it is the same object.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v2", "XR", "", "child")}}, + {res: xr("child").Build()}, + }, + want: map[int]string{1: composedErr}, + }, + "ManagedInputIsJudgedDespiteItsOwnRenderFailure": { + reason: "The verdict supersedes the managed input's own render failure, which is moot.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}}, + {res: xr("child").Build(), failedRender: true}, + }, + want: map[int]string{1: composedErr}, + }, + "AFailedManagerManagesNothing": { + reason: "What a manager manages is read from its render, so one that failed to render cannot reject.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}, failedRender: true}, + {res: xr("child").Build()}, + }, + }, + "InputRejectedBeforeRenderingIsNotJudged": { + reason: "An input already rejected by checkInputs keeps that error.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}}, + {res: xr("child").Build(), rejected: true}, + }, + }, + "GenerateNameOnlyInputIsNotJudged": { + reason: "An input with no name yet has no identity to be managed by.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "")}}, + {res: xr("").WithGenerateName("child-").Build()}, + }, + }, + "AnInputWithNoNameIsNobodysClaim": { + reason: "A generateName-only input must not match an XR's absent claimRef, which is also empty.", + inputs: []input{{res: xr("named").Build()}, {res: xr("").WithGenerateName("gen-").Build()}}, + }, + "BackingXRNamedByTheClaimsClusterResourceRef": { + reason: "A bound claim's cluster copy names its XR in spec.resourceRef.", + inputs: []input{ + {res: claim("my-claim").Build(), cluster: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, + {res: xr("backing").Build()}, + }, + want: map[int]string{1: claimErr}, + }, + "BackingXRNamedByTheClaimsInputResourceRef": { + reason: "A claim exported from the cluster carries spec.resourceRef in the input itself.", + inputs: []input{ + {res: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, + {res: xr("backing").Build()}, + }, + want: map[int]string{1: claimErr}, + }, + "BackingXRWhoseClaimRefNamesTheClaim": { + reason: "The XR's side of the binding is enough on its own.", + inputs: []input{ + {res: claim("my-claim").Build()}, + {res: xr("backing").WithSpecField("claimRef", claimRef).Build()}, + }, + want: map[int]string{1: claimErr}, + }, + "BackingXRWhoseClaimLabelsNameTheClaim": { + reason: "Crossplane stamps the claim's name and namespace on the XR as labels.", + inputs: []input{ + {res: claim("my-claim").Build()}, + {res: xr("backing").Build(), cluster: xr("backing").WithLabels(claimLabels("default")).Build()}, + }, + want: map[int]string{1: claimErr}, + }, + "ClaimLabelsForAnotherNamespaceDoNotMatch": { + reason: "A claim of the same name in another namespace is a different claim.", + inputs: []input{ + {res: claim("my-claim").Build()}, + {res: xr("backing").WithLabels(claimLabels("elsewhere")).Build()}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var ( + groups = make([]dt.XRDiffGroup, len(tt.inputs)) + checks = make([]inputCheck, len(tt.inputs)) + rendered = make([]map[string]bool, len(tt.inputs)) + failed = make([]error, len(tt.inputs)) + ) + + for i, in := range tt.inputs { + checks[i] = inputCheck{res: in.res} + if in.rejected { + checks[i].err = errors.New("rejected before rendering") + } + + if in.failedRender { + failed[i] = errors.New("render failed") + } + + if in.cluster != nil { + groups[i].Diffs = map[string]*dt.ResourceDiff{ + dt.MakeDiffKeyFromResource(in.res): {Current: dt.ResourceViews{Raw: in.cluster}}, + } + } + + rendered[i] = map[string]bool{} + for _, key := range in.rendered { + rendered[i][key] = true + } + } + + got := map[int]string{} + for i, err := range rejectManagedInputs(groups, checks, rendered, failed) { + got[i] = errMessage(err) + } + + if diff := gcmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nrejectManagedInputs(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +// TestBundleInputValidatorVerdicts drives the default validator through its stages as PerformDiff does, +// pinning what it owns beyond its rules: dropping duplicates from ToRender, and the precedence of a +// managed-input verdict over that input's own render failure. +func TestBundleInputValidatorVerdicts(t *testing.T) { + xr := func(name string) *un.Unstructured { return tu.NewResource("example.org/v1", "XR", name).Build() } + childKey := dt.MakeDiffKey("example.org/v1", "XR", "", "child") + + const composedErr = "XR/child is composed by input XR/parent, whose composition writes it; supplying it as an " + + "input too gives it a second writer — pass XR/parent only" + + conflict34 := conflictingInputError(3, 4, xr("b")).Error() + conflict43 := conflictingInputError(4, 3, xr("b")).Error() + + // render is one input's recorded render: its rendered keys and its own failure. + type render struct { + keys []string + err string + } + + type want struct { + ToRender []string // name, or "name: err" for an input rejected before rendering + Verdicts []string + } + + tests := map[string]struct { + reason string + inputs []*un.Unstructured + renders map[int]render // by ToRender index; an input rejected before rendering is not rendered + want want + }{ + "ManagedVerdictReplacesTheManagedInputsRenderFailure": { + reason: "A managed input whose own render failed reports why it should not have been passed, not the render failure.", + inputs: []*un.Unstructured{xr("parent"), xr("child")}, + renders: map[int]render{ + 0: {keys: []string{childKey}}, + 1: {err: "render failed"}, + }, + want: want{ToRender: []string{"parent", "child"}, Verdicts: []string{"", composedErr}}, + }, + "AnUnmanagedRenderFailureIsKept": { + reason: "Without a managed-input verdict, a group's final error is its own render failure.", + inputs: []*un.Unstructured{xr("a"), xr("b")}, + renders: map[int]render{0: {}, 1: {err: "render failed"}}, + want: want{ToRender: []string{"a", "b"}, Verdicts: []string{"", "render failed"}}, + }, + "DuplicateIsDroppedAndAConflictIsRejectedBeforeRendering": { + reason: "An identical duplicate is not handed back to render; the same object twice differently is, " + + "rejected, and that rejection is its verdict.", + inputs: []*un.Unstructured{ + xr("a"), xr("a"), + tu.NewResource("example.org/v1", "XR", "b").WithSpecField("v", "1").Build(), + tu.NewResource("example.org/v1", "XR", "b").WithSpecField("v", "2").Build(), + }, + renders: map[int]render{0: {}}, + want: want{ + ToRender: []string{"a", "b: " + conflict34, "b: " + conflict43}, + Verdicts: []string{"", conflict34, conflict43}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + v := NewBundleInputValidator(NewAdvisoryCollector(nil), tt.inputs) + + got := want{} + + toRender := v.ToRender() + for i, in := range toRender { + entry := in.Resource.GetName() + if in.Err != nil { + entry += ": " + in.Err.Error() + } + + got.ToRender = append(got.ToRender, entry) + + r, ok := tt.renders[i] + if !ok { + continue + } + + keys := map[string]bool{} + for _, k := range r.keys { + keys[k] = true + } + + var err error + if r.err != "" { + err = errors.New(r.err) + } + + v.RecordRender(i, keys, err) + } + + for _, err := range v.Verdicts(make([]dt.XRDiffGroup, len(toRender))) { + got.Verdicts = append(got.Verdicts, errMessage(err)) + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\nbundleInputValidator: -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestRenderOverlapErrors(t *testing.T) { + const key = "example.org/v1/Bucket/default/shared" + + // bucket is the shared resource as one render produces it: diffed as diffType, with spec.value set + // to value, controlled by the XR named controller ("" for none) under uid. Clean mirrors + // cleanupForDiff, which strips ownerReferences before anything is compared. + bucket := func(diffType dt.DiffType, controller, uid, value string) *dt.ResourceDiff { + b := tu.NewResource("example.org/v1", "Bucket", "shared").InNamespace("default").WithSpecField("value", value) + if controller != "" { + b = b.WithControllerReference("XR", controller, "example.org/v1", uid) + } + + raw := b.Build() + clean := raw.DeepCopy() + clean.SetOwnerReferences(nil) + + return &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + Namespace: "default", + ResourceName: "shared", + DiffType: diffType, + Desired: dt.ResourceViews{Raw: raw, Clean: clean}, + } + } + + // atV1beta1 re-expresses a bucket diff at example.org/v1beta1, the same object at another served version. + const betaKey = "example.org/v1beta1/Bucket/default/shared" + + atV1beta1 := func(d *dt.ResourceDiff) *dt.ResourceDiff { + d.Gvk.Version = "v1beta1" + d.Desired.Raw.SetAPIVersion("example.org/v1beta1") + d.Desired.Clean.SetAPIVersion("example.org/v1beta1") + + return d + } + + group := func(name string, diffs map[string]*dt.ResourceDiff) dt.XRDiffGroup { + return dt.XRDiffGroup{XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: name}, Diffs: diffs} + } + + sharing := func(names ...string) func(func(i int) *dt.ResourceDiff) []dt.XRDiffGroup { + return func(diff func(i int) *dt.ResourceDiff) []dt.XRDiffGroup { + groups := make([]dt.XRDiffGroup, len(names)) + for i, n := range names { + groups[i] = group(n, map[string]*dt.ResourceDiff{key: diff(i)}) + } + + return groups + } + } + + generated := func(groups []dt.XRDiffGroup) []dt.XRDiffGroup { + for i := range groups { + groups[i].NameGenerated = true + } + + return groups + } + + two := sharing("a", "b") + + const ( + contention = `cannot combine diffs: resource "example.org/v1/Bucket/default/shared" would be controlled by more ` + + `than one XR (XR/a, XR/b); Crossplane gives it to whichever of them creates it first, and the other fails to ` + + `reconcile it, so no diff predicts applying these inputs together — diff them separately` + disagreement = `cannot combine diffs: inputs XR/a, XR/b both produce resource "example.org/v1/Bucket/default/shared", ` + + `but differently, so no single diff is correct for both — diff them separately` + placeholder = `cannot combine diffs: inputs XR/g-(generated), XR/g-(generated) both produce resource ` + + `"example.org/v1/Bucket/default/shared" only because crossplane-diff gives XRs that share a generateName the ` + + `same placeholder name; the API server would name them differently, so they cannot be told apart here — diff ` + + `them separately` + ) + + tests := map[string]struct { + reason string + groups []dt.XRDiffGroup + want []string + }{ + "KeysProducedOnceAreFine": { + reason: "Only a key produced by more than one render can overlap.", + groups: []dt.XRDiffGroup{ + group("a", map[string]*dt.ResourceDiff{key: bucket(dt.DiffTypeModified, "a", "1", "v")}), + group("b", map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/other": bucket(dt.DiffTypeModified, "b", "2", "v")}), + }, + }, + "DifferentControllersContendEvenWhenIdentical": { + reason: "Crossplane gives the object to whichever XR creates it first; the renderings cannot change that.", + groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeModified, []string{"a", "b"}[i], "u", "v") }), + want: []string{contention}, + }, + "AllEqualIsTolerated": { + reason: "Equal diffs appear in no rendered view, so merging them loses nothing.", + groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeEqual, []string{"a", "b"}[i], "u", "v") }), + }, + "SameControllerIdenticalIsMerged": { + reason: "One change reached twice through one controller; the controller is compared by name, not UID.", + groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeModified, "parent", []string{"1", "2"}[i], "v") }), + }, + "SameControllerDifferentRenderingsDisagree": { + reason: "No single diff is right for both.", + groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeModified, "parent", "u", []string{"1", "2"}[i]) }), + want: []string{disagreement}, + }, + "UncontrolledDifferentRenderingsDisagree": { + reason: "The same holds for an object no XR controls.", + groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeModified, "", "", []string{"1", "2"}[i]) }), + want: []string{disagreement}, + }, + "RemovalIsJudgedByItsCurrentController": { + reason: "A removal has no rendered form, so its controller is read from the current object.", + groups: two(func(i int) *dt.ResourceDiff { + d := bucket(dt.DiffTypeRemoved, []string{"a", "b"}[i], "u", "v") + d.Current, d.Desired = d.Desired, dt.ResourceViews{} + + return d + }), + want: []string{contention}, + }, + "SharedGenerateNameCannotBeToldApart": { + reason: "Both render under one placeholder, so even identical renderings must not merge.", + groups: generated(sharing("g-(generated)", "g-(generated)")(func(int) *dt.ResourceDiff { + return bucket(dt.DiffTypeModified, "parent", "u", "v") + })), + want: []string{placeholder}, + }, + "DifferentGenerateNamesAreJudgedOnTheirMerits": { + reason: "Different generateNames get different placeholders, so the overlap is an ordinary one.", + groups: generated(sharing("g-(generated)", "h-(generated)")(func(int) *dt.ResourceDiff { + return bucket(dt.DiffTypeModified, "parent", "u", "v") + })), + }, + "OneObjectAtTwoVersionsContends": { + reason: "Served versions are views of one stored object, so different diff keys do not make different objects.", + groups: []dt.XRDiffGroup{ + group("a", map[string]*dt.ResourceDiff{key: bucket(dt.DiffTypeModified, "a", "u", "v")}), + group("b", map[string]*dt.ResourceDiff{betaKey: atV1beta1(bucket(dt.DiffTypeModified, "b", "u", "v"))}), + }, + want: []string{`cannot combine diffs: resource "example.org/v1/Bucket/default/shared" (also rendered as ` + + `"example.org/v1beta1/Bucket/default/shared") would be controlled by more than one XR (XR/a, XR/b); ` + + `Crossplane gives it to whichever of them creates it first, and the other fails to reconcile it, so no ` + + `diff predicts applying these inputs together — diff them separately`}, + }, + "OneObjectAtTwoVersionsDisagrees": { + reason: "Renderings at different versions differ in shape, so even one controller cannot merge them.", + groups: []dt.XRDiffGroup{ + group("a", map[string]*dt.ResourceDiff{key: bucket(dt.DiffTypeModified, "parent", "u", "v")}), + group("b", map[string]*dt.ResourceDiff{betaKey: atV1beta1(bucket(dt.DiffTypeModified, "parent", "u", "v"))}), + }, + want: []string{`cannot combine diffs: inputs XR/a, XR/b both produce resource ` + + `"example.org/v1/Bucket/default/shared" (also rendered as "example.org/v1beta1/Bucket/default/shared"), ` + + `but differently, so no single diff is correct for both — diff them separately`}, + }, + "OverlapsAreReportedInKeyOrder": { + reason: "Map iteration is unordered; the reported errors must not be.", + groups: []dt.XRDiffGroup{ + group("a", map[string]*dt.ResourceDiff{ + "example.org/v1/Bucket/default/z": bucket(dt.DiffTypeModified, "", "", "1"), + key: bucket(dt.DiffTypeModified, "", "", "1"), + }), + group("b", map[string]*dt.ResourceDiff{ + "example.org/v1/Bucket/default/z": bucket(dt.DiffTypeModified, "", "", "2"), + key: bucket(dt.DiffTypeModified, "", "", "2"), + }), + }, + want: []string{ + disagreement, + `cannot combine diffs: inputs XR/a, XR/b both produce resource "example.org/v1/Bucket/default/z", ` + + `but differently, so no single diff is correct for both — diff them separately`, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + errs := renderOverlapErrors(tt.groups) + + got := make([]string, 0, len(errs)) + for _, err := range errs { + got = append(got, errMessage(err)) + } + + if diff := gcmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nrenderOverlapErrors(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDiffKeyIdentity(t *testing.T) { + tests := map[string]struct { + key string + want string + }{ + "GroupedAPIVersion": {key: "example.org/v1/Bucket/default/b", want: "example.org/Bucket/default/b"}, + "CoreAPIVersion": {key: "v1/ConfigMap/default/cm", want: "/ConfigMap/default/cm"}, + "ClusterScoped": {key: "example.org/v1/Bucket//b", want: "example.org/Bucket//b"}, + "VersionDoesNotAffectIt": {key: "example.org/v2/Bucket/default/b", want: "example.org/Bucket/default/b"}, + "TooFewSegmentsHasNoIdentity": {key: "Bucket/b", want: ""}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := diffKeyIdentity(tt.key); got != tt.want { + t.Errorf("diffKeyIdentity(%q) = %q, want %q", tt.key, got, tt.want) + } + }) + } +} diff --git a/internal/diff/diffprocessor/preserve.go b/internal/diff/diffprocessor/preserve.go new file mode 100644 index 00000000..c1bd950a --- /dev/null +++ b/internal/diff/diffprocessor/preserve.go @@ -0,0 +1,42 @@ +package diffprocessor + +import ( + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +// Crossplane label keys used for resource identity and ownership. +const ( + // LabelComposite identifies the root composite resource that owns a composed resource. + LabelComposite = "crossplane.io/composite" + // LabelClaimName identifies the claim that triggered creation of the composite. + LabelClaimName = "crossplane.io/claim-name" + // LabelClaimNamespace identifies the namespace of the claim. + LabelClaimNamespace = "crossplane.io/claim-namespace" +) + +// CopyLabels copies specified labels from source to target if they exist in source. +// This is a no-op if source has no labels or if none of the specified keys exist. +func CopyLabels(source, target *un.Unstructured, keys ...string) { + sourceLabels := source.GetLabels() + if sourceLabels == nil { + return + } + + targetLabels := target.GetLabels() + if targetLabels == nil { + targetLabels = make(map[string]string) + } + + copied := false + + for _, key := range keys { + if value, exists := sourceLabels[key]; exists { + targetLabels[key] = value + copied = true + } + } + + if copied { + target.SetLabels(targetLabels) + } +} diff --git a/internal/diff/diffprocessor/preserve_test.go b/internal/diff/diffprocessor/preserve_test.go new file mode 100644 index 00000000..a456cdca --- /dev/null +++ b/internal/diff/diffprocessor/preserve_test.go @@ -0,0 +1,167 @@ +package diffprocessor + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func TestCopyLabels(t *testing.T) { + tests := []struct { + name string + source *un.Unstructured + target *un.Unstructured + keys []string + expectedLabels map[string]string + }{ + { + name: "CopiesSingleLabel", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "root-xr", + }). + Build(), + target: tu.NewResource("v1", "Resource", "target").Build(), + keys: []string{LabelComposite}, + expectedLabels: map[string]string{ + LabelComposite: "root-xr", + }, + }, + { + name: "CopiesMultipleLabels", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "root-xr", + LabelClaimName: "my-claim", + LabelClaimNamespace: "default", + }). + Build(), + target: tu.NewResource("v1", "Resource", "target").Build(), + keys: []string{LabelComposite, LabelClaimName, LabelClaimNamespace}, + expectedLabels: map[string]string{ + LabelComposite: "root-xr", + LabelClaimName: "my-claim", + LabelClaimNamespace: "default", + }, + }, + { + name: "PreservesExistingTargetLabels", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "root-xr", + }). + Build(), + target: tu.NewResource("v1", "Resource", "target"). + WithLabels(map[string]string{ + "existing-label": "existing-value", + }). + Build(), + keys: []string{LabelComposite}, + expectedLabels: map[string]string{ + LabelComposite: "root-xr", + "existing-label": "existing-value", + }, + }, + { + name: "OverwritesTargetLabelWithSourceValue", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "correct-root-xr", + }). + Build(), + target: tu.NewResource("v1", "Resource", "target"). + WithLabels(map[string]string{ + LabelComposite: "wrong-root-xr", + }). + Build(), + keys: []string{LabelComposite}, + expectedLabels: map[string]string{ + LabelComposite: "correct-root-xr", + }, + }, + { + name: "NoOpWhenSourceHasNoLabels", + source: tu.NewResource("v1", "Resource", "source").Build(), + target: tu.NewResource("v1", "Resource", "target"). + WithLabels(map[string]string{ + "existing-label": "existing-value", + }). + Build(), + keys: []string{LabelComposite}, + expectedLabels: map[string]string{ + "existing-label": "existing-value", + }, + }, + { + name: "NoOpWhenSourceLabelsNil", + // Use raw construction to test truly nil metadata + source: &un.Unstructured{ + Object: map[string]any{}, + }, + target: tu.NewResource("v1", "Resource", "target"). + WithLabels(map[string]string{ + "existing-label": "existing-value", + }). + Build(), + keys: []string{LabelComposite}, + expectedLabels: map[string]string{ + "existing-label": "existing-value", + }, + }, + { + name: "SkipsKeysNotInSource", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "root-xr", + }). + Build(), + target: tu.NewResource("v1", "Resource", "target").Build(), + keys: []string{LabelComposite, LabelClaimName, LabelClaimNamespace}, + expectedLabels: map[string]string{ + LabelComposite: "root-xr", + }, + }, + { + name: "CreatesLabelsMapWhenTargetHasNone", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "root-xr", + }). + Build(), + // Use raw construction to test target with no labels map + target: &un.Unstructured{ + Object: map[string]any{}, + }, + keys: []string{LabelComposite}, + expectedLabels: map[string]string{ + LabelComposite: "root-xr", + }, + }, + { + name: "NoOpWhenNoKeysSpecified", + source: tu.NewResource("v1", "Resource", "source"). + WithLabels(map[string]string{ + LabelComposite: "root-xr", + }). + Build(), + target: tu.NewResource("v1", "Resource", "target").Build(), + keys: []string{}, + expectedLabels: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + CopyLabels(tt.source, tt.target, tt.keys...) + + actualLabels := tt.target.GetLabels() + + if diff := cmp.Diff(tt.expectedLabels, actualLabels); diff != "" { + t.Errorf("Labels mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go new file mode 100644 index 00000000..e0a10a61 --- /dev/null +++ b/internal/diff/diffprocessor/processor_config.go @@ -0,0 +1,567 @@ +package diffprocessor + +import ( + "io" + + corev1 "k8s.io/api/core/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +const ( + // DefaultMaxNestedDepth is the default maximum depth for recursive nested XR processing. The + // processor constructors apply it, and the CLI's --max-nested-depth default must match it. + DefaultMaxNestedDepth = 10 + + // DefaultMaxRenderIterations is the default maximum render iterations. The processor constructors + // apply it, and the CLI's --max-iterations default must match it. + DefaultMaxRenderIterations = 20 +) + +// AnalyzeOn names the smallest composition change that triggers per-composite impact analysis. +// The values are ordered from least to most eager. +type AnalyzeOn string + +const ( + // AnalyzeOnSpecChange evaluates composites only when the composition's spec changes. The + // cheapest setting, and a deliberate user judgement: a metadata-only change still produces a new + // CompositionRevision, whose identity a composition template can observe through the XR's + // compositionRevisionRef, so choosing this asserts that none of your compositions do that. + AnalyzeOnSpecChange AnalyzeOn = "spec-change" + + // AnalyzeOnAnyChange evaluates composites whenever anything Crossplane hashes into the + // composition's identity differs — labels and annotations as well as spec. The default, because + // whether a metadata-only change reaches rendered output can only be settled by rendering. + AnalyzeOnAnyChange AnalyzeOn = "any-change" + + // AnalyzeOnAlways evaluates composites even when the composition is identical, which creates no + // revision and so cannot change anything. Useful as a pre-edit "is my cluster converged?" + // baseline; note that any delta it reports is by construction not caused by this composition. + AnalyzeOnAlways AnalyzeOn = "always" +) + +// DryRunOn selects which resources are round-tripped through the apiserver to compute their +// post-apply form. Like AnalyzeOn it is a depth/cost knob: every resource dry-run costs one +// apiserver round-trip and requires a permission, and a resource left un-dry-run is marked with a +// DryRunInfo so "we did not check" stays distinguishable from "we checked and it matches". +type DryRunOn string + +const ( + // DryRunOnExisting dry-runs only resources that already exist in the cluster, leaving additions + // as the render pipeline produced them plus locally predicted CRD defaults (see Defaulter). This + // was the only behaviour before crossplane-diff#334. It needs no create permission, and costs one + // round-trip fewer per added resource. + DryRunOnExisting DryRunOn = "existing" + + // DryRunOnAll additionally dry-run creates added resources, so their diffs pick up + // apiserver-side defaulting and mutating admission — neither of which the local Defaulter can + // predict for a built-in type, and neither of which it can predict at all for admission. The + // default. Where the create is not permitted, the resource degrades to rendered output plus + // locally predicted CRD defaults, and says so rather than failing the run. + DryRunOnAll DryRunOn = "all" +) + +// ChangeScope is how much of a composition differs from its in-cluster version, in terms of what +// Crossplane hashes into the composition's identity (see Composition.Hash()). +type ChangeScope string + +const ( + // ChangeScopeNone means nothing Crossplane hashes differs. No CompositionRevision is created, so + // nothing can adopt anything. + ChangeScopeNone ChangeScope = "none" + + // ChangeScopeMetadata means the labels or annotations differ but the spec does not. A new + // CompositionRevision is still created, carrying an identical spec. + ChangeScopeMetadata ChangeScope = "metadata" + + // ChangeScopeSpec means the spec differs. + ChangeScopeSpec ChangeScope = "spec" +) + +// triggersAnalysis reports whether a change of this scope should cause per-composite impact +// analysis at the given setting. +func (s ChangeScope) triggersAnalysis(on AnalyzeOn) bool { + switch on { + case AnalyzeOnAlways: + return true + case AnalyzeOnSpecChange: + return s == ChangeScopeSpec + case AnalyzeOnAnyChange, "": + return s != ChangeScopeNone + default: + // Unrecognized values are rejected by the CLI; fall back to the default rather than + // silently analysing nothing. + return s != ChangeScopeNone + } +} + +// ProcessorConfig contains configuration for the DiffProcessor. +type ProcessorConfig struct { + // Colorize determines whether to use colors in the diff output + Colorize bool + + // Compact determines whether to show a compact diff format + Compact bool + + // OutputFormat specifies the output format for diffs (diff, json, yaml) + OutputFormat renderer.OutputFormat + + // OutputVersion specifies the schema version of structured (json, yaml) output + OutputVersion renderer.OutputVersion + + // MaxNestedDepth is the maximum depth for recursive nested XR processing + MaxNestedDepth int + + // IncludeManual determines whether to include XRs with Manual update policy in composition diffs + IncludeManual bool + + // MinimizeComposition collapses composition changes to a single marker line per + // composition, omitting the full YAML diff body. Human renderer only; structured + // output always includes full compositionChanges. + MinimizeComposition bool + + // Advisories is the channel user-facing advisories are raised on, and drained from for structured + // output. Pass the same collector to every processor of one run (the CLI passes one set of options to + // both), so the composition processor's output includes what its XR processor raised. Unset, each + // processor collects its own, writing to Stderr. + Advisories *AdvisoryCollector + + // AnalyzeOn is the smallest composition change that triggers per-composite impact analysis. + // Evaluating a composite costs one function render, so this is a cost knob — it never suppresses + // a reported consequence. RevisionImpact is populated at every setting, and composites left + // unevaluated are marked ImpactAnalysisSkipped so "we did not look" stays distinguishable from + // "we looked and found nothing". + // + // Zero value means AnalyzeOnAnyChange, matching the CLI default. + AnalyzeOn AnalyzeOn + + // DryRunOn selects which resources are verified against the apiserver. Zero value means + // DryRunOnAll, matching the CLI default. + DryRunOn DryRunOn + + // EventualState enables iterative simulation to show eventual state after all reconciliation + // cycles complete. Useful with function-sequencer which hides later stage resources. + EventualState bool + + // MaxRenderIterations is the maximum number of render iterations when resolving requirements + // or simulating eventual state. Higher values may be needed for complex pipelines. + MaxRenderIterations int + + // IgnorePaths is a list of paths to ignore when calculating diffs + IgnorePaths []string + + // FunctionCredentials holds Secret credentials to pass to Functions during rendering + FunctionCredentials []corev1.Secret + + // FunctionRegistryOverride overrides the registry in all function package refs. + FunctionRegistryOverride string + + // MaxRecvMessageSize is the max gRPC message size (MB) for render function + // containers. Zero leaves the function's own default (function-sdk-go uses + // 4MB). When >0 it is injected as the appropriate container env var, + // so large XRs don't trip the default limit under render. + MaxRecvMessageSize int + + // Stdout is the writer for diff output (defaults to os.Stdout) + Stdout io.Writer + + // Stderr is the writer for error output (defaults to os.Stderr) + Stderr io.Writer + + // Logger is the logger to use + Logger logging.Logger + + // RenderFunc is the function to use for rendering resources. If left nil, + // processors construct a default engine-backed RenderFn on initialization. + RenderFunc RenderFn + + // CrossplaneRenderBinary, when non-empty, causes the default + // engine-backed RenderFn to invoke a local `crossplane` binary at the + // supplied path instead of the upstream docker engine. This is a test + // affordance that gives integration tests a fast in-process render path; + // production users should leave it empty so the docker engine pulls + // xpkg.crossplane.io/crossplane/crossplane:stable. Ignored when + // RenderFunc is set explicitly. + CrossplaneRenderBinary string + + // CrossplaneVersion, when non-empty, causes the default engine-backed + // RenderFn's docker engine to pull + // xpkg.crossplane.io/crossplane/crossplane: instead of :stable. + // Mutually exclusive with CrossplaneImage and CrossplaneRenderBinary. + // Ignored when RenderFunc is set explicitly. + CrossplaneVersion string + + // CrossplaneImage, when non-empty, causes the default engine-backed + // RenderFn's docker engine to pull this full image reference instead of + // the default xpkg.crossplane.io/crossplane/crossplane:stable. Mutually + // exclusive with CrossplaneVersion and CrossplaneRenderBinary. Ignored + // when RenderFunc is set explicitly. + CrossplaneImage string + + // Factories provide factory functions for creating components + Factories ComponentFactories +} + +// ComponentFactories contains factory functions for creating processor components. +type ComponentFactories struct { + // ResourceManager creates a ResourceManager + ResourceManager func(client k8.ResourceClient, defClient xp.DefinitionClient, treeClient xp.ResourceTreeClient, logger logging.Logger, advisories dt.Advisories) ResourceManager + + // SchemaValidator creates a SchemaValidator + SchemaValidator func(schema k8.SchemaClient, resource k8.ResourceClient, def xp.DefinitionClient, logger logging.Logger) SchemaValidator + + // Defaulter creates a Defaulter with the given policy. The processor makes two: a strict one for the + // XR it renders, and a lenient one the DiffCalculator uses to predict additions. + Defaulter func(schema k8.SchemaClient, def xp.DefinitionClient, policy DefaultingPolicy) Defaulter + + // DiffCalculator creates a DiffCalculator + DiffCalculator func(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, diffOptions renderer.DiffOptions, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator + + // DiffRenderer creates a DiffRenderer + DiffRenderer func(logger logging.Logger, diffOptions renderer.DiffOptions) renderer.DiffRenderer + + // CompDiffRenderer creates a CompDiffRenderer for composition diffs + CompDiffRenderer func(logger logging.Logger, diffRenderer renderer.DiffRenderer, opts renderer.DiffOptions) renderer.CompDiffRenderer + + // RequirementsProvider creates an ExtraResourceProvider + RequirementsProvider func(res k8.ResourceClient, def xp.EnvironmentClient, logger logging.Logger) *RequirementsProvider + + // FunctionProvider creates a FunctionProvider + FunctionProvider func(fnClient xp.FunctionClient, logger logging.Logger) FunctionProvider + + // InputValidator creates the InputValidator for one PerformDiff run + InputValidator InputValidatorFactory +} + +// ProcessorOption defines a function that can modify a ProcessorConfig. +type ProcessorOption func(*ProcessorConfig) + +// WithColorize sets whether to use colors in diff output. +func WithColorize(colorize bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.Colorize = colorize + } +} + +// WithCompact sets whether to use compact diff format. +func WithCompact(compact bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.Compact = compact + } +} + +// WithOutputFormat sets the output format for diffs. +func WithOutputFormat(format renderer.OutputFormat) ProcessorOption { + return func(config *ProcessorConfig) { + config.OutputFormat = format + } +} + +// WithOutputVersion sets the schema version of structured output. +func WithOutputVersion(version renderer.OutputVersion) ProcessorOption { + return func(config *ProcessorConfig) { + config.OutputVersion = version + } +} + +// WithMaxNestedDepth sets the maximum depth for recursive nested XR processing. +func WithMaxNestedDepth(depth int) ProcessorOption { + return func(config *ProcessorConfig) { + config.MaxNestedDepth = depth + } +} + +// WithIncludeManual sets whether to include XRs with Manual update policy in composition diffs. +func WithIncludeManual(includeManual bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.IncludeManual = includeManual + } +} + +// WithAdvisories sets the collector advisories are raised on and drained from for structured output. +func WithAdvisories(advisories *AdvisoryCollector) ProcessorOption { + return func(config *ProcessorConfig) { + config.Advisories = advisories + } +} + +// WithAnalyzeOn sets the smallest composition change that triggers per-composite impact analysis +// (see ProcessorConfig.AnalyzeOn). An empty value leaves the default in place. +func WithAnalyzeOn(analyzeOn AnalyzeOn) ProcessorOption { + return func(config *ProcessorConfig) { + if analyzeOn != "" { + config.AnalyzeOn = analyzeOn + } + } +} + +// WithDryRunOn sets which resources are round-tripped through the apiserver. An empty value leaves +// the config's default in place, matching WithAnalyzeOn. +func WithDryRunOn(dryRunOn DryRunOn) ProcessorOption { + return func(config *ProcessorConfig) { + if dryRunOn != "" { + config.DryRunOn = dryRunOn + } + } +} + +// WithMinimizeComposition sets whether to collapse composition changes to a single marker line. +func WithMinimizeComposition(minimize bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.MinimizeComposition = minimize + } +} + +// WithEventualState sets whether to show eventual state after all reconciliation cycles complete. +// When enabled, the processor runs an iterative simulation that synthesizes Ready status on +// rendered resources until no new resources appear. This is useful with function-sequencer +// which hides later stage resources until earlier stages become Ready. +func WithEventualState(enabled bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.EventualState = enabled + } +} + +// WithMaxRenderIterations sets the maximum number of render iterations when resolving requirements +// or simulating eventual state. +func WithMaxRenderIterations(maxIterations int) ProcessorOption { + return func(config *ProcessorConfig) { + config.MaxRenderIterations = maxIterations + } +} + +// WithIgnorePaths sets the paths to ignore when calculating diffs. +func WithIgnorePaths(ignorePaths []string) ProcessorOption { + return func(config *ProcessorConfig) { + config.IgnorePaths = ignorePaths + } +} + +// WithFunctionCredentials sets the credentials to pass to Functions during rendering. +func WithFunctionCredentials(creds []corev1.Secret) ProcessorOption { + return func(config *ProcessorConfig) { + config.FunctionCredentials = creds + } +} + +// WithFunctionRegistryOverride overrides the registry in all function package refs. +func WithFunctionRegistryOverride(registry string) ProcessorOption { + return func(config *ProcessorConfig) { + config.FunctionRegistryOverride = registry + } +} + +// WithMaxRecvMessageSize sets the max gRPC message size (MB) injected into +// render function containers. +func WithMaxRecvMessageSize(mb int) ProcessorOption { + return func(config *ProcessorConfig) { + config.MaxRecvMessageSize = mb + } +} + +// WithStdout sets the writer for diff output. +func WithStdout(w io.Writer) ProcessorOption { + return func(config *ProcessorConfig) { + config.Stdout = w + } +} + +// WithStderr sets the writer for error output. +func WithStderr(w io.Writer) ProcessorOption { + return func(config *ProcessorConfig) { + config.Stderr = w + } +} + +// WithLogger sets the logger for the processor. +func WithLogger(logger logging.Logger) ProcessorOption { + return func(config *ProcessorConfig) { + config.Logger = logger + } +} + +// WithRenderFunc sets the render function for the processor. +func WithRenderFunc(renderFn RenderFn) ProcessorOption { + return func(config *ProcessorConfig) { + config.RenderFunc = renderFn + } +} + +// WithCrossplaneRenderBinary points the default engine-backed RenderFn at a +// local `crossplane` binary instead of the upstream docker engine. See +// ProcessorConfig.CrossplaneRenderBinary for the semantics — production +// callers should not use this; it exists for fast integration-test iteration. +func WithCrossplaneRenderBinary(path string) ProcessorOption { + return func(config *ProcessorConfig) { + config.CrossplaneRenderBinary = path + } +} + +// WithCrossplaneVersion pins the crossplane render version the default +// engine-backed RenderFn's docker engine pulls (…/crossplane:). +// See ProcessorConfig.CrossplaneVersion. Mutually exclusive with +// WithCrossplaneImage and WithCrossplaneRenderBinary. +func WithCrossplaneVersion(version string) ProcessorOption { + return func(config *ProcessorConfig) { + config.CrossplaneVersion = version + } +} + +// WithCrossplaneImage pins the full crossplane render image reference the +// default engine-backed RenderFn's docker engine pulls. See +// ProcessorConfig.CrossplaneImage. Mutually exclusive with +// WithCrossplaneVersion and WithCrossplaneRenderBinary. +func WithCrossplaneImage(image string) ProcessorOption { + return func(config *ProcessorConfig) { + config.CrossplaneImage = image + } +} + +// WithResourceManagerFactory sets the ResourceManager factory function. +func WithResourceManagerFactory(factory func(k8.ResourceClient, xp.DefinitionClient, xp.ResourceTreeClient, logging.Logger, dt.Advisories) ResourceManager) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.ResourceManager = factory + } +} + +// WithSchemaValidatorFactory sets the SchemaValidator factory function. +func WithSchemaValidatorFactory(factory func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.SchemaValidator = factory + } +} + +// WithDefaulterFactory sets the Defaulter factory function. +func WithDefaulterFactory(factory func(k8.SchemaClient, xp.DefinitionClient, DefaultingPolicy) Defaulter) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.Defaulter = factory + } +} + +// WithDiffCalculatorFactory sets the DiffCalculator factory function. +func WithDiffCalculatorFactory(factory func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.DiffCalculator = factory + } +} + +// WithDiffRendererFactory sets the DiffRenderer factory function. +func WithDiffRendererFactory(factory func(logging.Logger, renderer.DiffOptions) renderer.DiffRenderer) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.DiffRenderer = factory + } +} + +// WithRequirementsProviderFactory sets the RequirementsProvider factory function. +func WithRequirementsProviderFactory(factory func(k8.ResourceClient, xp.EnvironmentClient, logging.Logger) *RequirementsProvider) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.RequirementsProvider = factory + } +} + +// WithInputValidatorFactory sets the InputValidator factory function, called once per PerformDiff run. +func WithInputValidatorFactory(factory InputValidatorFactory) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.InputValidator = factory + } +} + +// WithFunctionProviderFactory sets the FunctionProvider factory function. +func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) FunctionProvider) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.FunctionProvider = factory + } +} + +// GetDiffOptions returns DiffOptions based on the ProcessorConfig. +func (c *ProcessorConfig) GetDiffOptions() renderer.DiffOptions { + opts := renderer.DefaultDiffOptions() + opts.UseColors = c.Colorize + opts.Compact = c.Compact + opts.MinimizeComposition = c.MinimizeComposition + + opts.IgnorePaths = c.IgnorePaths + if c.OutputFormat != "" { + opts.Format = c.OutputFormat + } + + if c.OutputVersion != "" { + opts.OutputVersion = c.OutputVersion + } + + // Use config's Stdout/Stderr if set, otherwise keep defaults (os.Stdout/os.Stderr) + if c.Stdout != nil { + opts.Stdout = c.Stdout + } + + if c.Stderr != nil { + opts.Stderr = c.Stderr + } + + return opts +} + +// SetDefaultFactories sets default component factory functions if not already set. +func (c *ProcessorConfig) SetDefaultFactories() { + if c.Factories.ResourceManager == nil { + c.Factories.ResourceManager = NewResourceManager + } + + if c.Factories.SchemaValidator == nil { + c.Factories.SchemaValidator = NewSchemaValidator + } + + if c.Factories.Defaulter == nil { + c.Factories.Defaulter = NewDefaulter + } + + if c.Factories.DiffCalculator == nil { + c.Factories.DiffCalculator = NewDiffCalculator + } + + if c.Factories.DiffRenderer == nil { + // Set the appropriate renderer factory based on output format + switch c.OutputFormat { + case renderer.OutputFormatJSON, renderer.OutputFormatYAML: + c.Factories.DiffRenderer = renderer.NewStructuredDiffRenderer + case renderer.OutputFormatDiff: + c.Factories.DiffRenderer = renderer.NewDiffRenderer + default: + c.Factories.DiffRenderer = renderer.NewDiffRenderer + } + } + + if c.Factories.CompDiffRenderer == nil { + // Set the appropriate renderer factory based on output format + switch c.OutputFormat { + case renderer.OutputFormatJSON, renderer.OutputFormatYAML: + c.Factories.CompDiffRenderer = func(logger logging.Logger, _ renderer.DiffRenderer, opts renderer.DiffOptions) renderer.CompDiffRenderer { + return renderer.NewStructuredCompDiffRenderer(logger, opts) + } + case renderer.OutputFormatDiff: + fallthrough + default: + c.Factories.CompDiffRenderer = renderer.NewDefaultCompDiffRenderer + } + } + + if c.Factories.RequirementsProvider == nil { + c.Factories.RequirementsProvider = NewRequirementsProvider + } + + if c.Factories.FunctionProvider == nil { + // Use CachedFunctionProvider by default for container reuse across renders. + // This prevents container proliferation with --eventual-state (multiple iterations) + // and enables reuse when diffing multiple XRs with the same composition. + c.Factories.FunctionProvider = NewCachedFunctionProvider + } + + if c.Factories.InputValidator == nil { + c.Factories.InputValidator = NewBundleInputValidator + } +} diff --git a/internal/diff/diffprocessor/processor_config_test.go b/internal/diff/diffprocessor/processor_config_test.go new file mode 100644 index 00000000..314144c0 --- /dev/null +++ b/internal/diff/diffprocessor/processor_config_test.go @@ -0,0 +1,378 @@ +package diffprocessor + +import ( + "bytes" + "io" + "os" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/renderer" +) + +func TestNewDiffProcessor(t *testing.T) { + tests := map[string]struct { + options []ProcessorOption + expectError bool + }{ + "WithOptions": { + options: []ProcessorOption{WithColorize(false), WithCompact(true)}, + expectError: false, + }, + "BasicOptions": { + options: []ProcessorOption{}, + expectError: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := NewDiffProcessor(k8.Clients{}, xp.Clients{}, tt.options...) + + if processor == nil { + t.Errorf("NewDiffProcessor() returned nil processor") + } + }) + } +} + +// TestProcessorsDefaultEngineLimits verifies a caller that bypasses the CLI still gets the CLI's +// engine limits, and that an explicit option overrides them. +func TestProcessorsDefaultEngineLimits(t *testing.T) { + type limits struct{ MaxNestedDepth, MaxRenderIterations int } + + defaults := limits{DefaultMaxNestedDepth, DefaultMaxRenderIterations} + + xr := func(opts ...ProcessorOption) limits { + c := NewDiffProcessor(k8.Clients{}, xp.Clients{}, opts...).(*DefaultDiffProcessor).config + return limits{c.MaxNestedDepth, c.MaxRenderIterations} + } + comp := func(opts ...ProcessorOption) limits { + c := NewCompDiffProcessor(nil, nil, opts...).(*DefaultCompDiffProcessor).config + return limits{c.MaxNestedDepth, c.MaxRenderIterations} + } + + tests := map[string]struct { + got func(...ProcessorOption) limits + opts []ProcessorOption + want limits + }{ + "XRDefaults": {got: xr, want: defaults}, + "CompDefaults": {got: comp, want: defaults}, + "XROverridden": {got: xr, opts: []ProcessorOption{WithMaxNestedDepth(3), WithMaxRenderIterations(4)}, want: limits{3, 4}}, + "CompOverridden": {got: comp, opts: []ProcessorOption{WithMaxNestedDepth(3), WithMaxRenderIterations(4)}, want: limits{3, 4}}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if diff := gcmp.Diff(tt.want, tt.got(tt.opts...)); diff != "" { + t.Errorf("limits mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDiffOptions(t *testing.T) { + tests := []struct { + name string + config ProcessorConfig + expected renderer.DiffOptions + }{ + { + name: "DefaultOptions", + config: ProcessorConfig{ + Colorize: true, + Compact: false, + }, + expected: func() renderer.DiffOptions { + opts := renderer.DefaultDiffOptions() + opts.UseColors = true + opts.Compact = false + + return opts + }(), + }, + { + name: "NoColors", + config: ProcessorConfig{ + Colorize: false, + Compact: false, + }, + expected: func() renderer.DiffOptions { + opts := renderer.DefaultDiffOptions() + opts.UseColors = false + opts.Compact = false + + return opts + }(), + }, + { + name: "CompactDiff", + config: ProcessorConfig{ + Colorize: true, + Compact: true, + }, + expected: func() renderer.DiffOptions { + opts := renderer.DefaultDiffOptions() + opts.UseColors = true + opts.Compact = true + + return opts + }(), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.GetDiffOptions() + + if diff := gcmp.Diff(tt.expected.UseColors, got.UseColors); diff != "" { + t.Errorf("GetDiffOptions().UseColors mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.expected.Compact, got.Compact); diff != "" { + t.Errorf("GetDiffOptions().Compact mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestGetDiffOptions_PropagatesStdoutStderrFormat verifies that when a ProcessorConfig +// has Stdout, Stderr, and OutputFormat set, those values flow through to the returned +// DiffOptions. When Stdout/Stderr are nil, the defaults (os.Stdout/os.Stderr) must be +// preserved. +func TestGetDiffOptions_PropagatesStdoutStderrFormat(t *testing.T) { + t.Run("CustomWritersAndFormatPropagate", func(t *testing.T) { + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + config := ProcessorConfig{ + Colorize: true, + Stdout: &stdout, + Stderr: &stderr, + OutputFormat: renderer.OutputFormatJSON, + } + + got := config.GetDiffOptions() + + if got.Stdout != io.Writer(&stdout) { + t.Errorf("Expected Stdout to be the injected buffer, got: %v", got.Stdout) + } + + if got.Stderr != io.Writer(&stderr) { + t.Errorf("Expected Stderr to be the injected buffer, got: %v", got.Stderr) + } + + if got.Format != renderer.OutputFormatJSON { + t.Errorf("Expected Format to be %q, got %q", renderer.OutputFormatJSON, got.Format) + } + }) + + t.Run("ZeroValuesKeepDefaults", func(t *testing.T) { + config := ProcessorConfig{ + Colorize: true, + // Stdout, Stderr, and OutputFormat intentionally left at zero value + } + + got := config.GetDiffOptions() + + // When the config's Stdout/Stderr are nil, DefaultDiffOptions() defaults + // (os.Stdout/os.Stderr) should be preserved. + if got.Stdout != io.Writer(os.Stdout) { + t.Errorf("Expected default os.Stdout when config.Stdout is nil, got: %v", got.Stdout) + } + + if got.Stderr != io.Writer(os.Stderr) { + t.Errorf("Expected default os.Stderr when config.Stderr is nil, got: %v", got.Stderr) + } + + // When the config's OutputFormat is the zero value (""), DefaultDiffOptions() + // default (OutputFormatDiff) should be preserved rather than overwritten with "". + if got.Format != renderer.OutputFormatDiff { + t.Errorf("Expected default Format %q when config.OutputFormat is empty, got %q", + renderer.OutputFormatDiff, got.Format) + } + }) +} + +func TestWithOptions(t *testing.T) { + tests := []struct { + name string + options []ProcessorOption + expected ProcessorConfig + }{ + { + name: "WithMultipleOptions", + options: []ProcessorOption{ + WithColorize(false), + WithCompact(true), + }, + expected: ProcessorConfig{ + Colorize: false, + Compact: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a default config + config := ProcessorConfig{ + Colorize: true, + Compact: false, + } + + // Apply the options + for _, option := range tt.options { + option(&config) + } + + // Check colorize + if diff := gcmp.Diff(tt.expected.Colorize, config.Colorize); diff != "" { + t.Errorf("Colorize mismatch (-want +got):\n%s", diff) + } + + // Check compact + if diff := gcmp.Diff(tt.expected.Compact, config.Compact); diff != "" { + t.Errorf("Compact mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestWithCrossplaneRenderOverrides verifies that the render-backend selector +// options set the corresponding ProcessorConfig fields, so they thread through +// to render.EngineFlags when the default engine-backed RenderFn is built. +func TestWithCrossplaneRenderOverrides(t *testing.T) { + tests := map[string]struct { + option ProcessorOption + want ProcessorConfig + }{ + "WithCrossplaneVersion": { + option: WithCrossplaneVersion("v2.4.0"), + want: ProcessorConfig{CrossplaneVersion: "v2.4.0"}, + }, + "WithCrossplaneImage": { + option: WithCrossplaneImage("example.com/mirror/crossplane:v2.3.4"), + want: ProcessorConfig{CrossplaneImage: "example.com/mirror/crossplane:v2.3.4"}, + }, + "WithCrossplaneRenderBinary": { + option: WithCrossplaneRenderBinary("/usr/local/bin/crossplane"), + want: ProcessorConfig{CrossplaneRenderBinary: "/usr/local/bin/crossplane"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var config ProcessorConfig + tt.option(&config) + + if diff := gcmp.Diff(tt.want.CrossplaneVersion, config.CrossplaneVersion); diff != "" { + t.Errorf("CrossplaneVersion mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.want.CrossplaneImage, config.CrossplaneImage); diff != "" { + t.Errorf("CrossplaneImage mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.want.CrossplaneRenderBinary, config.CrossplaneRenderBinary); diff != "" { + t.Errorf("CrossplaneRenderBinary mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestNewDiffProcessorThreadsRenderBackendToEngine closes the gap +// TestWithCrossplaneRenderOverrides leaves open: that test proves the options +// land on the ProcessorConfig, not that NewDiffProcessor then hands them to the +// engine. Blanking both selector arguments at the NewEngineRenderFn call site +// used to leave the whole package green (crossplane-diff#488). +func TestNewDiffProcessorThreadsRenderBackendToEngine(t *testing.T) { + tests := map[string]struct { + options []ProcessorOption + want string + }{ + "NoSelectorResolvesStable": { + want: render.DefaultCrossplaneImage + ":stable", + }, + "CrossplaneVersionReachesEngine": { + options: []ProcessorOption{WithCrossplaneVersion("v2.4.0")}, + want: render.DefaultCrossplaneImage + ":v2.4.0", + }, + "BareCrossplaneVersionReachesEngineNormalized": { + options: []ProcessorOption{WithCrossplaneVersion("2.3.4")}, + want: render.DefaultCrossplaneImage + ":v2.3.4", + }, + "CrossplaneImageReachesEngine": { + options: []ProcessorOption{WithCrossplaneImage("example.com/mirror/crossplane:v2.4.1")}, + want: "example.com/mirror/crossplane:v2.4.1", + }, + "CrossplaneRenderBinaryReachesEngine": { + options: []ProcessorOption{WithCrossplaneRenderBinary("/usr/local/bin/crossplane")}, + want: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := NewDiffProcessor(k8.Clients{}, xp.Clients{}, tt.options...) + + p, ok := processor.(*DefaultDiffProcessor) + if !ok { + t.Fatalf("NewDiffProcessor() returned %T, want *DefaultDiffProcessor", processor) + } + + if p.engineFn == nil { + t.Fatal("NewDiffProcessor() left engineFn nil; the default engine-backed RenderFn was not built") + } + + if diff := gcmp.Diff(tt.want, p.engineFn.RenderImage()); diff != "" { + t.Errorf("engine render image mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestWithStdoutStderr verifies that WithStdout and WithStderr set the +// corresponding writers on the ProcessorConfig. +func TestWithStdoutStderr(t *testing.T) { + var ( + stdout bytes.Buffer + stderr bytes.Buffer + ) + + config := ProcessorConfig{} + + WithStdout(&stdout)(&config) + WithStderr(&stderr)(&config) + + if config.Stdout != io.Writer(&stdout) { + t.Errorf("Expected config.Stdout to be the injected buffer, got: %v", config.Stdout) + } + + if config.Stderr != io.Writer(&stderr) { + t.Errorf("Expected config.Stderr to be the injected buffer, got: %v", config.Stderr) + } +} + +// TestWithMaxRecvMessageSize verifies the option sets the field and that the +// zero value is preserved when the option is not applied. +func TestWithMaxRecvMessageSize(t *testing.T) { + def := ProcessorConfig{} + if def.MaxRecvMessageSize != 0 { + t.Errorf("Expected default MaxRecvMessageSize to be 0, got: %d", def.MaxRecvMessageSize) + } + + config := ProcessorConfig{} + WithMaxRecvMessageSize(16)(&config) + + if config.MaxRecvMessageSize != 16 { + t.Errorf("Expected config.MaxRecvMessageSize to be 16, got: %d", config.MaxRecvMessageSize) + } +} diff --git a/internal/diff/diffprocessor/render_engine.go b/internal/diff/diffprocessor/render_engine.go new file mode 100644 index 00000000..407c1747 --- /dev/null +++ b/internal/diff/diffprocessor/render_engine.go @@ -0,0 +1,493 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + "fmt" + "maps" + "os" + "slices" + "strings" + "sync" + + corev1 "k8s.io/api/core/v1" + kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + composed "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/internal/diff/dockerrun" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// RenderFn is the render abstraction injected into diff processors. Callers +// provide the Function CRs they already have; the implementation owns the +// engine and FunctionAddresses lifecycle. +type RenderFn func(ctx context.Context, log logging.Logger, in RenderInputs) (render.CompositionOutputs, error) + +// RenderInputs carries what the diff processor already holds. It deliberately +// omits FunctionAddrs — that's engine state, not caller state. +type RenderInputs struct { + CompositeResource *ucomposite.Unstructured + Composition *apiextensionsv1.Composition + Functions []pkgv1.Function + FunctionCredentials []corev1.Secret + ObservedResources []composed.Unstructured + RequiredResources []kunstructured.Unstructured + RequiredSchemas []spec3.OpenAPI + + // XRD is the CompositeResourceDefinition the binary should consider + // when rendering. Set by RenderToStableState based on a defClient lookup + // so the binary can pick the right composite.Schema (Legacy vs Modern) + // for the input XR's GVK. The renderer then writes canonical fields at + // the path the cluster CRD declares (spec.* for v1, spec.crossplane.* + // for v2), making dry-run apply succeed and the rendered desired + // comparable against cluster state. Optional; when nil the binary + // falls back to SchemaModern. + XRD *kunstructured.Unstructured +} + +// EngineRenderFn is the default RenderFn implementation. It lazily starts +// function runtimes on first use and reuses them across subsequent calls. It is +// safe for concurrent use: mu guards only the function-startup bookkeeping, so +// renders run in parallel and a function needed by concurrent renders is +// started once. +// +// A single `xr` invocation can render against XRs from multiple compositions +// whose function pipelines overlap but aren't identical. Engine.Setup +// (crossplane/cli#159) accepts being called once per batch: the call that +// creates a new environment — if any — returns a real cleanup; calls that +// integrate fns into an environment that already exists (because a prior +// Setup call established it, or because the engine was pre-configured to use +// an externally-managed environment via --crossplane-docker-network) return a +// no-op cleanup, as do calls on engines with nothing to clean up (the local +// engine). We accumulate every returned cleanup in a slice and call them in +// LIFO order on Cleanup without coordinating which one is real, per the +// caller contract in the upstream Engine.Setup docstring. +type EngineRenderFn struct { + engine render.Engine + cleanups []func() + addrsList []*render.FunctionAddresses + // startedNames is the set of function names we've already passed to + // startRuntimes; used for dedup so we don't restart a function that's + // already running. + startedNames map[string]struct{} + // addrs is the merged Addresses() map from every startRuntimes call. + // Filtered to in.Functions when building each render request. + addrs map[string]string + // mu serializes starting functions (engine.Setup, startRuntimes) and + // tearing them down (Cleanup), and guards cleanups, addrsList, + // startedNames and addrs. It is never held across engine.Render. + mu sync.Mutex + log logging.Logger + + // image is the fully-resolved render image reference the engine will pull, + // or "" when a local binary backend was selected. Written once at + // construction and never mutated, so it is read without holding mu. + image string + + // startRuntimes / stopRuntimes are seams for testing. They default to the + // real render package functions. + startRuntimes func(ctx context.Context, log logging.Logger, fns []pkgv1.Function) (*render.FunctionAddresses, error) + stopRuntimes func(ctx context.Context, fa *render.FunctionAddresses) error + + // run ties the containers and render network this engine creates to this + // process, so that a later run reclaims them if this one dies without + // cleaning up (#525). Its ID is the engine's session ID, so everything the + // engine creates is labelled with it, and it removes all of it in Cleanup. + // nil disables all of that, for tests that stub the engine out. + run *dockerrun.Run +} + +// NewEngineRenderFn constructs an EngineRenderFn. +// +// binaryPath, version, and image select the render backend via +// render.EngineFlags (they are mutually exclusive upstream, grouped under the +// "crossplane-selector" xor): +// - binaryPath (CrossplaneBinary): drives the upstream localRenderEngine +// against a local `crossplane` binary. +// - version (CrossplaneVersion): the docker engine pulls +// xpkg.crossplane.io/crossplane/crossplane:. +// - image (CrossplaneImage): the docker engine pulls that full image ref. +// +// When all three are empty the docker engine pulls +// xpkg.crossplane.io/crossplane/crossplane:stable. Both engines now capture +// stderr into the returned error and honour exit code 3 (partial-output-on- +// fatal — crossplane/crossplane#7455) per crossplane/cli#91, so we no longer +// wrap them. Our EngineRenderFn.Render still expects (rsp != nil, err != nil) +// on pipeline fatal and surfaces both — see the comment there. +// +// The docker engine renders on a Docker network: by default one it creates in +// its first Setup and removes in that Setup's cleanup, labelled with the run's +// session. CROSSPLANE_DIFF_DOCKER_NETWORK, read once here, names the user's +// network instead, passed as render.EngineFlags.CrossplaneDockerNetwork +// (crossplane/cli#65): the engine then runs the crossplane-render container on +// it AND annotates each fn at Setup time so its container joins it too — +// closing both halves of the "crossplane-diff inside a container" case +// (crossplane/cli#75). The engine never creates or removes that network, and +// neither does the run: it carries no session label. +func NewEngineRenderFn(log logging.Logger, advisories dt.Advisories, binaryPath, version, image string) *EngineRenderFn { + // Normalize before the value reaches EngineFlags: upstream formats it into + // the tag verbatim and only v-prefixed tags are published, so the bare form + // ValidateMinRenderVersion accepts would otherwise pass validation and then + // fail at pull time. This is the single EngineFlags construction site, so + // normalizing here also covers callers that set the version programmatically + // via WithCrossplaneVersion rather than through the CLI flag. + version = NormalizeRenderVersion(version) + + // A full image reference is floor-checked at the CLI boundary only as far as + // its tag permits; when it carries no comparable version there is nothing to + // check, and the user should hear that rather than assume the minimum was + // enforced. Advisory only — an unverifiable reference is not an invalid one. + if w := UncomparableRenderImageWarning(image); w != "" { + advisories.Raise(dt.OutputWarning{Message: w}) + } + + run := dockerrun.New(dockerrun.DefaultLeaseDir(), dockerrun.NewDockerClient, log, advisories) + + return &EngineRenderFn{ + engine: render.NewEngineFromFlags(&render.EngineFlags{ + CrossplaneBinary: binaryPath, + CrossplaneVersion: version, + CrossplaneImage: image, + CrossplaneDockerNetwork: os.Getenv(EnvDockerNetwork), + SessionID: run.ID(), + }, log), + image: resolveRenderImage(binaryPath, version, image), + log: log, + startRuntimes: render.StartFunctionRuntimes, + stopRuntimes: render.StopFunctionRuntimes, + run: run, + } +} + +// RenderImage returns the fully-resolved render image reference the engine will +// pull, or "" when a local crossplane binary was selected instead. It exists so +// callers — and tests — can observe which backend a processor actually ended up +// with; the upstream Engine interface exposes no accessor for it, so before this +// the selector arguments vanished at the boundary and dropping them entirely was +// undetectable (crossplane-diff#488). +func (e *EngineRenderFn) RenderImage() string { + return e.image +} + +// resolveRenderImage mirrors the upstream render package's unexported +// crossplaneImageFromFlags. Keep the two in sync: the same inputs must produce +// the same reference, or RenderImage() misreports what actually ran. +func resolveRenderImage(binaryPath, version, image string) string { + switch { + case binaryPath != "": + // The local engine runs a binary; it pulls nothing. + return "" + case image != "": + return image + case version != "": + return render.DefaultCrossplaneImage + ":" + version + default: + return render.DefaultCrossplaneImage + ":stable" + } +} + +// Render performs one render. It is safe for concurrent use: only starting +// functions is serialized (see startFunctions); the render itself runs +// unlocked, so concurrent renders proceed in parallel. Setup runs only on +// invocations that introduce previously-unseen functions; renders whose fns +// are all already running skip straight to building the request. See the +// EngineRenderFn docstring for the per-batch Setup contract this relies on. +func (e *EngineRenderFn) Render(ctx context.Context, log logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + fnAddrs, err := e.startFunctions(ctx, log, in.Functions) + if err != nil { + return render.CompositionOutputs{}, err + } + + req, err := render.BuildCompositeRequest(render.CompositionInputs{ + CompositeResource: in.CompositeResource, + Composition: in.Composition, + FunctionAddrs: fnAddrs, + FunctionCredentials: in.FunctionCredentials, + ObservedResources: in.ObservedResources, + RequiredResources: in.RequiredResources, + RequiredSchemas: in.RequiredSchemas, + XRD: in.XRD, + }) + if err != nil { + return render.CompositionOutputs{}, errors.Wrap(err, "cannot build render request") + } + + rsp, renderErr := e.engine.Render(ctx, req) + // When a pipeline step returns SEVERITY_FATAL, the binary exits with a + // distinct code, surfaces a populated rsp, and returns an error. Treat + // this case as "we have partial output; preserve it AND the error so + // the caller (RenderToStableState) can iterate on RequiredResources + // even when the pipeline fataled." + // + // Anything else with rsp == nil (including a hypothetical (nil, nil) from + // a misbehaving Engine implementation) is a hard failure — we can't parse + // what we don't have. + if rsp == nil { + if renderErr == nil { + return render.CompositionOutputs{}, errors.New("render engine returned nil response with no error") + } + + outErr := errors.Wrap(renderErr, "cannot render") + + // Augment, never replace: the hint rides along with the original + // failure (and everything it wraps) rather than standing in for it. + if hint := e.staleRenderBackendHint(classifyRenderError(renderErr)); hint != "" { + outErr = errors.Errorf("%w; %s", outErr, hint) + } + + return render.CompositionOutputs{}, outErr + } + + out, err := render.ParseCompositeResponse(rsp.GetComposite()) + if err != nil { + return render.CompositionOutputs{}, errors.Wrap(err, "cannot parse render response") + } + + if renderErr != nil { + return out, errors.Wrap(renderErr, "render returned partial output after pipeline fatal") + } + + return out, nil +} + +// startFunctions starts whichever of fns are not running yet and returns the +// addresses of all of fns. It holds mu throughout, so concurrent callers that +// need the same not-yet-started function start it once: the second waits, then +// finds it running. Upstream's docker engine Setup is not safe for concurrent +// use either (its first call records the network it creates), so Setup must be +// serialized anyway. Every render takes mu after the Setup that started its +// functions released it, so what that Setup wrote is visible to the unlocked +// engine.Render that follows. +// +// ponytail: a composition with an empty pipeline starts nothing, so its render +// is not ordered after the first Setup; rendered concurrently with that Setup, +// it races on the docker engine's network field. Only the first Setup of a run +// writes it, and only without CROSSPLANE_DIFF_DOCKER_NETWORK. Upgrade (e.g. a +// once-guarded first Setup) if empty pipelines get diffed concurrently. +func (e *EngineRenderFn) startFunctions(ctx context.Context, log logging.Logger, fns []pkgv1.Function) (map[string]string, error) { + e.mu.Lock() + defer e.mu.Unlock() + + if e.addrs == nil { + e.addrs = make(map[string]string) + } + + if e.startedNames == nil { + e.startedNames = make(map[string]struct{}) + } + + // Identify functions we haven't started yet. Dedup is by function name — + // independent of whether the address map has an entry, so test stubs that + // return an empty *FunctionAddresses don't mistakenly re-trigger Start. + newFns := make([]pkgv1.Function, 0, len(fns)) + for i := range fns { + if _, ok := e.startedNames[fns[i].GetName()]; ok { + continue + } + + newFns = append(newFns, fns[i]) + } + + // Only call Setup when there's actually a new batch to integrate. + // Renders where all fns are already running (newFns is empty) have no + // work for the engine and would just accumulate no-op cleanups in the + // slice for the lifetime of the engine. + if len(newFns) > 0 { + if err := e.claimForRun(ctx, newFns); err != nil { + return nil, err + } + + // Setup integrates newFns into the engine's environment. Whether + // this call creates the environment or only adds to one that + // already exists is the engine's concern; we just hold onto + // whatever cleanup it gives back and let Cleanup walk them LIFO. + cleanup, err := e.engine.Setup(ctx, newFns) + if err != nil { + return nil, errors.Wrap(err, "cannot setup render engine") + } + + // Keep the cleanup even if starting fails: the network it removes can + // only go once Cleanup has removed the containers that did start + // (their cleanup policy leaves them to the run), and the engine keeps + // using the network it created in later Setups, so it must outlive + // a failed start. + e.cleanups = append(e.cleanups, cleanup) + + fa, startErr := e.startRuntimes(ctx, log, newFns) + if startErr != nil { + return nil, errors.Wrap(startErr, "cannot start function runtimes") + } + + e.addrsList = append(e.addrsList, fa) + for i := range newFns { + e.startedNames[newFns[i].GetName()] = struct{}{} + } + + maps.Copy(e.addrs, fa.Addresses()) + } + + // Addresses for fns only — the binary needs addresses for this render's + // pipeline, not for every function we've ever started. Copied, because + // the render reads them after mu is released. + fnAddrs := make(map[string]string, len(fns)) + for i := range fns { + name := fns[i].GetName() + if a, ok := e.addrs[name]; ok { + fnAddrs[name] = a + } + } + + return fnAddrs, nil +} + +// errStaleRenderBackend is the kong usage error a crossplane CLI predating +// v2.3.x emits when asked to run `crossplane internal render`: the `internal` +// command did not exist, so it is rejected as a stray positional argument. +// +// Matching this phrase is deliberately narrow. It can only originate from a CLI +// that does not know the `internal` verb, so it cannot be produced by a +// composition, pipeline, or connectivity failure against a current backend — the +// failures a misattributed hint would send a user chasing the wrong thing for. +const staleRenderBackendSignature = "unexpected argument internal" + +// ErrStaleRenderBackend reports that the render backend predates `crossplane internal render`. +var ErrStaleRenderBackend = errors.New("render backend predates `crossplane internal render`") + +// classifyRenderError wraps err with ErrStaleRenderBackend when it carries the stale-backend +// signature. The upstream engine surfaces the backend's stderr only as message text, with no +// typed error to match, so this is the single place the text is inspected; everything +// downstream tests identity with errors.Is. The message of err is unchanged. +func classifyRenderError(err error) error { + if err == nil || !strings.Contains(err.Error(), staleRenderBackendSignature) { + return err + } + + return errors.Join(ErrStaleRenderBackend, err) +} + +// staleRenderBackendHint returns remediation text when err carries the signature +// of a render backend older than `crossplane internal render`, or "" when it does +// not. +// +// This shape is otherwise a dead end for the user: a bare kong usage error naming +// an argument they never passed, with nothing connecting it to a render image +// that has quietly gone stale. Upstream pulls the render image only when it is +// absent, so a locally cached :stable tag is frozen at whatever was first pulled +// — for however long that machine lives. +func (e *EngineRenderFn) staleRenderBackendHint(err error) string { + if !errors.Is(err, ErrStaleRenderBackend) { + return "" + } + + if e.image == "" { + return fmt.Sprintf("the crossplane render binary does not support `crossplane internal render`, so it predates %s, the minimum this tool supports; point --crossplane-render-binary at a newer binary", + MinCrossplaneRenderVersion) + } + + return fmt.Sprintf("the cached render image %q does not support `crossplane internal render`, so it predates %s, the minimum this tool supports; re-pull it with `docker pull %s`", + e.image, MinCrossplaneRenderVersion, e.image) +} + +// claimForRun prepares the run before anything is created for fns (taking +// this run's lease and reclaiming what dead runs left behind). It then gives +// fns' containers the run's session, which is what lets a later run recognise +// them, and leaves them for the run to remove (dockerrun.Run.Close) rather +// than stopped one by one upstream. The engine's Setup would add the session +// too, but keeps one a function already carries, which would hide its +// container from the run, so it is set here. A container name a function +// carries is dropped: the docker runtime reuses an existing container of that +// name without relabelling it, so concurrent runs would share one container +// that the first to finish removes, and a container another run created +// would never carry this run's session. The annotations are copied: a +// function may share its map with the caller's, e.g. a FunctionProvider's +// cache, and Setup adds to them in place. A no-op when there is no run. +func (e *EngineRenderFn) claimForRun(ctx context.Context, fns []pkgv1.Function) error { + if e.run == nil { + return nil + } + + if err := e.run.Start(ctx); err != nil { + return errors.Wrap(err, "cannot prepare Docker resources for rendering") + } + + for i := range fns { + annotations := maps.Clone(fns[i].GetAnnotations()) + if annotations == nil { + annotations = make(map[string]string, 2) + } + + delete(annotations, render.AnnotationKeyRuntimeNamedContainer) + annotations[render.AnnotationKeyRuntimeDockerSession] = e.run.ID() + annotations[render.AnnotationKeyRuntimeDockerCleanup] = string(render.AnnotationValueRuntimeDockerCleanupOrphan) + fns[i].SetAnnotations(annotations) + } + + return nil +} + +// Cleanup stops every function runtime started across the engine's lifetime, +// then has the run remove every container labelled with its session, run the +// cleanups accumulated from each engine.Setup call in LIFO order (removing the +// network the engine created, now that nothing is attached), and remove any +// of its networks still left. The run's own sweep also catches what upstream +// fails to remove. Idempotent and safe to call when Render was never invoked. +func (e *EngineRenderFn) Cleanup(ctx context.Context) error { + e.mu.Lock() + defer e.mu.Unlock() + + for _, fa := range e.addrsList { + // The run's sweep below removes whatever this leaves behind, and + // warns if it cannot. + if err := e.stopRuntimes(ctx, fa); err != nil { + e.log.Debug("Error stopping function runtimes", "error", err) + } + } + + e.addrsList = nil + e.addrs = nil + e.startedNames = nil + + // Run cleanups in LIFO order. Per the upstream Engine.Setup contract, + // at most one cleanup is real (the one that created the environment, + // if any); the rest are no-ops. LIFO matches the natural deferred- + // cleanup shape and is correct even if a future engine wants ordering. + release := func() { + for _, v := range slices.Backward(e.cleanups) { + v() + } + } + + if e.run != nil { + e.run.Close(ctx, release) + } else { + release() + } + + e.cleanups = nil + + return nil +} diff --git a/internal/diff/diffprocessor/render_engine_test.go b/internal/diff/diffprocessor/render_engine_test.go new file mode 100644 index 00000000..f1d14805 --- /dev/null +++ b/internal/diff/diffprocessor/render_engine_test.go @@ -0,0 +1,1007 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + "maps" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + gcmp "github.com/google/go-cmp/cmp" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/network" + "github.com/moby/moby/client" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/internal/diff/dockerrun" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// newTestRenderFn builds an engineRenderFn wired with the supplied mock engine +// and a stub startRuntimes that returns a bare *render.FunctionAddresses. The +// stopRuntimes counter ticks once per invocation so tests can assert cleanup. +func newTestRenderFn(mock *render.MockEngine, startCalls, stopCalls *int32) *EngineRenderFn { + return &EngineRenderFn{ + engine: mock, + log: logging.NewNopLogger(), + startRuntimes: func(_ context.Context, _ logging.Logger, _ []pkgv1.Function) (*render.FunctionAddresses, error) { + atomic.AddInt32(startCalls, 1) + // Empty FunctionAddresses — Addresses() returns nil, which is fine for BuildCompositeRequest. + return &render.FunctionAddresses{}, nil + }, + stopRuntimes: func(_ context.Context, _ *render.FunctionAddresses) error { + atomic.AddInt32(stopCalls, 1) + return nil + }, + } +} + +// The three tests below (HappyPath, CleanupIdempotent, ConcurrentRenders) each +// exercise a distinct lifecycle property of EngineRenderFn — they're not +// data-variant cases of a single operation. HappyPath asserts setup-once / +// reuse semantics across two sequential renders. CleanupIdempotent asserts +// teardown counts after 0/1/2 cleanup calls. ConcurrentRenders spawns concurrent +// goroutines and asserts their renders are inside the engine at the same time +// while the function they share starts once. Forcing these into a table would require per-row +// setup hooks, per-row assertion sets, and per-row concurrency primitives — +// the rows would share almost nothing. Procedural tests read more clearly +// here. + +// minimalRenderInputs returns RenderInputs with just enough populated that +// BuildCompositeRequest will not error during marshaling. Includes a single +// function so EngineRenderFn's "got a new function, call startRuntimes" path +// is exercised by tests that don't override Functions themselves. +func minimalRenderInputs() RenderInputs { + xr := ucomposite.New() + xr.SetAPIVersion("example.org/v1") + xr.SetKind("XExample") + xr.SetName("test-xr") + + return RenderInputs{ + CompositeResource: xr, + Composition: &apiextensionsv1.Composition{ + Spec: apiextensionsv1.CompositionSpec{ + Mode: apiextensionsv1.CompositionModePipeline, + }, + }, + Functions: []pkgv1.Function{ + {ObjectMeta: metav1.ObjectMeta{Name: "fn-default"}}, + }, + } +} + +func TestEngineRenderFn_HappyPath(t *testing.T) { + ctx := t.Context() + + var renderCalls atomic.Int32 + + mock := &render.MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + renderCalls.Add(1) + // Echo the composite resource back so ParseCompositeResponse succeeds. + if c := req.GetComposite(); c == nil { + t.Fatalf("expected composite input on request") + } + + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: req.GetComposite().GetCompositeResource(), + }, + }, + }, nil + }, + } + + var startCalls, stopCalls int32 + + e := newTestRenderFn(mock, &startCalls, &stopCalls) + + // First render: Setup + StartFunctionRuntimes should each run once. + out, err := e.Render(ctx, logging.NewNopLogger(), minimalRenderInputs()) + if err != nil { + t.Fatalf("first Render: unexpected error: %v", err) + } + + if out.CompositeResource == nil { + t.Fatalf("first Render: expected CompositeResource in output") + } + + if got := atomic.LoadInt32(&startCalls); got != 1 { + t.Fatalf("first Render: startRuntimes calls = %d, want 1", got) + } + + if got := renderCalls.Load(); got != 1 { + t.Fatalf("first Render: engine.Render calls = %d, want 1", got) + } + + // Second render: runtimes should be reused, no new start call. + if _, err := e.Render(ctx, logging.NewNopLogger(), minimalRenderInputs()); err != nil { + t.Fatalf("second Render: unexpected error: %v", err) + } + + if got := atomic.LoadInt32(&startCalls); got != 1 { + t.Fatalf("second Render: startRuntimes calls = %d, want still 1 (reused)", got) + } + + if got := renderCalls.Load(); got != 2 { + t.Fatalf("second Render: engine.Render calls = %d, want 2", got) + } +} + +func TestEngineRenderFn_CleanupIdempotent(t *testing.T) { + ctx := t.Context() + + var setupCalls, setupCleanupCalls int32 + + mock := &render.MockEngine{ + MockSetup: func(_ context.Context, _ []pkgv1.Function) (func(), error) { + atomic.AddInt32(&setupCalls, 1) + return func() { atomic.AddInt32(&setupCleanupCalls, 1) }, nil + }, + } + + var startCalls, stopCalls int32 + + e := newTestRenderFn(mock, &startCalls, &stopCalls) + + // Cleanup before any render: no-op. + if err := e.Cleanup(ctx); err != nil { + t.Fatalf("Cleanup before Render: unexpected error: %v", err) + } + + if got := atomic.LoadInt32(&stopCalls); got != 0 { + t.Fatalf("Cleanup before Render: stopRuntimes = %d, want 0", got) + } + + if got := atomic.LoadInt32(&setupCleanupCalls); got != 0 { + t.Fatalf("Cleanup before Render: setupCleanup = %d, want 0", got) + } + + // Render once to establish state. + if _, err := e.Render(ctx, logging.NewNopLogger(), minimalRenderInputs()); err != nil { + t.Fatalf("Render: unexpected error: %v", err) + } + + if got := atomic.LoadInt32(&setupCalls); got != 1 { + t.Fatalf("Render: MockSetup = %d, want 1", got) + } + + // First real cleanup: runs Stop + setup-cleanup exactly once. + if err := e.Cleanup(ctx); err != nil { + t.Fatalf("first Cleanup: unexpected error: %v", err) + } + + if got := atomic.LoadInt32(&stopCalls); got != 1 { + t.Fatalf("first Cleanup: stopRuntimes = %d, want 1", got) + } + + if got := atomic.LoadInt32(&setupCleanupCalls); got != 1 { + t.Fatalf("first Cleanup: setupCleanup = %d, want 1", got) + } + + // Second cleanup: idempotent (no extra Stop or setup-cleanup). + if err := e.Cleanup(ctx); err != nil { + t.Fatalf("second Cleanup: unexpected error: %v", err) + } + + if got := atomic.LoadInt32(&stopCalls); got != 1 { + t.Fatalf("second Cleanup: stopRuntimes = %d, want still 1", got) + } + + if got := atomic.LoadInt32(&setupCleanupCalls); got != 1 { + t.Fatalf("second Cleanup: setupCleanup = %d, want still 1", got) + } +} + +func TestEngineRenderFn_ConcurrentRenders(t *testing.T) { + ctx := t.Context() + + const renders = 4 + + // Each render waits inside the engine until every render is inside it, so the test only passes + // if no lock is held across engine.Render. A serialized implementation times out instead. + var inside sync.WaitGroup + inside.Add(renders) + + allInside := make(chan struct{}) + go func() { + inside.Wait() + close(allInside) + }() + + var setupCalls atomic.Int32 + + mock := &render.MockEngine{ + MockSetup: func(_ context.Context, _ []pkgv1.Function) (func(), error) { + setupCalls.Add(1) + return func() {}, nil + }, + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + inside.Done() + + select { + case <-allInside: + case <-time.After(10 * time.Second): + return nil, errors.New("renders did not overlap: another render never entered the engine") + } + + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: req.GetComposite().GetCompositeResource(), + }, + }, + }, nil + }, + } + + var startCalls, stopCalls int32 + + e := newTestRenderFn(mock, &startCalls, &stopCalls) + + var wg sync.WaitGroup + for range renders { + wg.Go(func() { + if _, err := e.Render(ctx, logging.NewNopLogger(), minimalRenderInputs()); err != nil { + t.Errorf("Render: %v", err) + } + }) + } + + wg.Wait() + + // Every render needs fn-default; it must be set up and started once, not once per render. + if got := setupCalls.Load(); got != 1 { + t.Errorf("engine.Setup calls = %d, want 1", got) + } + + if got := atomic.LoadInt32(&startCalls); got != 1 { + t.Errorf("startRuntimes calls = %d, want 1", got) + } +} + +// TestEngineRenderFn_MultiCompositionFunctionSet asserts that EngineRenderFn +// correctly handles renders whose RenderInputs.Functions slice differs across +// calls — the case where one `xr` invocation processes XRs that resolve to +// different compositions with overlapping but non-identical function pipelines. +// +// Required behaviour: +// - Setup runs once with the first batch of functions (creates network N). +// - The network name is captured from the annotation upstream's Setup +// stamps onto the first batch. +// - On each subsequent render, any functions NOT already started get the +// same network annotation applied directly, then are passed to +// startRuntimes — joining N as expected. +// - Already-running functions are skipped (their addresses cached). +// - All started FunctionAddresses are stopped on Cleanup. +func TestEngineRenderFn_MultiCompositionFunctionSet(t *testing.T) { + ctx := t.Context() + + const networkName = "test-network-name" + + mock := &render.MockEngine{ + MockSetup: func(_ context.Context, fns []pkgv1.Function) (func(), error) { + // Mimic dockerRenderEngine.Setup's network-annotation behaviour + // so EngineRenderFn can capture the network name back off the + // supplied functions. + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = map[string]string{} + } + + fns[i].Annotations[render.AnnotationKeyRuntimeDockerNetwork] = networkName + } + + return func() {}, nil + }, + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: req.GetComposite().GetCompositeResource(), + }, + }, + }, nil + }, + } + + // startedNames records which function names startRuntimes was called with, + // in the order it was called. Each StartFunctionRuntimes call is a single + // element listing names from that call. Used to assert dedup + new-fn-only + // semantics across renders. + type startCall struct { + names []string + networks []string + } + + var ( + startCallsLog []startCall + startCallsMu sync.Mutex + ) + + e := &EngineRenderFn{ + engine: mock, + log: logging.NewNopLogger(), + startRuntimes: func(_ context.Context, _ logging.Logger, fns []pkgv1.Function) (*render.FunctionAddresses, error) { + startCallsMu.Lock() + defer startCallsMu.Unlock() + + call := startCall{} + for _, fn := range fns { + call.names = append(call.names, fn.GetName()) + call.networks = append(call.networks, fn.GetAnnotations()[render.AnnotationKeyRuntimeDockerNetwork]) + } + + startCallsLog = append(startCallsLog, call) + + return &render.FunctionAddresses{}, nil + }, + stopRuntimes: func(_ context.Context, _ *render.FunctionAddresses) error { return nil }, + } + + mkFn := func(name string) pkgv1.Function { + return pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + } + } + + // First render — composition A's functions [F1, F2]. + in1 := minimalRenderInputs() + + in1.Functions = []pkgv1.Function{mkFn("F1"), mkFn("F2")} + if _, err := e.Render(ctx, logging.NewNopLogger(), in1); err != nil { + t.Fatalf("first Render: %v", err) + } + + // Second render — composition B's functions [F1, F3]. F3 is new, F1 is + // shared with composition A and must NOT be re-started. + in2 := minimalRenderInputs() + + in2.Functions = []pkgv1.Function{mkFn("F1"), mkFn("F3")} + if _, err := e.Render(ctx, logging.NewNopLogger(), in2); err != nil { + t.Fatalf("second Render: %v", err) + } + + // Third render — composition A again. All functions already running. + in3 := minimalRenderInputs() + + in3.Functions = []pkgv1.Function{mkFn("F1"), mkFn("F2")} + if _, err := e.Render(ctx, logging.NewNopLogger(), in3); err != nil { + t.Fatalf("third Render: %v", err) + } + + // Expectations: + // - First render starts F1 + F2. + // - Second render starts only F3 (F1 already running). + // - Third render starts nothing (F1 + F2 already running). + // - Every started function has the captured network annotation. + want := []startCall{ + {names: []string{"F1", "F2"}, networks: []string{networkName, networkName}}, + {names: []string{"F3"}, networks: []string{networkName}}, + } + + startCallsMu.Lock() + defer startCallsMu.Unlock() + + if got := len(startCallsLog); got != len(want) { + t.Fatalf("startRuntimes calls = %d, want %d (calls=%v)", got, len(want), startCallsLog) + } + + for i, w := range want { + if got := startCallsLog[i]; !equalStartCall(got, w) { + t.Errorf("startRuntimes call #%d = %v, want %v", i+1, got, w) + } + } +} + +// TestEngineRenderFn_PreservesExistingNetworkAnnotation asserts that an +// existing annotation is preserved: when a function arrives with a non-empty +// runtime-docker-network annotation already set (e.g. via +// CROSSPLANE_DIFF_DOCKER_NETWORK / a future containerized-job env var path), +// EngineRenderFn must NOT overwrite that value with the captured engine +// network on the subsequent-render annotate path. +func TestEngineRenderFn_PreservesExistingNetworkAnnotation(t *testing.T) { + ctx := t.Context() + + const ( + engineNetwork = "engine-network" + userNetwork = "user-supplied-network" + ) + + mock := &render.MockEngine{ + MockSetup: func(_ context.Context, fns []pkgv1.Function) (func(), error) { + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = map[string]string{} + } + + if fns[i].Annotations[render.AnnotationKeyRuntimeDockerNetwork] == "" { + fns[i].Annotations[render.AnnotationKeyRuntimeDockerNetwork] = engineNetwork + } + } + + return func() {}, nil + }, + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: req.GetComposite().GetCompositeResource(), + }, + }, + }, nil + }, + } + + var ( + seen []string + seenMu sync.Mutex + ) + + e := &EngineRenderFn{ + engine: mock, + log: logging.NewNopLogger(), + startRuntimes: func(_ context.Context, _ logging.Logger, fns []pkgv1.Function) (*render.FunctionAddresses, error) { + seenMu.Lock() + defer seenMu.Unlock() + + for i := range fns { + seen = append(seen, fns[i].GetName()+"="+fns[i].GetAnnotations()[render.AnnotationKeyRuntimeDockerNetwork]) + } + + return &render.FunctionAddresses{}, nil + }, + stopRuntimes: func(_ context.Context, _ *render.FunctionAddresses) error { return nil }, + } + + // First render — composition A's [F1]. Setup stamps engineNetwork on F1. + in1 := minimalRenderInputs() + + in1.Functions = []pkgv1.Function{ + {ObjectMeta: metav1.ObjectMeta{Name: "F1"}}, + } + if _, err := e.Render(ctx, logging.NewNopLogger(), in1); err != nil { + t.Fatalf("first Render: %v", err) + } + + // Second render — composition B introduces F2 with a pre-set user network. + // EngineRenderFn's annotate-on-subsequent-render path must NOT overwrite it. + in2 := minimalRenderInputs() + + in2.Functions = []pkgv1.Function{ + {ObjectMeta: metav1.ObjectMeta{Name: "F2", Annotations: map[string]string{ + render.AnnotationKeyRuntimeDockerNetwork: userNetwork, + }}}, + } + if _, err := e.Render(ctx, logging.NewNopLogger(), in2); err != nil { + t.Fatalf("second Render: %v", err) + } + + want := []string{ + "F1=" + engineNetwork, + "F2=" + userNetwork, + } + + seenMu.Lock() + defer seenMu.Unlock() + + if len(seen) != len(want) { + t.Fatalf("startRuntimes saw %d invocations of fn=net pairs, want %d (%v)", len(seen), len(want), seen) + } + + for i := range want { + if seen[i] != want[i] { + t.Errorf("startRuntimes seen[%d] = %q, want %q", i, seen[i], want[i]) + } + } +} + +// TestEngineRenderFn_CleanupStopsAllFunctionAddresses asserts that every +// *FunctionAddresses ever returned by startRuntimes is passed to stopRuntimes +// during Cleanup, not just the most recent one. +func TestEngineRenderFn_CleanupStopsAllFunctionAddresses(t *testing.T) { + ctx := t.Context() + + mock := &render.MockEngine{ + MockSetup: func(_ context.Context, fns []pkgv1.Function) (func(), error) { + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = map[string]string{} + } + + fns[i].Annotations[render.AnnotationKeyRuntimeDockerNetwork] = "test-network" + } + + return func() {}, nil + }, + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: req.GetComposite().GetCompositeResource(), + }, + }, + }, nil + }, + } + + var stopCalls atomic.Int32 + + e := &EngineRenderFn{ + engine: mock, + log: logging.NewNopLogger(), + startRuntimes: func(_ context.Context, _ logging.Logger, _ []pkgv1.Function) (*render.FunctionAddresses, error) { + return &render.FunctionAddresses{}, nil + }, + stopRuntimes: func(_ context.Context, _ *render.FunctionAddresses) error { + stopCalls.Add(1) + return nil + }, + } + + // Two renders that each introduce a brand-new function → two + // FunctionAddresses entries in fnAddrsList. + in1 := minimalRenderInputs() + + in1.Functions = []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "F1"}}} + if _, err := e.Render(ctx, logging.NewNopLogger(), in1); err != nil { + t.Fatalf("first Render: %v", err) + } + + in2 := minimalRenderInputs() + + in2.Functions = []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "F2"}}} + if _, err := e.Render(ctx, logging.NewNopLogger(), in2); err != nil { + t.Fatalf("second Render: %v", err) + } + + if err := e.Cleanup(ctx); err != nil { + t.Fatalf("Cleanup: %v", err) + } + + if got := stopCalls.Load(); got != 2 { + t.Errorf("stopRuntimes called %d times, want 2 (one per FunctionAddresses)", got) + } +} + +// equalStartCall ignores ordering — startRuntimes can receive functions in any +// order as long as the set + network annotations match. +func equalStartCall(a, b struct { + names []string + networks []string +}, +) bool { + if len(a.names) != len(b.names) { + return false + } + + bn := map[string]string{} + for i, n := range b.names { + bn[n] = b.networks[i] + } + + for i, n := range a.names { + want, ok := bn[n] + if !ok || want != a.networks[i] { + return false + } + } + + return true +} + +// TestNewEngineRenderFn_ResolvedRenderImage asserts that the render-backend +// selector arguments reach the engine, and reach it in a form that is actually +// pullable. Before RenderImage() existed there was nothing to assert against: +// the selected image disappeared into upstream's unexported +// crossplaneImageFromFlags, so a caller that dropped the pin entirely looked +// identical to one that honoured it (crossplane-diff#488). +func TestNewEngineRenderFn_ResolvedRenderImage(t *testing.T) { + stable := render.DefaultCrossplaneImage + ":stable" + + tests := map[string]struct { + binaryPath string + version string + image string + want string + }{ + "NoSelectorResolvesStable": { + want: stable, + }, + "VersionPinResolvesVersionedTag": { + version: "v2.4.0", + want: render.DefaultCrossplaneImage + ":v2.4.0", + }, + // Upstream formats the version into the tag verbatim and publishes only + // v-prefixed tags, so the bare form ValidateMinRenderVersion accepts has + // to be normalized before it gets here or it becomes an unpullable + // reference. + "BareVersionPinIsNormalized": { + version: "2.3.4", + want: render.DefaultCrossplaneImage + ":v2.3.4", + }, + "NonSemverVersionPinPassesThrough": { + version: "stable", + want: stable, + }, + "ImageOverrideResolvesVerbatim": { + image: "example.com/mirror/crossplane:v2.4.0", + want: "example.com/mirror/crossplane:v2.4.0", + }, + // The local engine pulls nothing. + "BinaryPathResolvesNoImage": { + binaryPath: "/usr/local/bin/crossplane", + want: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + e := NewEngineRenderFn(logging.NewNopLogger(), NewAdvisoryCollector(nil), tt.binaryPath, tt.version, tt.image) + + if diff := gcmp.Diff(tt.want, e.RenderImage()); diff != "" { + t.Errorf("RenderImage() mismatch (-want +got):\n%s", diff) + } + }) + } +} + +// TestNewEngineRenderFn_UncomparableImageWarns asserts that an image reference +// whose version cannot be compared against MinCrossplaneRenderVersion draws an +// advisory rather than silence. Refusing such a reference would break the +// private-mirror and digest-pinning cases --crossplane-image exists to serve, so +// a warning is the only thing left that keeps the floor visible. +func TestNewEngineRenderFn_UncomparableImageWarns(t *testing.T) { + tests := map[string]struct { + version string + image string + wantWarning bool + }{ + "UncomparableImageWarns": { + image: "internal-mirror/crossplane", + wantWarning: true, + }, + "ComparableImageStaysQuiet": { + image: "internal-mirror/crossplane:v2.4.1", + wantWarning: false, + }, + // The engine's own :stable default is not the user handing us an + // uncomparable reference, and warning on it would fire on every default + // invocation. + "NoSelectorStaysQuiet": { + wantWarning: false, + }, + "VersionPinStaysQuiet": { + version: "v2.4.0", + wantWarning: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + advisories := NewAdvisoryCollector(nil) + + NewEngineRenderFn(logging.NewNopLogger(), advisories, "", tt.version, tt.image) + + got := advisories.Advisories() + + if !tt.wantWarning { + if len(got) != 0 { + t.Fatalf("warnings = %v, want none", got) + } + + return + } + + if len(got) != 1 { + t.Fatalf("warnings = %v, want exactly 1", got) + } + + if !strings.Contains(got[0].Message, tt.image) { + t.Errorf("warning = %q, want it to name the image %q", got[0].Message, tt.image) + } + }) + } +} + +// TestEngineRenderFn_StaleRenderImageHint asserts that the one render failure +// shape that is purely an environment problem — a render backend so old it has +// no `crossplane internal render` subcommand, which is what a never-refreshed +// local `:stable` image degrades into — carries remediation instead of leaving +// the user with a bare kong usage error. The hint must augment the original +// error, never replace or soften it. +func TestEngineRenderFn_StaleRenderImageHint(t *testing.T) { + // The verbatim shape upstream surfaces: the container's stderr, wrapped. + staleErr := errors.New("crossplane internal render in Docker returned error with output: crossplane: error: unexpected argument internal") + + tests := map[string]struct { + image string + // renderErr is what the engine returns alongside a nil response. + renderErr error + // wantHintFor, when non-empty, must appear in the final error, as must + // MinCrossplaneRenderVersion. + wantHintFor string + }{ + "StaleDockerImageGetsHint": { + image: "xpkg.crossplane.io/crossplane/crossplane:stable", + renderErr: staleErr, + wantHintFor: "xpkg.crossplane.io/crossplane/crossplane:stable", + }, + "StaleLocalBinaryGetsHint": { + image: "", + renderErr: errors.New("crossplane: error: unexpected argument internal"), + wantHintFor: MinCrossplaneRenderVersion, + }, + // Narrowness matters more than coverage here: misattributing an ordinary + // pipeline or connectivity failure to a stale image would send users to + // fix the wrong thing. + "UnrelatedRenderFailureGetsNoHint": { + image: "xpkg.crossplane.io/crossplane/crossplane:stable", + renderErr: errors.New("cannot connect to the Docker daemon"), + }, + "UnrelatedUnexpectedArgumentGetsNoHint": { + image: "xpkg.crossplane.io/crossplane/crossplane:stable", + renderErr: errors.New("crossplane: error: unexpected argument render"), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + + mock := &render.MockEngine{ + MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return nil, tt.renderErr + }, + } + + var startCalls, stopCalls int32 + + e := newTestRenderFn(mock, &startCalls, &stopCalls) + e.image = tt.image + + _, err := e.Render(ctx, logging.NewNopLogger(), minimalRenderInputs()) + if err == nil { + t.Fatalf("Render() = nil error, want the render failure surfaced") + } + + // Whatever else happens, the original failure must survive intact. + if !errors.Is(err, tt.renderErr) { + t.Errorf("Render() error = %v, want it to wrap the engine error", err) + } + + if !strings.Contains(err.Error(), tt.renderErr.Error()) { + t.Errorf("Render() error = %q, want it to contain the engine message %q", err.Error(), tt.renderErr.Error()) + } + + if tt.wantHintFor == "" { + if strings.Contains(err.Error(), MinCrossplaneRenderVersion) { + t.Errorf("Render() error = %q, want no stale-image hint", err.Error()) + } + + return + } + + for _, want := range []string{tt.wantHintFor, MinCrossplaneRenderVersion} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Render() error = %q, want it to contain %q", err.Error(), want) + } + } + }) + } +} + +// TestEngineRenderFn_RunOwnsFunctionContainers pins how the engine ties what it starts to its run +// (#525), by what a fake Docker host holds afterwards. Upstream labels each function container with the +// session its Function's annotation names, and the engine's Setup creates a network for the run that its +// cleanup removes, which the daemon refuses while a container is still attached. Every function is started +// with the run's session, overriding one it carries, with no container name another run's container could +// already have, and left for the run to remove. The run's lease is held before the engine creates anything, +// so a run killed mid-render is reclaimable. Cleanup leaves nothing of the run behind, and removes its +// containers before the engine's cleanup, which therefore succeeds. The caller's Function objects are not +// modified. A function that fails to start still has its network removed by Cleanup. +func TestEngineRenderFn_RunOwnsFunctionContainers(t *testing.T) { + const ( + templating = "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0" + autoReady = "xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.4.2" + engineNet = "crossplane-render-net" + ) + + orphan := string(render.AnnotationValueRuntimeDockerCleanupOrphan) + + type want struct { + // Started is the annotations of each function the engine handed upstream to start. + Started map[string]map[string]string + // Containers and Networks are what the host holds after Cleanup. + Containers []string + Networks []string + + LeaseHeldAtSetup bool + EngineCleanupFailed bool + RenderErr bool + } + + tests := map[string]struct { + reason string + startErr error // fails starting every function after the first + want want + }{ + "Rendered": { + reason: "A rendered run's containers and network are all gone after Cleanup, and the engine's own " + + "cleanup could remove its network.", + want: want{ + Containers: []string{"unrelated"}, + Networks: []string{"user-net"}, + LeaseHeldAtSetup: true, + }, + }, + "StartFailed": { + reason: "A function that fails to start leaves the engine's network for Cleanup, which removes it " + + "once the containers that did start are gone.", + startErr: errors.New("cannot pull image"), + want: want{ + Containers: []string{"unrelated"}, + Networks: []string{"user-net"}, + LeaseHeldAtSetup: true, + RenderErr: true, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + leases := t.TempDir() + + docker := &tu.FakeDocker{ + Containers: []container.Summary{tu.DockerContainer("unrelated", container.StateRunning, nil)}, + Networks: []network.Summary{tu.DockerNetwork("user-net", nil)}, + } + run := dockerrun.New(leases, func() (dockerrun.Docker, error) { return docker, nil }, logging.NewNopLogger(), NewAdvisoryCollector(nil)) + + got := want{Started: map[string]map[string]string{}} + + mock := &render.MockEngine{ + MockSetup: func(_ context.Context, _ []pkgv1.Function) (func(), error) { + _, err := os.Stat(filepath.Join(leases, run.ID()+".lock")) + got.LeaseHeldAtSetup = err == nil + + docker.Networks = append(docker.Networks, tu.DockerNetwork(engineNet, tu.DockerSession(run.ID()))) + + return func() { + if _, err := docker.NetworkRemove(ctx, engineNet, client.NetworkRemoveOptions{}); err != nil { + got.EngineCleanupFailed = true + } + }, nil + }, + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{CompositeResource: req.GetComposite().GetCompositeResource()}, + }, + }, nil + }, + } + + e := &EngineRenderFn{ + engine: mock, + log: logging.NewNopLogger(), + run: run, + // Starts a running container per function, labelled with the session its annotation names and + // attached to the engine's network, as the docker runtime does. + startRuntimes: func(_ context.Context, _ logging.Logger, fns []pkgv1.Function) (*render.FunctionAddresses, error) { + for i, fn := range fns { + if i > 0 && tt.startErr != nil { + return nil, tt.startErr + } + + got.Started[fn.GetName()] = fn.GetAnnotations() + session := fn.GetAnnotations()[render.AnnotationKeyRuntimeDockerSession] + docker.Containers = append(docker.Containers, + tu.DockerContainer(fn.GetName(), container.StateRunning, tu.DockerSession(session), engineNet)) + } + + return &render.FunctionAddresses{}, nil + }, + // The orphan cleanup policy leaves containers to the run, so stopping them removes nothing. + stopRuntimes: func(context.Context, *render.FunctionAddresses) error { return nil }, + } + + in := minimalRenderInputs() + in.Functions = []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{Name: "fn-templating"}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: templating}}, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "fn-auto-ready", Annotations: map[string]string{ + "example.org/keep": "me", + render.AnnotationKeyRuntimeDockerCleanup: string(render.AnnotationValueRuntimeDockerCleanupRemove), + render.AnnotationKeyRuntimeDockerSession: "someone-elses", + render.AnnotationKeyRuntimeNamedContainer: "shared-auto-ready", + }}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: autoReady}}, + }, + } + callerAnnotations := maps.Clone(in.Functions[1].GetAnnotations()) + + _, err := e.Render(ctx, logging.NewNopLogger(), in) + got.RenderErr = err != nil + + if cerr := e.Cleanup(ctx); cerr != nil { + t.Fatalf("\n%s\nCleanup(...): unexpected error: %v", tt.reason, cerr) + } + + got.Containers = docker.ContainerIDs() + got.Networks = docker.NetworkNames() + + tt.want.Started = map[string]map[string]string{ + "fn-templating": { + render.AnnotationKeyRuntimeDockerSession: run.ID(), + render.AnnotationKeyRuntimeDockerCleanup: orphan, + }, + } + if tt.startErr == nil { + tt.want.Started["fn-auto-ready"] = map[string]string{ + "example.org/keep": "me", + render.AnnotationKeyRuntimeDockerSession: run.ID(), + render.AnnotationKeyRuntimeDockerCleanup: orphan, + } + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("\n%s\nRender(...)/Cleanup(...): -want, +got:\n%s", tt.reason, diff) + } + + if diff := gcmp.Diff(callerAnnotations, in.Functions[1].GetAnnotations()); diff != "" { + t.Errorf("\n%s\nRender(...) modified the caller's Function annotations, -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestClassifyRenderError(t *testing.T) { + stale := errors.Wrap(errors.New("crossplane: error: unexpected argument internal"), "cannot render") + + if got := classifyRenderError(stale); !errors.Is(got, ErrStaleRenderBackend) || !errors.Is(got, stale) || got.Error() == "" { + t.Errorf("classifyRenderError(stale) = %v, want ErrStaleRenderBackend preserving the original", got) + } + + if got := classifyRenderError(errors.New("boom")); errors.Is(got, ErrStaleRenderBackend) { + t.Errorf("classifyRenderError(unrelated) wrongly classified: %v", got) + } + + if got := classifyRenderError(nil); got != nil { + t.Errorf("classifyRenderError(nil) = %v, want nil", got) + } +} diff --git a/internal/diff/diffprocessor/render_version.go b/internal/diff/diffprocessor/render_version.go new file mode 100644 index 00000000..520bab96 --- /dev/null +++ b/internal/diff/diffprocessor/render_version.go @@ -0,0 +1,176 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "fmt" + "strings" + + "github.com/Masterminds/semver" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// MinCrossplaneRenderVersion is the minimum crossplane render version that +// crossplane-diff supports. Renders against older versions silently drop +// cluster-observed composed resources (their controller-ref UIDs no longer +// match the XR the way this tool assembles them), producing incorrect diffs — +// see crossplane-contrib/crossplane-diff#399. The value corresponds to the +// upstream release that preserves a non-empty input XR UID and validates +// observed resources (crossplane/crossplane#7544). +const MinCrossplaneRenderVersion = "v2.3.4" + +// minRenderVersion is MinCrossplaneRenderVersion parsed once at package init. +// semver.MustParse panics if the constant is not valid semver — that is a +// compile-time-constant programming error, caught immediately on first import +// rather than deferred to a per-call error branch. +// +//nolint:gochecknoglobals // Parsed-once view of the MinCrossplaneRenderVersion constant; immutable. +var minRenderVersion = semver.MustParse(MinCrossplaneRenderVersion) + +// ValidateMinRenderVersion returns an error if version parses to a semantic +// version older than MinCrossplaneRenderVersion, or if it cannot be parsed as +// a semantic version at all. A leading "v" is accepted (and optional — see +// NormalizeRenderVersion, which is what makes the bare form usable rather than +// merely accepted). It is intended for validating an explicitly pinned +// --crossplane-version; a full --crossplane-image reference goes through +// ValidateMinRenderImage instead, and the floating :stable default carries no +// comparable version at all. +func ValidateMinRenderVersion(version string) error { + v, err := semver.NewVersion(version) + if err != nil { + return errors.Wrapf(err, "cannot parse crossplane render version %q as a semantic version", version) + } + + if v.LessThan(minRenderVersion) { + return errors.Errorf("crossplane render version %q is below the minimum supported version %s", version, MinCrossplaneRenderVersion) + } + + return nil +} + +// NormalizeRenderVersion returns version with a leading "v" when it parses as a +// semantic version without one, and version unchanged otherwise. +// +// ValidateMinRenderVersion deliberately accepts a bare "2.3.4", but upstream +// formats the pinned version into the image tag verbatim +// (…/crossplane:) and publishes only v-prefixed tags, so without this +// the bare form validates and then dies at pull time as an unpullable image. +// The original string is prefixed rather than reformatted from the parsed value, +// so prerelease and build metadata survive byte-for-byte. Tags that are not +// semantic versions ("stable", "main") are real, pullable tags and are left +// alone. +func NormalizeRenderVersion(version string) string { + if version == "" || strings.HasPrefix(version, "v") { + return version + } + + if _, err := semver.NewVersion(version); err != nil { + return version + } + + return "v" + version +} + +// ValidateMinRenderImage returns an error if the tag of a full crossplane render +// image reference parses as a semantic version older than +// MinCrossplaneRenderVersion. +// +// A reference whose version is not comparable — pinned by digest, tagged with a +// floating name like "stable", or carrying no tag at all — is accepted: refusing +// it would break the mirrored and air-gapped registries --crossplane-image +// exists to serve, and a digest is opaque by construction. Those references are +// reported by UncomparableRenderImageWarning instead, so the floor stays visible +// without becoming a false positive. +func ValidateMinRenderImage(image string) error { + v, tag, ok := comparableRenderImageVersion(image) + if !ok { + return nil + } + + if v.LessThan(minRenderVersion) { + return errors.Errorf("crossplane render image %q is tagged %q, which is below the minimum supported version %s", image, tag, MinCrossplaneRenderVersion) + } + + return nil +} + +// UncomparableRenderImageWarning returns advisory text when image is a reference +// whose version cannot be compared against MinCrossplaneRenderVersion, or "" for +// an empty image (no override was requested) or one that ValidateMinRenderImage +// could check. Rendering with an image below the minimum silently drops +// cluster-observed composed resources, which shrinks the rendered set and +// surfaces as spurious removals, so an unverifiable reference is worth saying +// out loud even though it cannot be rejected. +func UncomparableRenderImageWarning(image string) string { + if image == "" { + return "" + } + + if _, _, ok := comparableRenderImageVersion(image); ok { + return "" + } + + return fmt.Sprintf("cannot check crossplane render image %q against the minimum supported version %s: the reference carries no comparable version. "+ + "Ensure it resolves to %s or newer — older renders silently drop cluster-observed composed resources, which surfaces as spurious removals in the diff.", + image, MinCrossplaneRenderVersion, MinCrossplaneRenderVersion) +} + +// comparableRenderImageVersion parses the tag of a container image reference as +// a semantic version. It reports ok=false when the reference carries no tag that +// can be compared, which is the case for a digest-pinned reference, a floating +// or branch tag, or a bare repository path. +func comparableRenderImageVersion(image string) (*semver.Version, string, bool) { + tag := renderImageTag(image) + if tag == "" { + return nil, "", false + } + + v, err := semver.NewVersion(tag) + if err != nil { + return nil, "", false + } + + return v, tag, true +} + +// renderImageTag returns the tag portion of a container image reference, or "" +// when there is none. +// +// A reference pinned by digest returns "" even when it also carries a tag: the +// digest is what the daemon resolves, so the tag is advisory and must not be +// treated as authoritative — least of all as grounds for rejection. +func renderImageTag(image string) string { + // An "@" in a reference can only introduce a digest. + if strings.Contains(image, "@") { + return "" + } + + i := strings.LastIndex(image, ":") + if i < 0 { + return "" + } + + tag := image[i+1:] + + // A ":" that precedes a "/" is a registry port, not a tag separator. + if strings.Contains(tag, "/") { + return "" + } + + return tag +} diff --git a/internal/diff/diffprocessor/render_version_test.go b/internal/diff/diffprocessor/render_version_test.go new file mode 100644 index 00000000..7e56a6ff --- /dev/null +++ b/internal/diff/diffprocessor/render_version_test.go @@ -0,0 +1,305 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "strings" + "testing" + + gcmp "github.com/google/go-cmp/cmp" +) + +func TestValidateMinRenderVersion(t *testing.T) { + tests := map[string]struct { + version string + wantErr bool + // errContains, when set, must appear in the returned error message. + errContains string + }{ + "ExactMinimum": { + version: "v2.3.4", + wantErr: false, + }, + "AboveMinimumPatch": { + version: "v2.3.5", + wantErr: false, + }, + "AboveMinimumMinor": { + version: "v2.4.0", + wantErr: false, + }, + "AboveMinimumMajor": { + version: "v3.0.0", + wantErr: false, + }, + "NoLeadingVAccepted": { + version: "2.3.4", + wantErr: false, + }, + "BelowMinimumPatch": { + version: "v2.3.3", + wantErr: true, + errContains: MinCrossplaneRenderVersion, + }, + "BelowMinimumMinor": { + version: "v2.0.0", + wantErr: true, + errContains: MinCrossplaneRenderVersion, + }, + "BelowMinimumMajor": { + version: "v1.20.0", + wantErr: true, + errContains: MinCrossplaneRenderVersion, + }, + "UnparseableStable": { + version: "stable", + wantErr: true, + errContains: "stable", + }, + "UnparseableLatest": { + version: "latest", + wantErr: true, + errContains: "latest", + }, + "Empty": { + version: "", + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + err := ValidateMinRenderVersion(tt.version) + + if tt.wantErr { + if err == nil { + t.Fatalf("ValidateMinRenderVersion(%q) = nil, want error", tt.version) + } + + if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ValidateMinRenderVersion(%q) error = %q, want it to contain %q", tt.version, err.Error(), tt.errContains) + } + + return + } + + if err != nil { + t.Errorf("ValidateMinRenderVersion(%q) = %v, want nil", tt.version, err) + } + }) + } +} + +func TestNormalizeRenderVersion(t *testing.T) { + tests := map[string]struct { + version string + want string + }{ + // The leniency ValidateMinRenderVersion advertises (see + // "NoLeadingVAccepted" above) only works end to end if the accepted + // bare form is turned into a tag that actually exists upstream. + "BareSemverGainsV": { + version: "2.3.4", + want: "v2.3.4", + }, + "AlreadyPrefixedUnchanged": { + version: "v2.3.4", + want: "v2.3.4", + }, + "BarePrereleasePreservedVerbatim": { + version: "2.4.0-rc.1", + want: "v2.4.0-rc.1", + }, + "BareBuildMetadataPreservedVerbatim": { + version: "2.3.4+build.5", + want: "v2.3.4+build.5", + }, + // Non-semver tags are real, pullable tags; rewriting them would break + // them. + "FloatingStableUnchanged": { + version: "stable", + want: "stable", + }, + "BranchTagUnchanged": { + version: "main", + want: "main", + }, + "EmptyUnchanged": { + version: "", + want: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if diff := gcmp.Diff(tt.want, NormalizeRenderVersion(tt.version)); diff != "" { + t.Errorf("NormalizeRenderVersion(%q) mismatch (-want +got):\n%s", tt.version, diff) + } + }) + } +} + +func TestValidateMinRenderImage(t *testing.T) { + tests := map[string]struct { + image string + // errContains, when set, must appear in the returned error. When empty, + // no error is expected. + errContains string + }{ + "TagAtMinimum": { + image: "example.com/mirror/crossplane:v2.3.4", + }, + "TagAboveMinimum": { + image: "example.com/mirror/crossplane:v2.4.1", + }, + "TagBelowMinimum": { + image: "internal-mirror/crossplane:v2.3.3", + errContains: MinCrossplaneRenderVersion, + }, + "BareSemverTagBelowMinimum": { + image: "internal-mirror/crossplane:2.3.3", + errContains: MinCrossplaneRenderVersion, + }, + "FarBelowMinimum": { + image: "xpkg.crossplane.io/crossplane/crossplane:v1.20.12", + errContains: MinCrossplaneRenderVersion, + }, + // A ":" before a "/" is a registry port, not a tag separator. + "RegistryPortWithTagBelowMinimum": { + image: "localhost:5000/crossplane/crossplane:v2.3.3", + errContains: MinCrossplaneRenderVersion, + }, + "RegistryPortWithTagAboveMinimum": { + image: "localhost:5000/crossplane/crossplane:v2.4.1", + }, + "RegistryPortNoTag": { + image: "localhost:5000/crossplane/crossplane", + }, + // Uncomparable references must be accepted: refusing them would break + // the private-mirror and digest-pinning cases the flag exists for. + "NoTag": { + image: "internal-mirror/crossplane", + }, + "FloatingStableTag": { + image: "xpkg.crossplane.io/crossplane/crossplane:stable", + }, + "LatestTag": { + image: "internal-mirror/crossplane:latest", + }, + "BranchTag": { + image: "internal-mirror/crossplane:main", + }, + "DigestPinned": { + image: "internal-mirror/crossplane@sha256:0000000000000000000000000000000000000000000000000000000000000000", + }, + // The digest is what docker resolves, so an accompanying tag is + // advisory and must not be treated as authoritative — not even to fail. + "DigestPinnedWithBelowMinimumTag": { + image: "internal-mirror/crossplane:v2.3.3@sha256:0000000000000000000000000000000000000000000000000000000000000000", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + err := ValidateMinRenderImage(tt.image) + + if tt.errContains != "" { + if err == nil { + t.Fatalf("ValidateMinRenderImage(%q) = nil, want error", tt.image) + } + + if !strings.Contains(err.Error(), tt.errContains) { + t.Errorf("ValidateMinRenderImage(%q) error = %q, want it to contain %q", tt.image, err.Error(), tt.errContains) + } + + return + } + + if err != nil { + t.Errorf("ValidateMinRenderImage(%q) = %v, want nil", tt.image, err) + } + }) + } +} + +func TestUncomparableRenderImageWarning(t *testing.T) { + tests := map[string]struct { + image string + // wantWarning reports whether a warning is expected. Its text is + // asserted separately below so every row does not have to restate it. + wantWarning bool + }{ + "ComparableTagProducesNoWarning": { + image: "example.com/mirror/crossplane:v2.4.1", + wantWarning: false, + }, + // A below-minimum tag is a hard failure at the CLI boundary, not an + // advisory: it must not also produce a warning. + "BelowMinimumTagProducesNoWarning": { + image: "example.com/mirror/crossplane:v2.3.3", + wantWarning: false, + }, + "NoTagWarns": { + image: "internal-mirror/crossplane", + wantWarning: true, + }, + "RegistryPortNoTagWarns": { + image: "localhost:5000/crossplane/crossplane", + wantWarning: true, + }, + "FloatingTagWarns": { + image: "xpkg.crossplane.io/crossplane/crossplane:stable", + wantWarning: true, + }, + "DigestPinnedWarns": { + image: "internal-mirror/crossplane@sha256:0000000000000000000000000000000000000000000000000000000000000000", + wantWarning: true, + }, + // No image selected at all means the engine resolves its own default; + // that is not the user handing us an uncomparable reference. + "EmptyProducesNoWarning": { + image: "", + wantWarning: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := UncomparableRenderImageWarning(tt.image) + + if tt.wantWarning { + if got == "" { + t.Fatalf("UncomparableRenderImageWarning(%q) = %q, want a warning", tt.image, got) + } + + // A warning a user cannot act on is not worth printing: it must + // name the reference and the floor it could not be checked against. + for _, want := range []string{tt.image, MinCrossplaneRenderVersion} { + if !strings.Contains(got, want) { + t.Errorf("UncomparableRenderImageWarning(%q) = %q, want it to contain %q", tt.image, got, want) + } + } + + return + } + + if got != "" { + t.Errorf("UncomparableRenderImageWarning(%q) = %q, want no warning", tt.image, got) + } + }) + } +} diff --git a/internal/diff/diffprocessor/requirements_provider.go b/internal/diff/diffprocessor/requirements_provider.go new file mode 100644 index 00000000..64f42f3b --- /dev/null +++ b/internal/diff/diffprocessor/requirements_provider.go @@ -0,0 +1,337 @@ +package diffprocessor + +import ( + "context" + "strconv" + "strings" + "sync" + + v1 "github.com/crossplane/function-sdk-go/proto/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// addUniqueResource adds a resource to the map if not already present. +// Returns true if the resource was new. Used by RenderToStableState +// to deduplicate required resources. +func addUniqueResource(m map[string]un.Unstructured, res *un.Unstructured) bool { + if res == nil { + return false + } + + key := dt.MakeDiffKeyFromResource(res) + if _, exists := m[key]; !exists { + m[key] = *res + + return true + } + + return false +} + +// RequirementsProvider consolidates requirement processing with caching. +type RequirementsProvider struct { + client k8.ResourceClient + envClient xp.EnvironmentClient + logger logging.Logger + + // Resource cache by resource key (apiVersion+kind+namespace+name — see + // dt.MakeDiffKey). Namespace is required so same-named resources in + // different namespaces don't collide. + resourceCache map[string]*un.Unstructured + cacheMutex sync.RWMutex +} + +// NewRequirementsProvider creates a new provider with caching. +func NewRequirementsProvider(res k8.ResourceClient, env xp.EnvironmentClient, logger logging.Logger) *RequirementsProvider { + return &RequirementsProvider{ + client: res, + envClient: env, + logger: logger, + resourceCache: make(map[string]*un.Unstructured), + } +} + +// Initialize pre-fetches resources like environment configs. +func (p *RequirementsProvider) Initialize(ctx context.Context) error { + p.logger.Debug("Initializing extra resource provider") + + // Pre-fetch environment configs + envConfigs, err := p.envClient.GetEnvironmentConfigs(ctx) + if err != nil { + return errors.Wrap(err, "cannot get environment configs") + } + + // Add to cache + p.cacheResources(envConfigs) + + p.logger.Debug("Extra resource provider initialized", + "envConfigCount", len(envConfigs), + "cacheSize", len(p.resourceCache)) + + return nil +} + +// ClearCache clears all cached resources. +func (p *RequirementsProvider) ClearCache() { + p.cacheMutex.Lock() + defer p.cacheMutex.Unlock() + + p.resourceCache = make(map[string]*un.Unstructured) + p.logger.Debug("Resource cache cleared") +} + +// cacheResources adds resources to the cache. +func (p *RequirementsProvider) cacheResources(resources []*un.Unstructured) { + p.cacheMutex.Lock() + defer p.cacheMutex.Unlock() + + for _, res := range resources { + key := dt.MakeDiffKeyFromResource(res) + p.resourceCache[key] = res + } +} + +// getCachedResource retrieves a resource from cache if available. +func (p *RequirementsProvider) getCachedResource(apiVersion, kind, namespace, name string) *un.Unstructured { + p.cacheMutex.RLock() + defer p.cacheMutex.RUnlock() + + key := dt.MakeDiffKey(apiVersion, kind, namespace, name) + + return p.resourceCache[key] +} + +// ResolveSelectors resolves a flat list of ResourceSelector entries into their +// backing resources. Checks the cache first; on a miss it defers to the +// per-selector fetcher and caches the result. +// +// Matches the render.CompositionOutputs.RequiredResources shape introduced in +// upstream crossplane PR #7339. +func (p *RequirementsProvider) ResolveSelectors(ctx context.Context, selectors []*v1.ResourceSelector, xrNamespace string) ([]*un.Unstructured, error) { + if len(selectors) == 0 { + p.logger.Debug("No selectors provided, returning empty") + return nil, nil + } + + var ( + allResources []*un.Unstructured + newlyFetchedResources []*un.Unstructured + ) + + for i, selector := range selectors { + res, fetched, err := p.processSelector(ctx, strconv.Itoa(i), selector, xrNamespace) + if err != nil { + return nil, err + } + + allResources = append(allResources, res...) + newlyFetchedResources = append(newlyFetchedResources, fetched...) + } + + if len(newlyFetchedResources) > 0 { + p.cacheResources(newlyFetchedResources) + } + + p.logger.Debug("Resolved selectors", + "selectorCount", len(selectors), + "resourceCount", len(allResources), + "newlyFetchedCount", len(newlyFetchedResources), + "cacheSize", len(p.resourceCache)) + + return allResources, nil +} + +// processSelector processes a single resource selector. resourceKey is a +// short identifier (typically the selector's index in the parent slice) used +// only for debug logging. +func (p *RequirementsProvider) processSelector(ctx context.Context, resourceKey string, selector *v1.ResourceSelector, xrNamespace string) ([]*un.Unstructured, []*un.Unstructured, error) { + if selector == nil { + p.logger.Debug("Nil selector in requirements", "resourceKey", resourceKey) + return nil, nil, nil + } + + gvk := schema.GroupVersionKind{ + Group: parseGroupFromAPIVersion(selector.GetApiVersion()), + Version: parseVersionFromAPIVersion(selector.GetApiVersion()), + Kind: selector.GetKind(), + } + + switch { + case selector.GetMatchName() != "": + resources, fromCache, err := p.processNameSelector(ctx, selector, gvk, xrNamespace) + if err != nil { + return nil, nil, err + } + + // Return resources as newly fetched only if not from cache + var newlyFetched []*un.Unstructured + if !fromCache { + newlyFetched = resources + } + + return resources, newlyFetched, nil + + case selector.GetMatchLabels() != nil: + resources, err := p.processLabelSelector(ctx, selector, gvk, xrNamespace) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot get resources by label") + } + + // Label selector results are always newly fetched (can't cache efficiently) + return resources, resources, nil + + default: + p.logger.Debug("Unsupported selector type", "resourceKey", resourceKey) + return nil, nil, nil + } +} + +// parseGroupFromAPIVersion extracts the group from an apiVersion string. +func parseGroupFromAPIVersion(apiVersion string) string { + group, _ := parseAPIVersion(apiVersion) + return group +} + +// parseVersionFromAPIVersion extracts the version from an apiVersion string. +func parseVersionFromAPIVersion(apiVersion string) string { + _, version := parseAPIVersion(apiVersion) + return version +} + +// resolveNamespace determines the appropriate namespace for a resource based on its scope and selector. +func (p *RequirementsProvider) resolveNamespace(ctx context.Context, gvk schema.GroupVersionKind, selector *v1.ResourceSelector, xrNamespace string) (string, error) { + isNamespaced, err := p.client.IsNamespacedResource(ctx, gvk) + if err != nil { + return "", errors.Wrapf(err, "cannot determine namespace scope for resource %s", gvk.String()) + } + + if !isNamespaced { + return "", nil + } + + if selector.GetNamespace() != "" { + return selector.GetNamespace(), nil + } + + return xrNamespace, nil +} + +// processNameSelector handles resource selection by name. +// Returns (resources, fromCache, error) where fromCache indicates if the resource was found in cache. +func (p *RequirementsProvider) processNameSelector(ctx context.Context, selector *v1.ResourceSelector, gvk schema.GroupVersionKind, xrNamespace string) ([]*un.Unstructured, bool, error) { + name := selector.GetMatchName() + + // Resolve namespace FIRST so we can check cache correctly. + // This prevents returning wrong resources when same-named resources exist + // in different namespaces (e.g., ConfigMap/my-config in both ns-a and ns-b). + ns, err := p.resolveNamespace(ctx, gvk, selector, xrNamespace) + if err != nil { + return nil, false, err + } + + // Now check cache with correct namespace + if cached := p.getCachedResource(selector.GetApiVersion(), selector.GetKind(), ns, name); cached != nil { + p.logger.Debug("Found resource in cache", + "apiVersion", selector.GetApiVersion(), + "kind", selector.GetKind(), + "namespace", ns, + "name", name) + + return []*un.Unstructured{cached}, true, nil + } + + p.logger.Debug("Fetching reference by name", + "gvk", gvk.String(), + "name", name, + "namespace", ns) + + resource, err := p.client.GetResource(ctx, gvk, ns, name) + if err != nil { + // Unmet matchName requirement: not an error. Mirrors upstream + // Crossplane reconcile (internal/xfn/required_resources.go), which + // returns nil on NotFound so the function receives an empty entry for + // that requirement key. Any user-facing breadcrumb comes from the + // function's own conditions/results, captured in the render output. + if apierrors.IsNotFound(err) { + p.logger.Debug("Required resource not found; treating as unmet requirement", + "gvk", gvk.String(), + "name", name, + "namespace", ns, + "error", err) + + return nil, false, nil + } + + return nil, false, errors.Wrapf(err, "cannot get referenced resource %s/%s", ns, name) + } + + return []*un.Unstructured{resource}, false, nil +} + +// processLabelSelector handles resource selection by labels. +// +// The selector namespace is passed through as-is for namespaced kinds, +// mirroring upstream Crossplane (internal/xfn/required_resources.go), which +// lists label-matched resources with client.InNamespace(rs.GetNamespace()). An +// empty namespace therefore lists across ALL namespaces — the documented +// behavior for a matchLabels requirement that omits the namespace. Unlike the +// matchName path, this must NOT fall back to the XR namespace: doing so would +// scope a cluster-wide requirement to a single namespace and silently drop +// matches (crossplane-contrib/crossplane-diff#376). +// +// For cluster-scoped kinds the namespace is forced to "". Upstream relies on +// controller-runtime, whose client consults the RESTMapper and ignores a +// namespace on a cluster-scoped list. We instead use the dynamic client +// directly, which honors .Namespace(...) blindly — passing a namespace for a +// cluster-scoped kind would build an invalid namespaced request path and +// silently return nothing. Consulting the scope keeps our effective behavior +// aligned with upstream. +func (p *RequirementsProvider) processLabelSelector(ctx context.Context, selector *v1.ResourceSelector, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + labelSelector := metav1.LabelSelector{ + MatchLabels: selector.GetMatchLabels().GetLabels(), + } + + isNamespaced, err := p.client.IsNamespacedResource(ctx, gvk) + if err != nil { + return nil, errors.Wrapf(err, "cannot determine namespace scope for resource %s", gvk.String()) + } + + // Namespaced: use the selector namespace verbatim (empty = all namespaces). + // Cluster-scoped: always "". + var ns string + if isNamespaced { + ns = selector.GetNamespace() + } + + p.logger.Debug("Fetching resources by label", + "gvk", gvk.String(), + "labels", labelSelector.MatchLabels, + "namespace", ns, + "namespaced", isNamespaced) + + return p.client.GetResourcesByLabel(ctx, gvk, ns, labelSelector) +} + +// Helper to parse apiVersion into group and version. +func parseAPIVersion(apiVersion string) (string, string) { + var group, version string + if parts := strings.SplitN(apiVersion, "/", 2); len(parts) == 2 { + // Normal case: group/version (e.g., "apps/v1") + group, version = parts[0], parts[1] + } else { + // Core case: version only (e.g., "v1") + version = apiVersion + } + + return group, version +} diff --git a/internal/diff/diffprocessor/requirements_provider_test.go b/internal/diff/diffprocessor/requirements_provider_test.go new file mode 100644 index 00000000..0436621f --- /dev/null +++ b/internal/diff/diffprocessor/requirements_provider_test.go @@ -0,0 +1,476 @@ +package diffprocessor + +import ( + "context" + "testing" + + v1 "github.com/crossplane/function-sdk-go/proto/v1" + "github.com/google/go-cmp/cmp" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +// TestRequirementsProvider_ResolveSelectors covers the selector-flat entry +// point used by the new render.CompositionOutputs.RequiredResources shape. +func TestRequirementsProvider_ResolveSelectors(t *testing.T) { + ctx := t.Context() + + configMap := tu.NewResource("v1", "ConfigMap", "config1").WithNamespace("default").Build() + secret := tu.NewResource("v1", "Secret", "secret1").WithNamespace("default").Build() + + selFor := func(kind, name string) *v1.ResourceSelector { + return &v1.ResourceSelector{ + ApiVersion: "v1", + Kind: kind, + Match: &v1.ResourceSelector_MatchName{MatchName: name}, + } + } + + tests := map[string]struct { + selectors []*v1.ResourceSelector + setupRes func() *tu.MockResourceClient + wantCount int + wantNames []string + wantErr bool + }{ + "Nil": { + selectors: nil, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().Build() + }, + wantCount: 0, + }, + "Empty": { + selectors: []*v1.ResourceSelector{}, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().Build() + }, + wantCount: 0, + }, + "SingleMatchName": { + selectors: []*v1.ResourceSelector{selFor("ConfigMap", "config1")}, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Kind == "ConfigMap" && name == "config1" { + return configMap, nil + } + + return nil, errors.New("not found") + }). + Build() + }, + wantCount: 1, + wantNames: []string{"config1"}, + }, + "TwoSelectorsDistinctKinds": { + selectors: []*v1.ResourceSelector{ + selFor("ConfigMap", "config1"), + selFor("Secret", "secret1"), + }, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + switch { + case gvk.Kind == "ConfigMap" && name == "config1": + return configMap, nil + case gvk.Kind == "Secret" && name == "secret1": + return secret, nil + } + + return nil, errors.New("not found") + }). + Build() + }, + wantCount: 2, + wantNames: []string{"config1", "secret1"}, + }, + "FetchError": { + selectors: []*v1.ResourceSelector{selFor("ConfigMap", "missing")}, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, _ string) (*un.Unstructured, error) { + return nil, errors.New("boom") + }). + Build() + }, + wantErr: true, + }, + "MatchLabels": { + // processSelector → processLabelSelector → GetResourcesByLabel. + // Two ConfigMaps both labelled tier=cache; selector requests + // tier=cache and we expect both back. Different code path from + // MatchName (no GetResource, no namespace-aware cache). + selectors: []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: "ConfigMap", + Match: &v1.ResourceSelector_MatchLabels{ + MatchLabels: &v1.MatchLabels{Labels: map[string]string{"tier": "cache"}}, + }, + }, + }, + setupRes: func() *tu.MockResourceClient { + cacheA := tu.NewResource("v1", "ConfigMap", "cache-a").WithNamespace("default").Build() + cacheB := tu.NewResource("v1", "ConfigMap", "cache-b").WithNamespace("default").Build() + + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}). + WithGetResourcesByLabel(func(_ context.Context, gvk schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + if gvk.Kind == "ConfigMap" && sel.MatchLabels["tier"] == "cache" { + return []*un.Unstructured{cacheA, cacheB}, nil + } + + return nil, nil + }). + Build() + }, + wantCount: 2, + wantNames: []string{"cache-a", "cache-b"}, + }, + // Reproduction for crossplane-contrib/crossplane-diff#355: a go-templating + // ExtraResources block using matchName for a Secret that doesn't exist + // surfaces as a hard failure in `crossplane-diff comp` under the + // "Affected Composite Resources" section. The matchLabels equivalent + // (zero matches) is a non-error today — see "MatchLabelsNoMatches" below + // for the parity case. Both should be no-ops since an unmet ExtraResource + // requirement is a normal Crossplane state, not a failure. + "MatchNameNotFound": { + selectors: []*v1.ResourceSelector{selFor("Secret", "some-secret")}, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + return nil, apierrors.NewNotFound(schema.GroupResource{Group: "", Resource: "secrets"}, name) + }). + Build() + }, + wantCount: 0, + }, + "MatchNameNotFoundWrapped": { + // Production parity: DefaultResourceClient.GetResource wraps the + // dynamic client's NotFound with crossplane-runtime errors.Wrapf + // (resource_client.go:70). apierrors.IsNotFound must still detect + // it through the wrap. + selectors: []*v1.ResourceSelector{selFor("Secret", "some-secret")}, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, ns, name string) (*un.Unstructured, error) { + nf := apierrors.NewNotFound(schema.GroupResource{Group: "", Resource: "secrets"}, name) + return nil, errors.Wrapf(nf, "cannot get resource %s/%s of kind %s", ns, name, gvk.Kind) + }). + Build() + }, + wantCount: 0, + }, + "MatchLabelsNoMatches": { + // Parity case: matchLabels with zero results is already a non-error. + selectors: []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: "Secret", + Match: &v1.ResourceSelector_MatchLabels{ + MatchLabels: &v1.MatchLabels{Labels: map[string]string{"tier": "nope"}}, + }, + }, + }, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}). + WithGetResourcesByLabel(func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error) { + return nil, nil + }). + Build() + }, + wantCount: 0, + }, + "MatchLabelsFetchError": { + // Error path for the label-selector branch, parallel to FetchError. + selectors: []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: "ConfigMap", + Match: &v1.ResourceSelector_MatchLabels{ + MatchLabels: &v1.MatchLabels{Labels: map[string]string{"tier": "cache"}}, + }, + }, + }, + setupRes: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}). + WithGetResourcesByLabel(func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error) { + return nil, errors.New("boom") + }). + Build() + }, + wantErr: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + provider := NewRequirementsProvider( + tt.setupRes(), + tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + tu.TestLogger(t, false), + ) + if err := provider.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + + got, err := provider.ResolveSelectors(ctx, tt.selectors, "default") + if tt.wantErr { + if err == nil { + t.Fatalf("ResolveSelectors: expected error, got nil") + } + + return + } + + if err != nil { + t.Fatalf("ResolveSelectors: unexpected error: %v", err) + } + + if diff := cmp.Diff(tt.wantCount, len(got)); diff != "" { + t.Errorf("resource count mismatch (-want +got):\n%s", diff) + } + + if tt.wantNames != nil { + names := make(map[string]bool, len(got)) + for _, r := range got { + names[r.GetName()] = true + } + + for _, want := range tt.wantNames { + if !names[want] { + t.Errorf("expected resource %q not found in result", want) + } + } + } + }) + } +} + +// TestRequirementsProvider_MatchLabelsCrossNamespace reproduces +// crossplane-contrib/crossplane-diff#376: a go-templating ExtraResources block +// that selects a namespaced kind (e.g. v1/Secret) by matchLabels only, omitting +// the namespace, is a cluster-wide (all-namespaces) lookup in Crossplane. +// +// Upstream reference: crossplane/v2 internal/xfn/required_resources.go +// ExistingRequiredResourcesFetcher.Fetch passes rs.GetNamespace() verbatim to +// client.InNamespace(...); an empty namespace lists across ALL namespaces. It +// never falls back to the XR namespace for the matchLabels path. +// +// crossplane-diff's resolveNamespace instead defaults an empty selector +// namespace to xrNamespace for any namespaced kind, which scopes the label +// lookup to a single namespace. The Secret then isn't found, the ExtraResource +// requirement comes back empty, and function-go-templating fatals reading the +// Crossplane Context value it expected to be populated +// ("Failed to read context '...': ValueError('Value not set')"). +func TestRequirementsProvider_MatchLabelsCrossNamespace(t *testing.T) { + ctx := t.Context() + + // A Secret living in some other namespace than the XR's. The mock returns + // it ONLY for a cluster-wide (empty-namespace) label lookup — a regression + // that scopes the lookup to the XR namespace yields zero results. + secretInOther := tu.NewResource("v1", "Secret", "parameter-store-config"). + WithNamespace("other-ns"). + Build() + + resourceClient := tu.NewMockResourceClient(). + WithNamespacedResource(schema.GroupVersionKind{Group: "", Version: "v1", Kind: "Secret"}). + WithResourcesFoundByLabel([]*un.Unstructured{secretInOther}, "app.kubernetes.io/name", "parameter-store-config", metav1.NamespaceAll). + Build() + + provider := NewRequirementsProvider( + resourceClient, + tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + tu.TestLogger(t, false), + ) + if err := provider.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + + // matchLabels, no namespace — the #376 selector shape. + selectors := []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: "Secret", + Match: &v1.ResourceSelector_MatchLabels{ + MatchLabels: &v1.MatchLabels{ + Labels: map[string]string{"app.kubernetes.io/name": "parameter-store-config"}, + }, + }, + }, + } + + // XR is namespaced ("xr-ns"); the Secret lives in "other-ns". A single + // result proves the lookup was cluster-wide, not scoped to the XR namespace. + got, err := provider.ResolveSelectors(ctx, selectors, "xr-ns") + if err != nil { + t.Fatalf("ResolveSelectors: unexpected error: %v", err) + } + + if len(got) != 1 { + t.Fatalf("expected 1 resource from cluster-wide label lookup, got %d "+ + "(matchLabels without namespace must list across all namespaces, matching upstream required_resources.go)", len(got)) + } +} + +// TestRequirementsProvider_MatchLabelsClusterScoped asserts that a matchLabels +// requirement for a cluster-scoped kind always lists with an empty namespace, +// even if the selector carries one. +// +// crossplane-diff lists via the dynamic client, which — unlike upstream's +// controller-runtime client — honors .Namespace(...) without consulting the +// RESTMapper. Passing a namespace for a cluster-scoped kind would build an +// invalid namespaced request path and silently return nothing (the same class +// of failure as #376). processLabelSelector must therefore clear the namespace +// for cluster-scoped kinds to stay aligned with upstream's effective behavior. +func TestRequirementsProvider_MatchLabelsClusterScoped(t *testing.T) { + ctx := t.Context() + + // Returned only for a cluster-wide (empty namespace) lookup — if the stray + // selector namespace leaked through, the namespaced lookup finds nothing. + clusterXR := tu.NewResource("example.org/v1", "XClusterThing", "thing-1").Build() + + resourceClient := tu.NewMockResourceClient(). + WithClusterScopedResource(schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XClusterThing"}). + WithResourcesFoundByLabel([]*un.Unstructured{clusterXR}, "tier", "cache", metav1.NamespaceAll). + Build() + + provider := NewRequirementsProvider( + resourceClient, + tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + tu.TestLogger(t, false), + ) + if err := provider.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + + // Cluster-scoped kind, but the selector mistakenly carries a namespace. + // It must be ignored (cleared to ""). + selectors := []*v1.ResourceSelector{ + { + ApiVersion: "example.org/v1", + Kind: "XClusterThing", + Namespace: new("stray-ns"), + Match: &v1.ResourceSelector_MatchLabels{ + MatchLabels: &v1.MatchLabels{Labels: map[string]string{"tier": "cache"}}, + }, + }, + } + + got, err := provider.ResolveSelectors(ctx, selectors, "xr-ns") + if err != nil { + t.Fatalf("ResolveSelectors: unexpected error: %v", err) + } + + // A single result proves the stray selector namespace was cleared: the mock + // only returns the resource for a cluster-wide (empty namespace) lookup. + if len(got) != 1 { + t.Fatalf("expected 1 resource from cluster-scoped label lookup, got %d "+ + "(cluster-scoped matchLabels must list with empty namespace regardless of selector namespace)", len(got)) + } +} + +// TestRequirementsProvider_NamespaceCollision tests that resources with the same name +// but different namespaces are correctly distinguished in the cache. +// +// Pairs with the (currently skipped) E2E TestCompDiffIntegration/CrossNamespaceResourceCollision, +// which is blocked on function-extra-resources#106 (the function emits Selector{Namespace:""} +// for Reference-typed extras that omit ref.namespace). This unit test exercises the same +// defaulting + cache-keying behavior at our layer without depending on the function, so +// regressions in resolveNamespace / namespace-aware cache keys are caught immediately. +func TestRequirementsProvider_NamespaceCollision(t *testing.T) { + ctx := t.Context() + + // Two ConfigMaps with the SAME name in DIFFERENT namespaces. + configInNsA := tu.NewResource("v1", "ConfigMap", "my-config"). + WithNamespace("ns-a"). + WithSpecField("data", "value-a"). + Build() + + configInNsB := tu.NewResource("v1", "ConfigMap", "my-config"). + WithNamespace("ns-b"). + WithSpecField("data", "value-b"). + Build() + + resourceClient := tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: "", Version: "v1", Kind: "ConfigMap"}, + ). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, ns, name string) (*un.Unstructured, error) { + t.Logf("GetResource called for %s/%s in namespace %s - cache miss", gvk.Kind, name, ns) + + if ns == "ns-a" { + return configInNsA, nil + } + + if ns == "ns-b" { + return configInNsB, nil + } + + return nil, errors.New("resource not found") + }). + Build() + + environmentClient := tu.NewMockEnvironmentClient(). + WithSuccessfulEnvironmentConfigsFetch([]*un.Unstructured{configInNsA, configInNsB}). + Build() + + provider := NewRequirementsProvider( + resourceClient, + environmentClient, + tu.TestLogger(t, true), + ) + + if err := provider.Initialize(ctx); err != nil { + t.Fatalf("Failed to initialize provider: %v", err) + } + + // Empty Namespace on the selector — should default to xrNamespace ("ns-a"). + selectors := []*v1.ResourceSelector{ + { + ApiVersion: "v1", + Kind: "ConfigMap", + Match: &v1.ResourceSelector_MatchName{MatchName: "my-config"}, + }, + } + + resources, err := provider.ResolveSelectors(ctx, selectors, "ns-a") + if err != nil { + t.Fatalf("ResolveSelectors() unexpected error: %v", err) + } + + if len(resources) != 1 { + t.Fatalf("Expected 1 resource, got %d", len(resources)) + } + + gotResource := resources[0] + gotNamespace := gotResource.GetNamespace() + gotData, _, _ := un.NestedString(gotResource.Object, "spec", "data") + + t.Logf("Got resource: namespace=%s, data=%s (expected: namespace=ns-a, data=value-a)", gotNamespace, gotData) + + if gotNamespace != "ns-a" { + t.Errorf("Namespace collision bug: expected resource from namespace 'ns-a', got %q", gotNamespace) + } + + if gotData != "value-a" { + t.Errorf("Namespace collision bug: expected data 'value-a', got %q (got resource from wrong namespace)", gotData) + } +} diff --git a/internal/diff/diffprocessor/resource_manager.go b/internal/diff/diffprocessor/resource_manager.go new file mode 100644 index 00000000..1baa605c --- /dev/null +++ b/internal/diff/diffprocessor/resource_manager.go @@ -0,0 +1,756 @@ +package diffprocessor + +import ( + "context" + "fmt" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/uuid" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// ResourceManager handles resource-related operations like fetching, updating owner refs, +// and identifying resources to be removed. +type ResourceManager interface { + // FetchCurrentObject retrieves the current state of an object from the cluster + FetchCurrentObject(ctx context.Context, composite *un.Unstructured, desired *un.Unstructured) (*un.Unstructured, bool, error) + + // UpdateOwnerRefs ensures all OwnerReferences have valid UIDs + UpdateOwnerRefs(ctx context.Context, parent *un.Unstructured, child *un.Unstructured) + + // FetchObservedResources fetches the observed composed resources for the given XR + FetchObservedResources(ctx context.Context, xr *cmp.Unstructured) ([]cpd.Unstructured, error) +} + +// treeNodeNotFoundWarning is raised when a composed resource recorded on its XR no longer exists. +const treeNodeNotFoundWarning = "A composed resource referenced by its XR was not found in the cluster (it may have been deleted out of band); it is omitted from the observed resources" + +// DefaultResourceManager implements ResourceManager interface. +type DefaultResourceManager struct { + client k8.ResourceClient + defClient xp.DefinitionClient + treeClient xp.ResourceTreeClient + logger logging.Logger + advisories dt.Advisories +} + +// NewResourceManager creates a new DefaultResourceManager. +func NewResourceManager(client k8.ResourceClient, defClient xp.DefinitionClient, treeClient xp.ResourceTreeClient, logger logging.Logger, advisories dt.Advisories) ResourceManager { + return &DefaultResourceManager{ + client: client, + defClient: defClient, + treeClient: treeClient, + logger: logger, + advisories: advisories, + } +} + +// FetchCurrentObject retrieves the current state of the object from the cluster +// It returns the current object, a boolean indicating if it's a new object, and any error. +func (m *DefaultResourceManager) FetchCurrentObject(ctx context.Context, composite *un.Unstructured, desired *un.Unstructured) (*un.Unstructured, bool, error) { + // Get the GroupVersionKind and name/namespace for lookup + gvk := desired.GroupVersionKind() + name := desired.GetName() + generateName := desired.GetGenerateName() + namespace := desired.GetNamespace() + + // Create a resource ID for logging + resourceID := m.createResourceID(gvk, namespace, name, generateName) + + m.logger.Debug("Fetching current object state", + "resource", resourceID, + "hasName", name != "", + "hasGenerateName", generateName != "") + + // Try direct lookup by name if available + if name != "" { + current, err := m.client.GetResource(ctx, gvk, namespace, name) + if err == nil && current != nil { + m.logger.Debug("Found resource by direct lookup", + "resource", resourceID, + "resourceVersion", current.GetResourceVersion()) + + m.checkCompositeOwnership(ctx, current, composite) + + return current, false, nil + } + + // If it's not a NotFound error, propagate it + if err != nil && !apierrors.IsNotFound(err) { + m.logger.Debug("Error getting resource", + "resource", resourceID, + "error", err) + + return nil, false, err + } + } + + // If direct lookup failed, try looking up by labels and annotations + if composite != nil { + current, found, err := m.lookupByComposite(ctx, composite, desired) + if err != nil { + // For resources that primarily use generateName, errors in label-based lookup + // should result in a new resource rather than an error. + // This matches the original behavior. + if generateName != "" { + m.logger.Debug("Error during label-based lookup for resource with generateName (treating as new)", + "resource", resourceID, + "error", err) + + return nil, true, nil + } + + // For direct name lookups, propagate the error + m.logger.Debug("Error during label-based lookup", + "resource", resourceID, + "error", err) + + return nil, false, err + } + + if found { + return current, false, nil + } + } + + // We didn't find a matching resource using any strategy + m.logger.Debug("No matching resource found", "resource", resourceID) + + return nil, true, nil +} + +// createResourceID generates a resource ID string for logging purposes. +func (m *DefaultResourceManager) createResourceID(gvk schema.GroupVersionKind, namespace, name, generateName string) string { + // Handle case with a proper name + if name != "" { + if namespace != "" { + return fmt.Sprintf("%s/%s/%s", gvk.String(), namespace, name) + } + + return fmt.Sprintf("%s/%s", gvk.String(), name) + } + + // Handle case with generateName + if generateName != "" { + if namespace != "" { + return fmt.Sprintf("%s/%s/%s*", gvk.String(), namespace, generateName) + } + + return fmt.Sprintf("%s/%s*", gvk.String(), generateName) + } + + // Fallback case when neither name nor generateName is provided + return fmt.Sprintf("%s/", gvk.String()) +} + +// checkCompositeOwnership logs a warning if the resource is owned by a different composite. +func (m *DefaultResourceManager) checkCompositeOwnership(ctx context.Context, current *un.Unstructured, composite *un.Unstructured) { + if composite == nil { + return + } + + owner, exists := current.GetLabels()[LabelComposite] + if !exists { + return + } + + newOwner := m.compositeLabelValue(ctx, composite) + if owner == newOwner { + return + } + + m.advisories.Raise(dt.OutputWarning{ + // TODO: should we fail by default here? maybe require a --force flag to proceed? + Message: "Resource already belongs to another composite. Applying this diff will assume ownership!", + Context: map[string]string{ + "resource": fmt.Sprintf("%s/%s", current.GetKind(), current.GetName()), + "namespace": current.GetNamespace(), + "currentOwner": owner, + "newOwner": newOwner, + }, + }) +} + +// compositeLabelValue returns the crossplane.io/composite label Crossplane gives composite's composed +// resources. For an XR that is its own name. A claim composes nothing itself: its composed resources +// belong to its backing XR and carry that XR's name, never the claim's (#534). The backing XR is the +// one the claim's spec.resourceRef names or, for a claim not yet bound to one, one named after the +// claim, the same XR CompositeResolver resolves. +func (m *DefaultResourceManager) compositeLabelValue(ctx context.Context, composite *un.Unstructured) string { + if !m.defClient.IsClaimResource(ctx, composite) { + return composite.GetName() + } + + if ref, bound := resourceRef(composite); bound { + return ref.name + } + + return composite.GetName() +} + +// lookupByComposite attempts to find a resource by looking at composite ownership and composition resource name. +func (m *DefaultResourceManager) lookupByComposite(ctx context.Context, composite *un.Unstructured, desired *un.Unstructured) (*un.Unstructured, bool, error) { + // Derive parameters from the provided arguments + gvk := desired.GroupVersionKind() + namespace := desired.GetNamespace() + generateName := desired.GetGenerateName() + resourceID := m.createResourceID(gvk, namespace, desired.GetName(), generateName) + + // Check if we have annotations + annotations := desired.GetAnnotations() + if annotations == nil { + m.logger.Debug("Resource has no annotations, creating new", + "resource", resourceID) + + return nil, false, nil + } + + // Extract the composition resource name from annotations + compResourceName := m.getCompositionResourceName(annotations) + if compResourceName == "" { + m.logger.Debug("Resource has no composition-resource-name, creating new", + "resource", resourceID) + + return nil, false, nil + } + + m.logger.Debug("Looking up resource by labels and annotations", + "resource", resourceID, + "compositeName", composite.GetName(), + "compositionResourceName", compResourceName, + "hasGenerateName", generateName != "") + + // Only proceed if we have a composite name + if composite.GetName() == "" { + return nil, false, nil + } + + // Determine the appropriate label selector based on whether the composite is a claim + var ( + labelSelector metav1.LabelSelector + lookupName string + ) + + isCompositeAClaim := m.defClient.IsClaimResource(ctx, composite) + + // Check if the desired resource already has a crossplane.io/composite label + // If so, use that for lookup instead of the parent's name. This handles nested XRs + // where the composed resources should have the ROOT XR's name in their composite label, + // not the intermediate nested XR's name. + desiredCompositeLabel := desired.GetLabels()[LabelComposite] + + switch { + case isCompositeAClaim: + // For claims, we need to find the XR that was created from this claim + // The downstream resources will have labels pointing to that XR + // We'll use the claim labels to find downstream resources + labelSelector = metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelClaimName: composite.GetName(), + LabelClaimNamespace: composite.GetNamespace(), + }, + } + lookupName = composite.GetName() + m.logger.Debug("Using claim labels for resource lookup", + "claim", composite.GetName(), + "namespace", composite.GetNamespace()) + case desiredCompositeLabel != "" && desiredCompositeLabel != composite.GetName(): + // The desired resource has a different composite label than the parent + // (e.g., for nested XRs where we've fixed the label to point to root XR) + // Use the resource's own label value for lookup + labelSelector = metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelComposite: desiredCompositeLabel, + }, + } + lookupName = desiredCompositeLabel + m.logger.Debug("Using resource's own composite label for lookup", + "composite", desiredCompositeLabel, + "parentComposite", composite.GetName()) + default: + // For XRs, use the composite label + labelSelector = metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelComposite: composite.GetName(), + }, + } + lookupName = composite.GetName() + m.logger.Debug("Using composite label for resource lookup", + "composite", composite.GetName()) + } + + // Look up resources with the appropriate label selector + resources, err := m.client.GetResourcesByLabel(ctx, gvk, namespace, labelSelector) + if err != nil { + return nil, false, errors.Wrapf(err, "cannot list resources for %s %s", + map[bool]string{true: "claim", false: "composite"}[isCompositeAClaim], lookupName) + } + + if len(resources) == 0 { + m.logger.Debug("No resources found with owner labels", + "lookupName", lookupName, + "isClaimLookup", isCompositeAClaim) + + return nil, false, nil + } + + m.logger.Debug("Found potential matches by label", + "resource", resourceID, + "matchCount", len(resources)) + + // Find a resource with matching composition-resource-name + return m.findMatchingResource(resources, compResourceName, generateName) +} + +// getCompositionResourceName extracts the composition resource name from annotations. +func (m *DefaultResourceManager) getCompositionResourceName(annotations map[string]string) string { + // First check standard annotation + if value, exists := annotations["crossplane.io/composition-resource-name"]; exists { + return value + } + + // Then check function-specific variations + for key, value := range annotations { + if strings.HasSuffix(key, "/composition-resource-name") { + return value + } + } + + return "" +} + +// findMatchingResource looks through resources to find one matching the composition resource name. +func (m *DefaultResourceManager) findMatchingResource( + resources []*un.Unstructured, + compResourceName string, + generateName string, +) (*un.Unstructured, bool, error) { + for _, res := range resources { + resAnnotations := res.GetAnnotations() + if resAnnotations == nil { + continue + } + + // Check if this resource has a matching composition resource name + if !m.hasMatchingResourceName(resAnnotations, compResourceName) { + continue + } + + // TODO: is this logic correct? we should definitely match composition-resource-name, even if the generateName + // doesn't match. maybe as a fallback if we don't have the annotation? + + // If we have a generateName, verify the match has the right prefix + if generateName != "" { + resName := res.GetName() + if !strings.HasPrefix(resName, generateName) { + m.logger.Debug("Found resource with matching composition name but wrong generateName prefix", + "expectedPrefix", generateName, + "actualName", resName) + + continue + } + } + + // We found a match! + m.logger.Debug("Found resource by label and annotation", + "resource", res.GetName(), + "compositionResourceName", compResourceName) + + return res, true, nil + } + + m.logger.Debug("No matching resource found with composition resource name", + "compositionResourceName", compResourceName) + + return nil, false, nil +} + +// hasMatchingResourceName checks if annotations have a matching composition-resource-name. +func (m *DefaultResourceManager) hasMatchingResourceName(annotations map[string]string, compResourceName string) bool { + // Check standard annotation + if value, exists := annotations["crossplane.io/composition-resource-name"]; exists && value == compResourceName { + return true + } + + // Check function-specific variations + for key, value := range annotations { + if strings.HasSuffix(key, "/composition-resource-name") && value == compResourceName { + return true + } + } + + return false +} + +// UpdateOwnerRefs ensures all OwnerReferences have valid UIDs. +// It handles Claims and XRs differently according to Crossplane's ownership model: +// - Claims should never be controller owners of composed resources. +// - XRs should be controller owners and use their UID for matching references. +func (m *DefaultResourceManager) UpdateOwnerRefs(ctx context.Context, parent *un.Unstructured, child *un.Unstructured) { + // if there's no parent, we are the parent. + if parent == nil { + m.logger.Debug("No parent provided for owner references update") + return + } + + // Check if the parent is a claim and dispatch to appropriate handler + isParentAClaim := m.defClient.IsClaimResource(ctx, parent) + + switch { + case isParentAClaim: + m.updateOwnerRefsForClaim(parent, child) + default: + m.updateOwnerRefsForXR(parent, child) + } + + // Update composite owner label (common for both Claims and XRs) + m.updateCompositeOwnerLabel(ctx, parent, child) +} + +// updateOwnerRefsForClaim handles owner reference updates when the parent is a Claim. +// Claims should not be controller owners of composed resources per Crossplane's model: +// Claim -> owns -> XR -> owns -> Composed Resources. +func (m *DefaultResourceManager) updateOwnerRefsForClaim(claim *un.Unstructured, child *un.Unstructured) { + m.logger.Debug("Processing owner references with claim parent", + "parentKind", claim.GetKind(), + "parentName", claim.GetName(), + "childKind", child.GetKind(), + "childName", child.GetName()) + + // Get the current owner references + refs := child.GetOwnerReferences() + updatedRefs := make([]metav1.OwnerReference, 0, len(refs)) + + for _, ref := range refs { + // Generate UIDs for any owner references that don't have them + // For Claims, we never use the Claim's UID even for matching refs + if ref.UID == "" { + ref.UID = uuid.NewUUID() + m.logger.Debug("Generated UID for owner reference", + "refKind", ref.Kind, + "refName", ref.Name, + "newUID", ref.UID) + } + + // Ensure Claims are never controller owners + if ref.Kind == claim.GetKind() && ref.Name == claim.GetName() { + if ref.Controller != nil && *ref.Controller { + ref.Controller = new(false) + m.logger.Debug("Set Controller to false for claim owner reference", + "refKind", ref.Kind, + "refName", ref.Name) + } + } + + updatedRefs = append(updatedRefs, ref) + } + + child.SetOwnerReferences(updatedRefs) +} + +// updateOwnerRefsForXR handles owner reference updates when the parent is an XR. +// XRs should be controller owners of their composed resources and use their UID +// for matching owner references. +func (m *DefaultResourceManager) updateOwnerRefsForXR(xr *un.Unstructured, child *un.Unstructured) { + uid := xr.GetUID() + m.logger.Debug("Updating owner references", + "parentKind", xr.GetKind(), + "parentName", xr.GetName(), + "parentUID", uid, + "childKind", child.GetKind(), + "childName", child.GetName()) + + // Get the current owner references + refs := child.GetOwnerReferences() + m.logger.Debug("Current owner references", "count", len(refs)) + + // Create new slice to hold the updated references + updatedRefs := make([]metav1.OwnerReference, 0, len(refs)) + + // Set a valid UID for each reference + for _, ref := range refs { + originalUID := ref.UID + + // If there is an owner ref on the dependent that matches the parent XR, + // overwrite its UID with the cluster UID (when known). The upstream + // render engine pre-fills a deterministic SHA1-derived UID, so the + // guard must match on name/apiVersion/kind alone and not on UID=="" — + // otherwise the synthetic UID survives alongside the real cluster UID + // and dry-run apply rejects the dual-controller ownerRefs. + if uid != "" && + ref.Name == xr.GetName() && + ref.APIVersion == xr.GetAPIVersion() && + ref.Kind == xr.GetKind() { + ref.UID = uid + m.logger.Debug("Updated matching owner reference with parent UID", + "refName", ref.Name, + "oldUID", originalUID, + "newUID", ref.UID) + } + + // For non-matching owner refs, generate a random UID + if ref.UID == "" { + ref.UID = uuid.NewUUID() + m.logger.Debug("Generated new random UID for owner reference", + "refName", ref.Name, + "oldUID", originalUID, + "newUID", ref.UID) + } + + updatedRefs = append(updatedRefs, ref) + } + + // Dedupe owner refs targeting the same parent. The upstream render + // engine can emit two controller refs to the same parent XR in nested + // scenarios — one with its SHA1-derived UID and one with the cluster + // UID. After the UID-overwrite loop above they carry the same UID, but + // their presence as two entries still fails the apiserver "single + // controller" validation. Collapse matches by (APIVersion, Kind, Name), + // keeping the first occurrence. + dedupedRefs := make([]metav1.OwnerReference, 0, len(updatedRefs)) + + seen := make(map[string]bool, len(updatedRefs)) + for _, ref := range updatedRefs { + // OwnerReferences are namespace-scoped to the owning object's namespace, + // so MakeDiffKey's apiVersion/kind/namespace/name shape is overkill here + // — but reusing it keeps key construction consistent with the rest of + // the diff layer. Empty namespace is fine: dedup is per (kind, name). + key := dt.MakeDiffKey(ref.APIVersion, ref.Kind, "", ref.Name) + if seen[key] { + m.logger.Debug("Dropping duplicate owner reference", + "refKind", ref.Kind, + "refName", ref.Name, + "refUID", ref.UID) + + continue + } + + seen[key] = true + + dedupedRefs = append(dedupedRefs, ref) + } + + updatedRefs = dedupedRefs + + // Update the object with the modified owner references + child.SetOwnerReferences(updatedRefs) + + m.logger.Debug("Updated owner references and labels", + "newCount", len(updatedRefs)) +} + +// updateCompositeOwnerLabel updates the ownership labels on the child resource. +// For Claims, it sets claim-name and claim-namespace labels. +// For XRs, it sets the composite label. +func (m *DefaultResourceManager) updateCompositeOwnerLabel(ctx context.Context, parent, child *un.Unstructured) { + if parent == nil { + return + } + + // Get current labels or create a new map + labels := child.GetLabels() + if labels == nil { + labels = make(map[string]string) + } + + parentName := parent.GetName() + if parentName == "" && parent.GetGenerateName() != "" { + // For XRs with only generateName, use the generateName prefix + parentName = parent.GetGenerateName() + } + + if parentName == "" { + return + } + + // Check if the parent is a claim + isParentAClaim := m.defClient.IsClaimResource(ctx, parent) + + switch { + case isParentAClaim: + // For claims, set claim-specific labels (all claims are namespaced) + labels[LabelClaimName] = parentName + labels[LabelClaimNamespace] = parent.GetNamespace() + + // For claims, only set the composite label if it doesn't already exist + // If it exists, it likely points to a generated XR name which we should preserve + existingComposite, hasComposite := labels[LabelComposite] + if !hasComposite { + // No existing composite label, set it to the claim name + labels[LabelComposite] = parentName + m.logger.Debug("Set composite label for new claim resource", + "claimName", parentName, + "claimNamespace", parent.GetNamespace(), + "child", child.GetName()) + } else { + // Preserve existing composite label (likely a generated XR name) + m.logger.Debug("Preserved existing composite label for claim resource", + "claimName", parentName, + "claimNamespace", parent.GetNamespace(), + "existingComposite", existingComposite, + "child", child.GetName()) + } + default: + // For XRs, only set the composite label if it doesn't already exist + // If it exists, preserve it (e.g., for managed resources that already have correct ownership) + existingComposite, hasComposite := labels[LabelComposite] + if !hasComposite { + // No existing composite label, set it to the parent XR name + labels[LabelComposite] = parentName + m.logger.Debug("Set composite label for new XR resource", + "xrName", parentName, + "child", child.GetName()) + } else { + // Preserve existing composite label + m.logger.Debug("Preserved existing composite label for XR resource", + "xrName", parentName, + "existingComposite", existingComposite, + "child", child.GetName()) + } + } + + child.SetLabels(labels) +} + +// FetchObservedResources fetches the observed composed resources for the given XR. +// Returns a flat slice of composed resources suitable for RenderInputs.ObservedResources. +func (m *DefaultResourceManager) FetchObservedResources(ctx context.Context, xr *cmp.Unstructured) ([]cpd.Unstructured, error) { + m.logger.Debug("Fetching observed resources for XR", + "xr_kind", xr.GetKind(), + "xr_name", xr.GetName()) + + // Get the resource tree from the cluster + tree, err := m.treeClient.GetResourceTree(ctx, &xr.Unstructured) + if err != nil { + m.logger.Debug("Failed to get resource tree for XR", + "xr", xr.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot get resource tree") + } + + // Extract composed resources from the tree, scoped to those this XR controls. + observed, err := m.extractComposedResourcesFromTree(tree, xr.GetUID()) + if err != nil { + return nil, err + } + + m.logger.Debug("Fetched observed composed resources", + "xr", xr.GetName(), + "count", len(observed)) + + return observed, nil +} + +// extractComposedResourcesFromTree recursively extracts composed resources from a resource tree. +// It returns a flat slice of composed resources, suitable for RenderInputs.ObservedResources. +// +// Only resources controlled by the XR being rendered are returned: +// - the resource must carry the crossplane.io/composition-resource-name annotation +// (this filters out the root XR and non-composed resources), AND +// - it must either have no controller owner reference, or have one whose UID matches +// xrUID (the UID of the XR this observed set is being assembled for). +// +// The second condition matters for nested XRs: the tree walk descends into a nested XR's +// own children (grandchildren of the top XR), but those are controlled by the nested XR, +// not the top XR. The crossplane render binary (>= v2.3.4) rejects observed resources whose +// controller-ref UID does not match the XR being rendered ("has a controller ref but is not +// controlled by the XR"), so each render level must only receive the resources it controls. +// Nested XRs are rendered separately (with their own observed set) during recursive processing. +// +// The upstream tree client never fails as a whole: it records each node's fetch error on the node +// and leaves that node's object holding only the GVK, name and namespace it was referenced by. Such +// a node has no annotations, so without an explicit check it would be dropped exactly like a +// resource that is legitimately not composed, and the diff would then report a resource that exists +// as an addition. So every node's error is checked before the filters above: +// - NotFound on a descendant means the resource really is absent (deleted out of band since the +// XR recorded it). Omitting it is the correct observed state, so it is skipped with a warning. +// - Any other error (Forbidden, a timeout, a transport failure) means its state is unknown, so the +// whole extraction fails rather than return an observed set that is confidently wrong. +// - An error on the root fails the extraction whatever its type. The root is the XR handed to the +// tree client rather than one it fetched, so an error there means the tree as a whole is not +// usable, and skipping it would leave an empty observed set — the same silent wrong answer. +func (m *DefaultResourceManager) extractComposedResourcesFromTree(tree *resource.Resource, xrUID k8stypes.UID) ([]cpd.Unstructured, error) { + if tree.Error != nil { + return nil, errors.Wrapf(tree.Error, "cannot load XR %s into its resource tree", m.treeNodeID(tree)) + } + + var resources []cpd.Unstructured + + // Recursively collect composed resources from the tree + var collectResources func(node *resource.Resource) error + + collectResources = func(node *resource.Resource) error { + if node.Error != nil { + if !apierrors.IsNotFound(node.Error) { + return errors.Wrapf(node.Error, "cannot fetch composed resource %s from the cluster", m.treeNodeID(node)) + } + + // The context deliberately omits the XR: a nested XR's grandchild is walked once for the top + // XR and again for the nested XR, and identical advisories collapse into one. + m.advisories.Raise(dt.OutputWarning{ + Message: treeNodeNotFoundWarning, + Context: map[string]string{ + "resource": fmt.Sprintf("%s/%s", node.Unstructured.GetKind(), node.Unstructured.GetName()), + "namespace": node.Unstructured.GetNamespace(), + }, + }) + + // A node that could not be fetched has no children to walk. + return nil + } + + // Only include resources that have the composition-resource-name annotation + // (this filters out the root XR and non-composed resources) + if _, hasAnno := node.Unstructured.GetAnnotations()["crossplane.io/composition-resource-name"]; hasAnno { + // Only include resources controlled by this XR (or uncontrolled). A resource + // controlled by a *different* owner (e.g. a nested XR's own composed resource) + // is not part of this XR's observed set and would be rejected by the render + // binary's controller-ref validation. + if c := metav1.GetControllerOf(&node.Unstructured); c == nil || xrUID == "" || c.UID == xrUID { + resources = append(resources, cpd.Unstructured{ + Unstructured: node.Unstructured, + }) + } + } + + // Recursively process children + for _, child := range node.Children { + if err := collectResources(child); err != nil { + return err + } + } + + return nil + } + + // Start from root's children to avoid including the XR itself + for _, child := range tree.Children { + if err := collectResources(child); err != nil { + return nil, err + } + } + + return resources, nil +} + +// treeNodeID identifies a resource-tree node in an error. Upstream keeps the GVK, name and namespace +// on a node even when fetching it failed, so this is meaningful for exactly the nodes that need it. +func (m *DefaultResourceManager) treeNodeID(node *resource.Resource) string { + u := &node.Unstructured + return m.createResourceID(u.GroupVersionKind(), u.GetNamespace(), u.GetName(), "") +} diff --git a/internal/diff/diffprocessor/resource_manager_test.go b/internal/diff/diffprocessor/resource_manager_test.go new file mode 100644 index 00000000..053012e1 --- /dev/null +++ b/internal/diff/diffprocessor/resource_manager_test.go @@ -0,0 +1,1523 @@ +package diffprocessor + +import ( + "bytes" + "context" + "sort" + "strings" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +const ( + testClaimName = "test-claim" + testClaimKind = "TestClaim" +) + +func TestDefaultResourceManager_FetchCurrentObject(t *testing.T) { + ctx := t.Context() + + // Create test resources + existingResource := tu.NewResource("example.org/v1", "TestResource", "existing-resource"). + WithSpecField("field", "value"). + Build() + + // Resource with generateName instead of name + resourceWithGenerateName := tu.NewResource("example.org/v1", "TestResource", ""). + WithSpecField("field", "value"). + Build() + resourceWithGenerateName.SetGenerateName("test-resource-") + + // Existing resource that matches generateName pattern + existingGeneratedResource := tu.NewResource("example.org/v1", "TestResource", "test-resource-abc123"). + WithSpecField("field", "value"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build() + + // Existing resource that matches generateName pattern but has different resource name + existingGeneratedResourceWithDifferentResName := tu.NewResource("example.org/v1", "TestResource", "test-resource-abc123"). + WithSpecField("field", "value"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-b", + }). + Build() + + // Composed resource with annotations + composedResource := tu.NewResource("example.org/v1", "ComposedResource", "composed-resource"). + WithSpecField("field", "value"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build() + + // Parent XR + parentXR := tu.NewResource("example.org/v1", "XR", "parent-xr"). + WithSpecField("field", "value"). + Build() + + tests := map[string]struct { + setupResourceClient func() *tu.MockResourceClient + defClient *tu.MockDefinitionClient + composite *un.Unstructured + desired *un.Unstructured + wantIsNew bool + wantResourceID string + wantErr bool + }{ + "ExistingResourceFoundDirectly": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithResourcesExist(existingResource). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: nil, + desired: existingResource.DeepCopy(), + wantIsNew: false, + wantResourceID: "existing-resource", + wantErr: false, + }, + "ResourceNotFound": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithResourceNotFound(). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: nil, + desired: tu.NewResource("example.org/v1", "TestResource", "non-existent").Build(), + wantIsNew: true, + wantResourceID: "", + wantErr: false, + }, + "CompositeIsNil_NewXR": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithResourceNotFound(). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: nil, + desired: tu.NewResource("example.org/v1", "XR", "new-xr").Build(), + wantIsNew: true, + wantResourceID: "", + wantErr: false, + }, + "ResourceWithGenerateName_NotFound": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithResourceNotFound(). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: nil, + desired: resourceWithGenerateName, + wantIsNew: true, + wantResourceID: "", + wantErr: false, + }, + "ResourceWithGenerateName_FoundByLabelAndAnnotation": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + // Return "not found" for direct name lookup + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + return nil, apierrors.NewNotFound( + schema.GroupResource{ + Group: gvk.Group, + Resource: strings.ToLower(gvk.Kind) + "s", + }, + name, + ) + }). + // Return existing resource when looking up by label AND check the composition-resource-name annotation + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + if owner, exists := sel.MatchLabels["crossplane.io/composite"]; exists && owner == "parent-xr" { + return []*un.Unstructured{existingGeneratedResource, existingGeneratedResourceWithDifferentResName}, nil + } + + return []*un.Unstructured{}, nil + }). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: parentXR, + desired: tu.NewResource("example.org/v1", "TestResource", ""). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + WithGenerateName("test-resource-"). + Build(), + wantIsNew: false, + wantResourceID: "test-resource-abc123", + wantErr: false, + }, + "ComposedResource_FoundByLabelAndAnnotation": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + // Return "not found" for direct name lookup to force label lookup + WithResourceNotFound(). + // Return our existing resource when looking up by label AND check the composition-resource-name annotation + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + if owner, exists := sel.MatchLabels["crossplane.io/composite"]; exists && owner == "parent-xr" { + return []*un.Unstructured{composedResource}, nil + } + + return []*un.Unstructured{}, nil + }). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: parentXR, + desired: tu.NewResource("example.org/v1", "ComposedResource", "composed-resource"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build(), + wantIsNew: false, + wantResourceID: "composed-resource", + wantErr: false, + }, + "NoAnnotations_NewResource": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithResourceNotFound(). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: parentXR, + desired: tu.NewResource("example.org/v1", "Resource", "resource-name"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + // No composition-resource-name annotation + Build(), + wantIsNew: true, + wantResourceID: "", + wantErr: false, + }, + "GenerateNameMismatch": { + setupResourceClient: func() *tu.MockResourceClient { + mismatchedResource := tu.NewResource("example.org/v1", "TestResource", "different-prefix-abc123"). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build() + + return tu.NewMockResourceClient(). + WithResourceNotFound(). + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + if owner, exists := sel.MatchLabels["crossplane.io/composite"]; exists && owner == "parent-xr" { + return []*un.Unstructured{mismatchedResource}, nil + } + + return []*un.Unstructured{}, nil + }). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: parentXR, + desired: tu.NewResource("example.org/v1", "TestResource", ""). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + WithGenerateName("test-resource-"). + Build(), + wantIsNew: true, // Should be treated as new because generateName prefix doesn't match + wantResourceID: "", + wantErr: false, + }, + "ErrorLookingUpResources": { + setupResourceClient: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithResourceNotFound(). + WithGetResourcesByLabel(func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error) { + return nil, errors.New("error looking up resources") + }). + Build() + }, + defClient: tu.NewMockDefinitionClient().Build(), + composite: parentXR, + desired: tu.NewResource("example.org/v1", "ComposedResource", ""). + WithLabels(map[string]string{ + "crossplane.io/composite": "parent-xr", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + WithGenerateName("test-resource-"). + Build(), + wantIsNew: true, // Fall back to creating a new resource + wantErr: false, // We handle the error gracefully + }, + "ClaimResource_FoundByClaimLabels": { + setupResourceClient: func() *tu.MockResourceClient { + // Create an existing resource with claim labels + existingClaimResource := tu.NewResource("example.org/v1", "ComposedResource", "claim-managed-resource"). + WithSpecField("field", "value"). + WithLabels(map[string]string{ + "crossplane.io/claim-name": testClaimName, + "crossplane.io/claim-namespace": "test-namespace", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build() + + return tu.NewMockResourceClient(). + WithResourceNotFound(). // Direct lookup fails + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, sel metav1.LabelSelector) ([]*un.Unstructured, error) { + // Check if looking up by claim labels + if claimName, exists := sel.MatchLabels["crossplane.io/claim-name"]; exists && claimName == testClaimName { + if claimNS, exists := sel.MatchLabels["crossplane.io/claim-namespace"]; exists && claimNS == "test-namespace" { + return []*un.Unstructured{existingClaimResource}, nil + } + } + + return []*un.Unstructured{}, nil + }). + Build() + }, + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, resource *un.Unstructured) bool { + return resource.GetKind() == testClaimKind + }). + Build(), + composite: tu.NewResource("example.org/v1", testClaimKind, testClaimName). + InNamespace("test-namespace"). + Build(), + desired: tu.NewResource("example.org/v1", "ComposedResource", "claim-managed-resource"). + WithLabels(map[string]string{ + "crossplane.io/claim-name": testClaimName, + "crossplane.io/claim-namespace": "test-namespace", + }). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "resource-a", + }). + Build(), + wantIsNew: false, + wantResourceID: "claim-managed-resource", + wantErr: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create the resource manager + resourceClient := tt.setupResourceClient() + + rm := NewResourceManager(resourceClient, tt.defClient, tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + // Call the method under test + current, isNew, err := rm.FetchCurrentObject(ctx, tt.composite, tt.desired) + + // Check error expectations + if tt.wantErr { + if err == nil { + t.Errorf("FetchCurrentObject() expected error but got none") + } + + return + } + + if err != nil { + t.Fatalf("FetchCurrentObject() unexpected error: %v", err) + } + + // Check if isNew flag matches expectations + if isNew != tt.wantIsNew { + t.Errorf("FetchCurrentObject() isNew = %v, want %v", isNew, tt.wantIsNew) + } + + // For new resources, current should be nil + if isNew && current != nil { + t.Errorf("FetchCurrentObject() returned non-nil current for new resource") + } + + // For existing resources, check the resource ID + if !isNew && tt.wantResourceID != "" { + if current == nil { + t.Fatalf("FetchCurrentObject() returned nil current for existing resource") + } + + if current.GetName() != tt.wantResourceID { + t.Errorf("FetchCurrentObject() current.GetName() = %v, want %v", + current.GetName(), tt.wantResourceID) + } + } + }) + } +} + +func TestDefaultResourceManager_UpdateOwnerRefs(t *testing.T) { + ctx := t.Context() + // Create test resources + parentXR := tu.NewResource("example.org/v1", "XR", "parent-xr").Build() + + const ParentUID = "parent-uid" + parentXR.SetUID(ParentUID) + + tests := map[string]struct { + parent *un.Unstructured + child *un.Unstructured + defClient *tu.MockDefinitionClient + validate func(t *testing.T, child *un.Unstructured) + }{ + "NilParent_NoChange": { + parent: nil, + child: tu.NewResource("example.org/v1", "Child", "child-resource"). + WithOwnerReference("some-api-version", "SomeKind", "some-name", "foobar"). + Build(), + defClient: tu.NewMockDefinitionClient().Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + // Owner refs should be unchanged + ownerRefs := child.GetOwnerReferences() + if len(ownerRefs) != 1 { + t.Fatalf("Expected 1 owner reference, got %d", len(ownerRefs)) + } + // UID should be generated but not parent's UID + if ownerRefs[0].UID == ParentUID { + t.Errorf("UID should not be parent's UID when parent is nil") + } + + if ownerRefs[0].UID == "" { + t.Errorf("UID should not be empty") + } + }, + }, + "MatchingOwnerRef_UpdatedWithParentUID": { + parent: parentXR, + child: tu.NewResource("example.org/v1", "Child", "child-resource"). + WithOwnerReference("XR", "parent-xr", "example.org/v1", ""). + Build(), + defClient: tu.NewMockDefinitionClient().Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + // Owner reference should be updated with parent's UID + ownerRefs := child.GetOwnerReferences() + if len(ownerRefs) != 1 { + t.Fatalf("Expected 1 owner reference, got %d", len(ownerRefs)) + } + + if ownerRefs[0].UID != ParentUID { + t.Errorf("UID = %s, want %s", ownerRefs[0].UID, ParentUID) + } + }, + }, + "NonMatchingOwnerRef_GenerateRandomUID": { + parent: parentXR, + child: tu.NewResource("example.org/v1", "Child", "child-resource"). + WithOwnerReference("other-api-version", "OtherKind", "other-name", ""). + Build(), + defClient: tu.NewMockDefinitionClient().Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + // Owner reference should have a UID, but not parent's UID + ownerRefs := child.GetOwnerReferences() + if len(ownerRefs) != 1 { + t.Fatalf("Expected 1 owner reference, got %d", len(ownerRefs)) + } + + if ownerRefs[0].UID == ParentUID { + t.Errorf("UID should not be parent's UID for non-matching owner ref") + } + + if ownerRefs[0].UID == "" { + t.Errorf("UID should not be empty") + } + }, + }, + "MultipleOwnerRefs_OnlyUpdateMatching": { + parent: parentXR, + child: func() *un.Unstructured { + child := tu.NewResource("example.org/v1", "Child", "child-resource").Build() + + // Add multiple owner references + child.SetOwnerReferences([]metav1.OwnerReference{ + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "parent-xr", + UID: "", // Empty UID should be updated + }, + { + APIVersion: "other.org/v1", + Kind: "OtherKind", + Name: "other-name", + UID: "", // Empty UID should be generated + }, + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "different-parent", + UID: "", // Empty UID should be generated + }, + }) + + return child + }(), + defClient: tu.NewMockDefinitionClient().Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + ownerRefs := child.GetOwnerReferences() + if len(ownerRefs) != 3 { + t.Fatalf("Expected 3 owner references, got %d", len(ownerRefs)) + } + + // Check each owner ref + for _, ref := range ownerRefs { + // All UIDs should be filled + if ref.UID == "" { + t.Errorf("UID should not be empty for any owner reference") + } + + // Only the matching reference should have parent's UID + if ref.APIVersion == "example.org/v1" && ref.Kind == "XR" && ref.Name == "parent-xr" { + if ref.UID != ParentUID { + t.Errorf("Matching owner ref has UID = %s, want %s", ref.UID, ParentUID) + } + } else { + if ref.UID == ParentUID { + t.Errorf("Non-matching owner ref should not have parent's UID") + } + } + } + }, + }, + "ClaimParent_SetsClaimLabels": { + parent: tu.NewResource("example.org/v1", testClaimKind, testClaimName). + InNamespace("test-namespace"). + Build(), + child: tu.NewResource("example.org/v1", "Child", "child-resource").Build(), + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, resource *un.Unstructured) bool { + return resource.GetKind() == testClaimKind + }). + Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + labels := child.GetLabels() + if labels == nil { + t.Fatal("Expected labels to be set") + } + + // Check claim-specific labels + if claimName, exists := labels["crossplane.io/claim-name"]; !exists || claimName != testClaimName { + t.Errorf("Expected crossplane.io/claim-name=%s, got %s", testClaimName, claimName) + } + + if claimNS, exists := labels["crossplane.io/claim-namespace"]; !exists || claimNS != "test-namespace" { + t.Errorf("Expected crossplane.io/claim-namespace=test-namespace, got %s", claimNS) + } + + // Should have composite label pointing to claim for new resources + if composite, exists := labels["crossplane.io/composite"]; !exists || composite != testClaimName { + t.Errorf("Expected crossplane.io/composite=%s, got %s", testClaimName, composite) + } + }, + }, + "ClaimParent_PreservesExistingCompositeLabel": { + parent: tu.NewResource("example.org/v1", testClaimKind, testClaimName). + InNamespace("test-namespace"). + Build(), + child: tu.NewResource("example.org/v1", "Child", "child-resource"). + WithLabels(map[string]string{ + "crossplane.io/composite": "test-claim-82crv", // Existing composite label + }). + Build(), + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, resource *un.Unstructured) bool { + return resource.GetKind() == testClaimKind + }). + Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + labels := child.GetLabels() + if labels == nil { + t.Fatal("Expected labels to be set") + } + + // Check claim-specific labels + if claimName, exists := labels["crossplane.io/claim-name"]; !exists || claimName != testClaimName { + t.Errorf("Expected crossplane.io/claim-name=%s, got %s", testClaimName, claimName) + } + + if claimNS, exists := labels["crossplane.io/claim-namespace"]; !exists || claimNS != "test-namespace" { + t.Errorf("Expected crossplane.io/claim-namespace=test-namespace, got %s", claimNS) + } + + // Should preserve existing composite label pointing to generated XR + if composite, exists := labels["crossplane.io/composite"]; !exists || composite != "test-claim-82crv" { + t.Errorf("Expected crossplane.io/composite=test-claim-82crv (preserved), got %s", composite) + } + }, + }, + "XRParent_SetsCompositeLabel": { + parent: tu.NewResource("example.org/v1", "XR", "test-xr").Build(), + child: tu.NewResource("example.org/v1", "Child", "child-resource").Build(), + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, resource *un.Unstructured) bool { + return resource.GetKind() == testClaimKind + }). + Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + labels := child.GetLabels() + if labels == nil { + t.Fatal("Expected labels to be set") + } + + // Check composite label + if composite, exists := labels["crossplane.io/composite"]; !exists || composite != "test-xr" { + t.Errorf("Expected crossplane.io/composite=test-xr, got %s", composite) + } + + // Should not have claim-specific labels for XRs + if claimName, exists := labels["crossplane.io/claim-name"]; exists { + t.Errorf("Should not have crossplane.io/claim-name label for XR, but got %s", claimName) + } + + if claimNS, exists := labels["crossplane.io/claim-namespace"]; exists { + t.Errorf("Should not have crossplane.io/claim-namespace label for XR, but got %s", claimNS) + } + }, + }, + "ClaimParent_SkipsOwnerRefUpdate": { + parent: func() *un.Unstructured { + u := tu.NewResource("example.org/v1", testClaimKind, testClaimName). + InNamespace("test-namespace"). + Build() + u.SetUID("claim-uid") + + return u + }(), + child: func() *un.Unstructured { + u := tu.NewResource("example.org/v1", "Child", "child-resource").Build() + u.SetOwnerReferences([]metav1.OwnerReference{ + { + APIVersion: "example.org/v1", + Kind: "XTestResource", + Name: "test-claim-82crv", + UID: "", + Controller: new(true), + BlockOwnerDeletion: new(true), + }, + { + APIVersion: "example.org/v1", + Kind: testClaimKind, + Name: testClaimName, + UID: "", + Controller: new(true), + BlockOwnerDeletion: new(true), + }, + }) + + return u + }(), + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, resource *un.Unstructured) bool { + return resource.GetKind() == testClaimKind + }). + Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + // When parent is a Claim, owner references should not be modified + refs := child.GetOwnerReferences() + if len(refs) != 2 { + t.Fatalf("Expected 2 owner references (unchanged), got %d", len(refs)) + } + + // Find the references + var xrRef, claimRef *metav1.OwnerReference + + for i := range refs { + switch refs[i].Kind { + case "XTestResource": + xrRef = &refs[i] + case testClaimKind: + claimRef = &refs[i] + } + } + + if xrRef == nil { + t.Fatal("Expected to find XTestResource owner reference") + } + + if claimRef == nil { + t.Fatal("Expected to find TestClaim owner reference") + } + + // All refs should have UIDs now + if xrRef.UID == "" { + t.Error("Expected XTestResource owner reference to have a UID") + } + + if claimRef.UID == "" { + t.Error("Expected TestClaim owner reference to have a UID") + } + + // The XR should keep Controller: true + if xrRef.Controller == nil || !*xrRef.Controller { + t.Error("Expected XTestResource owner reference to have Controller: true") + } + + // The Claim should have Controller: false + if claimRef.Controller == nil || *claimRef.Controller { + t.Error("Expected TestClaim owner reference to have Controller: false, but it has Controller: true") + } + + // But labels should still be updated + labels := child.GetLabels() + if labels == nil { + t.Fatal("Expected labels to be set") + } + + // Check claim-specific labels + if claimName, exists := labels["crossplane.io/claim-name"]; !exists || claimName != testClaimName { + t.Errorf("Expected crossplane.io/claim-name=%s, got %s", testClaimName, claimName) + } + + if claimNS, exists := labels["crossplane.io/claim-namespace"]; !exists || claimNS != "test-namespace" { + t.Errorf("Expected crossplane.io/claim-namespace=test-namespace, got %s", claimNS) + } + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create the resource manager + rm := NewResourceManager(tu.NewMockResourceClient().Build(), tt.defClient, tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), NewAdvisoryCollector(nil)) + + // Need to create a copy of the child to avoid modifying test data + child := tt.child.DeepCopy() + + // Call the method under test + rm.UpdateOwnerRefs(ctx, tt.parent, child) + + // Validate the results + tt.validate(t, child) + }) + } +} + +func TestDefaultResourceManager_getCompositionResourceName(t *testing.T) { + rm := &DefaultResourceManager{ + logger: tu.TestLogger(t, false), + } + + tests := map[string]struct { + annotations map[string]string + want string + }{ + "StandardAnnotation": { + annotations: map[string]string{ + "crossplane.io/composition-resource-name": "my-resource", + }, + want: "my-resource", + }, + "FunctionSpecificAnnotation": { + annotations: map[string]string{ + "function.crossplane.io/composition-resource-name": "function-resource", + }, + want: "function-resource", + }, + "BothAnnotations_StandardTakesPrecedence": { + annotations: map[string]string{ + "crossplane.io/composition-resource-name": "standard-resource", + "function.crossplane.io/composition-resource-name": "function-resource", + }, + want: "standard-resource", + }, + "NoAnnotations": { + annotations: map[string]string{ + "some-other-annotation": "value", + }, + want: "", + }, + "EmptyAnnotations": { + annotations: map[string]string{}, + want: "", + }, + "NilAnnotations": { + annotations: nil, + want: "", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := rm.getCompositionResourceName(tt.annotations) + if got != tt.want { + t.Errorf("getCompositionResourceName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDefaultResourceManager_hasMatchingResourceName(t *testing.T) { + rm := &DefaultResourceManager{ + logger: tu.TestLogger(t, false), + } + + tests := map[string]struct { + annotations map[string]string + compResourceName string + want bool + }{ + "StandardAnnotationMatches": { + annotations: map[string]string{ + "crossplane.io/composition-resource-name": "my-resource", + }, + compResourceName: "my-resource", + want: true, + }, + "StandardAnnotationDoesNotMatch": { + annotations: map[string]string{ + "crossplane.io/composition-resource-name": "my-resource", + }, + compResourceName: "different-resource", + want: false, + }, + "FunctionSpecificAnnotationMatches": { + annotations: map[string]string{ + "function.crossplane.io/composition-resource-name": "function-resource", + }, + compResourceName: "function-resource", + want: true, + }, + "FunctionSpecificAnnotationDoesNotMatch": { + annotations: map[string]string{ + "function.crossplane.io/composition-resource-name": "function-resource", + }, + compResourceName: "different-resource", + want: false, + }, + "NoAnnotations": { + annotations: map[string]string{ + "some-other-annotation": "value", + }, + compResourceName: "my-resource", + want: false, + }, + "EmptyAnnotations": { + annotations: map[string]string{}, + compResourceName: "my-resource", + want: false, + }, + "NilAnnotations": { + annotations: nil, + compResourceName: "my-resource", + want: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := rm.hasMatchingResourceName(tt.annotations, tt.compResourceName) + if got != tt.want { + t.Errorf("hasMatchingResourceName() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDefaultResourceManager_createResourceID(t *testing.T) { + rm := &DefaultResourceManager{ + logger: tu.TestLogger(t, false), + } + + gvk := schema.GroupVersionKind{ + Group: "example.org", + Version: "v1", + Kind: "TestResource", + } + + tests := map[string]struct { + gvk schema.GroupVersionKind + namespace string + name string + generateName string + want string + }{ + "NamedResourceWithNamespace": { + gvk: gvk, + namespace: "default", + name: "my-resource", + want: "example.org/v1, Kind=TestResource/default/my-resource", + }, + "NamedResourceWithoutNamespace": { + gvk: gvk, + name: "my-resource", + want: "example.org/v1, Kind=TestResource/my-resource", + }, + "GenerateNameWithNamespace": { + gvk: gvk, + namespace: "default", + generateName: "my-resource-", + want: "example.org/v1, Kind=TestResource/default/my-resource-*", + }, + "GenerateNameWithoutNamespace": { + gvk: gvk, + generateName: "my-resource-", + want: "example.org/v1, Kind=TestResource/my-resource-*", + }, + "NoNameOrGenerateName": { + gvk: gvk, + want: "example.org/v1, Kind=TestResource/", + }, + "BothNameAndGenerateName_NameTakesPrecedence": { + gvk: gvk, + namespace: "default", + name: "my-resource", + generateName: "my-resource-", + want: "example.org/v1, Kind=TestResource/default/my-resource", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := rm.createResourceID(tt.gvk, tt.namespace, tt.name, tt.generateName) + if got != tt.want { + t.Errorf("createResourceID() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestDefaultResourceManager_checkCompositeOwnership(t *testing.T) { + // Only the claim kind is a claim; every other composite is an XR. + defClient := tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, resource *un.Unstructured) bool { + return resource.GetKind() == testClaimKind + }). + Build() + + ownedBy := func(composite string) *un.Unstructured { + return tu.NewResource("example.org/v1", "Resource", "my-resource"). + WithLabels(map[string]string{"crossplane.io/composite": composite}). + Build() + } + + claim := func() *tu.ResourceBuilder { + return tu.NewResource("example.org/v1", testClaimKind, testClaimName).InNamespace("test-namespace") + } + + boundClaim := func(backingXR string) *un.Unstructured { + return claim(). + WithSpecField("resourceRef", map[string]any{"apiVersion": "example.org/v1", "kind": "XR", "name": backingXR}). + Build() + } + + takeover := func(currentOwner, newOwner string) map[string]string { + return map[string]string{ + "resource": "Resource/my-resource", + "namespace": "", + "currentOwner": currentOwner, + "newOwner": newOwner, + } + } + + tests := map[string]struct { + reason string + current *un.Unstructured + composite *un.Unstructured + // wantContext is the context of the one ownership warning expected, or nil if none is. + wantContext map[string]string + }{ + "NilComposite": { + reason: "Without a composite there is no new owner to compare against.", + current: ownedBy("other-xr"), + composite: nil, + }, + "NoCompositeLabel": { + reason: "A resource no composite has labelled belongs to none.", + current: tu.NewResource("example.org/v1", "Resource", "my-resource"). + WithLabels(map[string]string{"unrelated": "label"}). + Build(), + composite: tu.NewResource("example.org/v1", "XR", "my-xr").Build(), + }, + "NilLabels": { + reason: "A resource with no labels at all belongs to no composite.", + current: tu.NewResource("example.org/v1", "Resource", "my-resource").Build(), + composite: tu.NewResource("example.org/v1", "XR", "my-xr").Build(), + }, + "MatchingCompositeLabel": { + reason: "An XR's composed resources are labelled with the XR's name.", + current: ownedBy("my-xr"), + composite: tu.NewResource("example.org/v1", "XR", "my-xr").Build(), + }, + "DifferentCompositeLabel": { + reason: "Applying an XR takes over a resource labelled with another XR's name.", + current: ownedBy("other-xr"), + composite: tu.NewResource("example.org/v1", "XR", "my-xr").Build(), + wantContext: takeover("other-xr", "my-xr"), + }, + "XRIsComparedByItsOwnNameEvenWithAResourceRef": { + reason: "Only a claim's spec.resourceRef names its backing XR; an XR is always compared by its own name.", + current: ownedBy("referenced-xr"), + composite: tu.NewResource("example.org/v1", "XR", "my-xr"). + WithSpecField("resourceRef", map[string]any{"apiVersion": "example.org/v1", "kind": "XR", "name": "referenced-xr"}). + Build(), + wantContext: takeover("referenced-xr", "my-xr"), + }, + "ClaimLabelMatchesBackingXR": { + reason: "Crossplane labels a claim's composed resources with its backing XR's name, never the claim's (#534).", + current: ownedBy("test-claim-abc12"), + composite: boundClaim("test-claim-abc12"), + }, + "ClaimLabelNamesAnotherXR": { + reason: "Applying a claim takes over a resource labelled with an XR other than the claim's backing XR.", + current: ownedBy("other-xr"), + composite: boundClaim("test-claim-abc12"), + wantContext: takeover("other-xr", "test-claim-abc12"), + }, + "BoundClaimLabelNamesTheClaimItself": { + reason: "A bound claim's composed resources carry its backing XR's name, so the claim's own name is another composite's.", + current: ownedBy(testClaimName), + composite: boundClaim("test-claim-abc12"), + wantContext: takeover(testClaimName, "test-claim-abc12"), + }, + "UnboundClaimLabelMatchesClaimName": { + reason: "A claim with no resourceRef is bound to a backing XR named after the claim, so that is its composed resources' label.", + current: ownedBy(testClaimName), + composite: claim().Build(), + }, + "UnboundClaimLabelNamesAnotherXR": { + reason: "Applying a claim with no resourceRef takes over a resource labelled with any XR not named after the claim.", + current: ownedBy("other-xr"), + composite: claim().Build(), + wantContext: takeover("other-xr", testClaimName), + }, + "ClaimWithIncompleteResourceRef": { + reason: "A resourceRef with no name does not bind the claim yet, so its backing XR is named after the claim.", + current: ownedBy(testClaimName), + composite: claim(). + WithSpecField("resourceRef", map[string]any{"apiVersion": "example.org/v1", "kind": "XR"}). + Build(), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var stderr bytes.Buffer + + advisories := NewAdvisoryCollector(&stderr) + + rm := &DefaultResourceManager{ + defClient: defClient, + logger: tu.TestLogger(t, false), + advisories: advisories, + } + + rm.checkCompositeOwnership(t.Context(), tt.current, tt.composite) + + got := advisories.Advisories() + + if tt.wantContext == nil { + if len(got) != 0 { + t.Errorf("%s\nexpected no ownership warning, got %v", tt.reason, got) + } + + return + } + + if len(got) != 1 { + t.Fatalf("%s\nexpected exactly one ownership warning, got %v", tt.reason, got) + } + + // The message must name the consequence, not just the condition: a user who sees this needs + // to know that applying the diff takes the resource over. + if !strings.Contains(got[0].Message, "assume ownership") { + t.Errorf("warning should say applying assumes ownership, got %q", got[0].Message) + } + + // The old and new owners are the load-bearing detail — without them a user cannot tell + // which composite they are about to take the resource from. + if diff := gcmp.Diff(tt.wantContext, got[0].Context); diff != "" { + t.Errorf("%s\nwarning context mismatch (-want +got):\n%s", tt.reason, diff) + } + + if !strings.Contains(stderr.String(), "WARNING: ") { + t.Errorf("expected the warning on stderr, got %q", stderr.String()) + } + }) + } +} + +func TestDefaultResourceManager_updateCompositeOwnerLabel_EdgeCases(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + parent *un.Unstructured + child *un.Unstructured + defClient *tu.MockDefinitionClient + validate func(t *testing.T, child *un.Unstructured) + }{ + "ParentWithOnlyGenerateName": { + parent: tu.NewResource("example.org/v1", "XR", ""). + WithGenerateName("my-xr-"). + Build(), + child: tu.NewResource("example.org/v1", "Child", "child-resource"). + Build(), + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, _ *un.Unstructured) bool { + return false + }). + Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + labels := child.GetLabels() + if labels == nil { + t.Fatal("Expected labels to be set") + } + + if composite, exists := labels["crossplane.io/composite"]; !exists || composite != "my-xr-" { + t.Errorf("Expected crossplane.io/composite=my-xr-, got %s", composite) + } + }, + }, + "ParentWithNoNameOrGenerateName": { + parent: tu.NewResource("example.org/v1", "XR", ""). + Build(), + child: tu.NewResource("example.org/v1", "Child", "child-resource"). + Build(), + defClient: tu.NewMockDefinitionClient(). + WithIsClaimResource(func(_ context.Context, _ *un.Unstructured) bool { + return false + }). + Build(), + validate: func(t *testing.T, child *un.Unstructured) { + t.Helper() + + labels := child.GetLabels() + // Should not set any composite label + if labels != nil { + if _, exists := labels["crossplane.io/composite"]; exists { + t.Error("Should not set composite label when parent has no name") + } + } + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + rm := &DefaultResourceManager{ + defClient: tt.defClient, + logger: tu.TestLogger(t, false), + } + + child := tt.child.DeepCopy() + rm.updateCompositeOwnerLabel(ctx, tt.parent, child) + tt.validate(t, child) + }) + } +} + +func TestDefaultResourceManager_FetchObservedResources(t *testing.T) { + ctx := t.Context() + + // Create test XR + testXR := tu.NewResource("example.org/v1", "XR", "test-xr"). + WithSpecField("field", "value"). + Build() + + // Create composed resources with composition-resource-name annotation + composedResource1 := tu.NewResource("example.org/v1", "ManagedResource", "resource-1"). + WithSpecField("field", "value1"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "db-instance", + }). + Build() + + composedResource2 := tu.NewResource("example.org/v1", "ManagedResource", "resource-2"). + WithSpecField("field", "value2"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "db-subnet", + }). + Build() + + // Create a nested XR (child XR with composition-resource-name annotation) + nestedXR := tu.NewResource("example.org/v1", "ChildXR", "nested-xr"). + WithSpecField("nested", "value"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "child-xr", + }). + Build() + + // Create composed resources under the nested XR + nestedComposedResource := tu.NewResource("example.org/v1", "NestedResource", "nested-resource-1"). + WithSpecField("field", "nested-value"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "nested-db", + }). + Build() + + // Named causes, so each failing case asserts that the error it got wraps exactly this one. + errTreeFetch := errors.New("failed to get tree") + errChildForbidden := apierrors.NewForbidden(schema.GroupResource{Group: "example.org", Resource: "managedresources"}, "resource-denied", errors.New("RBAC: access denied")) + errTransport := errors.New("connection reset by peer") + errRootNotFound := apierrors.NewNotFound(schema.GroupResource{Group: "example.org", Resource: "xrs"}, "test-xr") + + // Create a resource without the composition-resource-name annotation (should be filtered out) + resourceWithoutAnnotation := tu.NewResource("example.org/v1", "OtherResource", "other-resource"). + WithSpecField("field", "other"). + Build() + + tests := map[string]struct { + setupTreeClient func() *tu.MockResourceTreeClient + xr *un.Unstructured + wantCount int + wantResourceIDs []string // Names of resources we expect to find + wantWarnings []dt.OutputWarning + wantErr error // matched with errors.Is, so a wrapped cause matches + }{ + "SuccessfullyFetchesFlatComposedResources": { + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithResourceTreeFromXRAndComposed(testXR, []*un.Unstructured{ + composedResource1, + composedResource2, + }). + Build() + }, + xr: testXR, + wantCount: 2, + wantResourceIDs: []string{"resource-1", "resource-2"}, + }, + "SuccessfullyFetchesNestedResources": { + setupTreeClient: func() *tu.MockResourceTreeClient { + // Build a tree with nested structure: + // XR + // -> composedResource1 + // -> nestedXR + // -> nestedComposedResource + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(composedResource1), + tu.NewTreeNode(nestedXR).WithChildren(tu.NewTreeNode(nestedComposedResource)), + ).Build()). + Build() + }, + xr: testXR, + wantCount: 3, // composedResource1, nestedXR, nestedComposedResource + wantResourceIDs: []string{"resource-1", "nested-xr", "nested-resource-1"}, + }, + "FiltersOutResourcesWithoutAnnotation": { + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(composedResource1), + // This resource lacks the annotation and should be filtered out + tu.NewTreeNode(resourceWithoutAnnotation), + tu.NewTreeNode(composedResource2), + ).Build()). + Build() + }, + xr: testXR, + wantCount: 2, // Only composedResource1 and composedResource2 + wantResourceIDs: []string{"resource-1", "resource-2"}, + }, + "ReturnsEmptySliceWhenNoComposedResources": { + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build() + }, + xr: testXR, + wantCount: 0, + wantResourceIDs: []string{}, + }, + "ReturnsEmptySliceWhenOnlyRootXRInTree": { + setupTreeClient: func() *tu.MockResourceTreeClient { + // Tree with only root (XR itself has no composition-resource-name) + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).Build()). + Build() + }, + xr: testXR, + wantCount: 0, + wantResourceIDs: []string{}, + }, + "ReturnsErrorWhenTreeClientFails": { + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithGetResourceTree(func(context.Context, *un.Unstructured) (*resource.Resource, error) { + return nil, errTreeFetch + }). + Build() + }, + xr: testXR, + wantErr: errTreeFetch, + }, + "FiltersGrandchildrenControlledByNestedXR": { + // Regression for the "has a controller ref but is not controlled by + // the XR" render error (issue #399). A grandchild controlled by a + // nested XR (different UID than the top XR) must NOT be returned as + // an observed resource for the top-level XR's render, because the + // crossplane render binary (>= v2.3.4) rejects observed resources + // whose controller-ref UID does not match the XR being rendered. + setupTreeClient: func() *tu.MockResourceTreeClient { + // XR (uid xr-uid) + // -> composedResource1 (controlled by XR) + // -> nestedXR (controlled by XR, uid nested-uid) + // -> grandchild (controlled by nestedXR) <- must be dropped + topXR := tu.NewResource("example.org/v1", "XR", "test-xr"). + WithSpecField("field", "value"). + WithUID("xr-uid"). + Build() + + directChild := tu.NewResource("example.org/v1", "ManagedResource", "resource-1"). + WithSpecField("field", "value1"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "db-instance", + }). + WithControllerReference("XR", "test-xr", "example.org/v1", "xr-uid"). + Build() + + nestedXRWithUID := tu.NewResource("example.org/v1", "ChildXR", "nested-xr"). + WithSpecField("nested", "value"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "child-xr", + }). + WithControllerReference("XR", "test-xr", "example.org/v1", "xr-uid"). + WithUID("nested-uid"). + Build() + + grandchild := tu.NewResource("example.org/v1", "NestedResource", "grandchild-1"). + WithSpecField("field", "nested-value"). + WithAnnotations(map[string]string{ + "crossplane.io/composition-resource-name": "nested-db", + }). + WithControllerReference("ChildXR", "nested-xr", "example.org/v1", "nested-uid"). + Build() + + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(topXR).WithChildren( + tu.NewTreeNode(directChild), + tu.NewTreeNode(nestedXRWithUID).WithChildren(tu.NewTreeNode(grandchild)), + ).Build()). + Build() + }, + xr: tu.NewResource("example.org/v1", "XR", "test-xr"). + WithSpecField("field", "value"). + WithUID("xr-uid"). + Build(), + wantCount: 2, // directChild + nestedXR; grandchild filtered out + wantResourceIDs: []string{"resource-1", "nested-xr"}, + }, + "HandlesDeepNestedStructure": { + setupTreeClient: func() *tu.MockResourceTreeClient { + // Build a deeply nested tree: + // XR + // -> composedResource1 + // -> nestedXR + // -> nestedComposedResource + // -> composedResource2 + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(composedResource1).WithChildren( + tu.NewTreeNode(nestedXR).WithChildren( + tu.NewTreeNode(nestedComposedResource).WithChildren( + tu.NewTreeNode(composedResource2), + ), + ), + ), + ).Build()). + Build() + }, + xr: testXR, + wantCount: 4, // All 4 resources have the annotation + wantResourceIDs: []string{"resource-1", "nested-xr", "nested-resource-1", "resource-2"}, + }, + // Upstream's tree client records a per-node fetch failure on the node itself and always + // returns the tree with a nil error. A failed node carries only the GVK, name and namespace + // it was referenced by, so it has no composition-resource-name annotation and would otherwise + // be indistinguishable from a resource that is legitimately not composed (issue #495). + "SkipsChildNotFoundWithWarning": { + // A child deleted out of band since the XR last recorded it is genuinely absent: omitting it + // from the observed set is the right answer, but the user is told. + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(composedResource1), + tu.NewTreeNode(tu.NewResource("example.org/v1", "ManagedResource", "resource-gone").InNamespace("ns").Build()). + WithError(apierrors.NewNotFound(schema.GroupResource{Group: "example.org", Resource: "managedresources"}, "resource-gone")), + ).Build()). + Build() + }, + xr: testXR, + wantCount: 1, + wantResourceIDs: []string{"resource-1"}, + wantWarnings: []dt.OutputWarning{{ + Message: treeNodeNotFoundWarning, + Context: map[string]string{"resource": "ManagedResource/resource-gone", "namespace": "ns"}, + }}, + }, + "SkipsWrappedChildNotFoundUnderNestedXR": { + // NotFound is recognised through wrapping, and anywhere in the tree, not only at depth one. + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(nestedXR).WithChildren( + tu.NewTreeNode(tu.NewResource("example.org/v1", "NestedResource", "nested-gone").Build()). + WithError(errors.Wrap( + apierrors.NewNotFound(schema.GroupResource{Group: "example.org", Resource: "nestedresources"}, "nested-gone"), + "cannot get resource")), + ), + ).Build()). + Build() + }, + xr: testXR, + wantCount: 1, + wantResourceIDs: []string{"nested-xr"}, + wantWarnings: []dt.OutputWarning{{ + Message: treeNodeNotFoundWarning, + Context: map[string]string{"resource": "NestedResource/nested-gone", "namespace": ""}, + }}, + }, + "FailsOnChildForbidden": { + // The resource may well exist; we just cannot see it. Continuing would report it as an + // addition, so the diff for this XR must fail instead. + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(composedResource1), + tu.NewTreeNode(tu.NewResource("example.org/v1", "ManagedResource", "resource-denied").InNamespace("ns").Build()). + WithError(errChildForbidden), + ).Build()). + Build() + }, + xr: testXR, + wantErr: errChildForbidden, + }, + "FailsOnGrandchildTransportError": { + // Any error other than NotFound is fatal, at any depth. + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR).WithChildren( + tu.NewTreeNode(nestedXR).WithChildren( + tu.NewTreeNode(tu.NewResource("example.org/v1", "NestedResource", "nested-unreachable").Build()). + WithError(errTransport), + ), + ).Build()). + Build() + }, + xr: testXR, + wantErr: errTransport, + }, + "FailsOnRootError": { + // The root is the XR handed to the tree client, never re-fetched by it, so an error there + // means the whole tree is unusable. Even a NotFound is fatal: skipping the root would mean + // an empty observed set, which is the silent wrong answer this check exists to prevent. + setupTreeClient: func() *tu.MockResourceTreeClient { + return tu.NewMockResourceTreeClient(). + WithSuccessfulResourceTreeFetch(tu.NewTreeNode(testXR). + WithError(errRootNotFound). + WithChildren(tu.NewTreeNode(composedResource1)). + Build()). + Build() + }, + xr: testXR, + wantErr: errRootNotFound, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Use the constructor and interface type to test via the public API + advisories := NewAdvisoryCollector(nil) + rm := NewResourceManager( + nil, + nil, + tt.setupTreeClient(), + tu.TestLogger(t, false), advisories) + + observed, err := rm.FetchObservedResources(ctx, &cmp.Unstructured{Unstructured: *tt.xr}) + + if diff := gcmp.Diff(tt.wantWarnings, advisories.Advisories()); diff != "" { + t.Errorf("FetchObservedResources() warnings mismatch (-want +got):\n%s", diff) + } + + if diff := gcmp.Diff(tt.wantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("FetchObservedResources() error mismatch (-want +got):\n%s", diff) + } + + if err != nil { + return + } + + if len(observed) != tt.wantCount { + t.Errorf("FetchObservedResources() got %d resources, want %d", len(observed), tt.wantCount) + } + + // Verify we got the expected resources + if len(tt.wantResourceIDs) > 0 { + // Extract resource IDs from observed resources + var gotResourceIDs []string + for _, res := range observed { + gotResourceIDs = append(gotResourceIDs, res.GetName()) + } + + sort.Strings(gotResourceIDs) + + wantResourceIDs := append([]string{}, tt.wantResourceIDs...) + sort.Strings(wantResourceIDs) + + if diff := gcmp.Diff(wantResourceIDs, gotResourceIDs); diff != "" { + t.Errorf("FetchObservedResources() resource IDs mismatch (-want +got):\n%s", diff) + } + + // Verify all resources have the composition-resource-name annotation + for _, res := range observed { + if _, hasAnno := res.GetAnnotations()["crossplane.io/composition-resource-name"]; !hasAnno { + t.Errorf("FetchObservedResources() returned resource %s without composition-resource-name annotation", res.GetName()) + } + } + } + }) + } +} diff --git a/internal/diff/diffprocessor/schema_validator.go b/internal/diff/diffprocessor/schema_validator.go new file mode 100644 index 00000000..f32c6c13 --- /dev/null +++ b/internal/diff/diffprocessor/schema_validator.go @@ -0,0 +1,479 @@ +package diffprocessor + +import ( + "context" + "fmt" + "regexp" + "strings" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" +) + +// SchemaValidator handles validation of resources against CRD schemas. +type SchemaValidator interface { + // ValidateResources validates resources using schema validation + ValidateResources(ctx context.Context, xr *un.Unstructured, composed []cpd.Unstructured) error + + // EnsureComposedResourceCRDs ensures we have all required CRDs for validation + EnsureComposedResourceCRDs(ctx context.Context, resources []*un.Unstructured) error + + // ValidateScopeConstraints validates that a resource has the appropriate namespace for its scope + // and that namespaced resources match the expected namespace (no cross-namespace refs) + ValidateScopeConstraints(ctx context.Context, resource *un.Unstructured, expectedNamespace string, isClaimRoot bool) error +} + +// DefaultSchemaValidator implements SchemaValidator interface. +type DefaultSchemaValidator struct { + defClient xp.DefinitionClient + schemaClient k8.SchemaClient + resourceClient k8.ResourceClient + logger logging.Logger +} + +// NewSchemaValidator creates a new DefaultSchemaValidator. +func NewSchemaValidator(sClient k8.SchemaClient, rClient k8.ResourceClient, dClient xp.DefinitionClient, logger logging.Logger) SchemaValidator { + return &DefaultSchemaValidator{ + defClient: dClient, + schemaClient: sClient, + resourceClient: rClient, + logger: logger, + } +} + +// LoadCRDs loads CRDs from the cluster. +func (v *DefaultSchemaValidator) LoadCRDs(ctx context.Context) error { + v.logger.Debug("Loading CRDs from cluster") + + // Get XRDs from the client (which will use its cache when available) + xrds, err := v.defClient.GetXRDs(ctx) + if err != nil { + v.logger.Debug("Failed to get XRDs", "error", err) + return errors.Wrap(err, "cannot get XRDs") + } + + // Use SchemaClient to load CRDs from XRDs + err = v.schemaClient.LoadCRDsFromXRDs(ctx, xrds) + if err != nil { + v.logger.Debug("Failed to load CRDs into schema client", "error", err) + return errors.Wrap(err, "cannot load CRDs into schema client") + } + + // Logging is handled internally by the schema client + + return nil +} + +// GetCRDs returns the current CRDs. +func (v *DefaultSchemaValidator) GetCRDs() []*extv1.CustomResourceDefinition { + return v.schemaClient.GetAllCRDs() +} + +// ValidateResources validates resources using schema validation. +func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.Unstructured, composed []cpd.Unstructured) error { + v.logger.Debug("Validating resources", + "xr", fmt.Sprintf("%s/%s", xr.GetKind(), xr.GetName()), + "namespace", xr.GetNamespace(), + "composedCount", len(composed)) + + // Collect all resources that need to be validated. Real cluster CRDs + // derived from XRDs declare spec.crossplane (Crossplane's CRD generator + // emits the subtree), so the v2-style XR + the composed resources we + // hand to SchemaValidation should pass strict validation against those + // CRDs unmodified. Our integration tests generate their XR CRDs from + // the XRD fixtures the same way (crossplane-runtime pkg/xcrd), so no + // preprocessing is needed here. + resources := make([]*un.Unstructured, 0, len(composed)+1) + + resources = append(resources, xr) + for i := range composed { + resources = append(resources, &un.Unstructured{Object: composed[i].UnstructuredContent()}) + } + + // Ensure we have all the required CRDs + v.logger.Debug("Ensuring required CRDs for validation", + "cachedCRDs", len(v.schemaClient.GetAllCRDs()), + "resourceCount", len(resources)) + + err := v.EnsureComposedResourceCRDs(ctx, resources) + if err != nil { + return errors.Wrap(err, "unable to ensure CRDs") + } + + // SchemaValidate is the structured-result API: it returns a + // *ValidationResult that callers inspect directly. + // + // Validation is read-only. SchemaValidate applies CRD defaults to its + // own deep copy of each resource before validating, so a field that is + // both required and defaulted validates without the caller's objects + // being defaulted. They must not be: the diff calculator sends them to + // the apiserver as server-side apply payloads, where every field present + // claims ownership, so a locally defaulted field would take over a + // value another manager set (#503). The apiserver applies defaults to + // what it is sent; the lenient Defaulter predicts them for additions + // that are never sent. + // + // Every resource is passed through, including built-in Kubernetes types + // that have no CRD. SchemaValidate validates those against its embedded + // Kubernetes scheme rather than reporting them as missing a schema, so + // filtering them out here would only discard real coverage — a + // ConfigMap with a misspelled field would validate clean. + v.logger.Debug("Performing schema validation", "resourceCount", len(resources)) + + result, err := pkgvalidate.SchemaValidate(ctx, resources, v.schemaClient.GetAllCRDs()) + if err != nil { + // SchemaValidate's error return is reserved for setup + // failures (e.g. a CRD that can't be compiled into a + // validator). Wrap it as a SchemaValidationError so + // IsSchemaValidationError / DetermineExitCode classify it the + // same way the pre-refactor SchemaValidation entry point + // did — exit code 2 (schema validation), not 1 (tool error). + // No Result is attached because the validator never produced + // one. + return NewSchemaValidationError("", "schema validation setup failed", err) + } + + v.logResultDetails(result) + + if rerr := pkgvalidate.ResultError(result, true); rerr != nil { + return NewSchemaValidationError("", formatValidationErrors(result), rerr).WithResult(result) + } + + // Additionally validate resource scope constraints (namespace requirements and cross-namespace refs) + expectedNamespace := xr.GetNamespace() + isClaimRoot := v.defClient.IsClaimResource(ctx, xr) + v.logger.Debug("Performing resource scope validation", "resourceCount", len(resources), "expectedNamespace", expectedNamespace, "isClaimRoot", isClaimRoot) + + for _, resource := range resources { + err := v.ValidateScopeConstraints(ctx, resource, expectedNamespace, isClaimRoot) + if err != nil { + resourceID := fmt.Sprintf("%s/%s", resource.GetKind(), resource.GetName()) + return NewSchemaValidationError(resourceID, "resource scope validation failed", err) + } + } + + v.logger.Debug("Resources validated successfully") + + return nil +} + +// EnsureComposedResourceCRDs checks if we have all the CRDs needed for the cpd resources +// and fetches any missing ones from the cluster. +func (v *DefaultSchemaValidator) EnsureComposedResourceCRDs(ctx context.Context, resources []*un.Unstructured) error { + v.logger.Debug("Ensuring required CRDs for validation", "resourceCount", len(resources)) + + // Collect unique GVKs from resources + uniqueGVKs := make(map[schema.GroupVersionKind]bool) + + for _, res := range resources { + gvk := res.GroupVersionKind() + uniqueGVKs[gvk] = true + } + + // Try to fetch each required CRD - GetCRD will use cache if already present + var missingCRDs []string + + for gvk := range uniqueGVKs { + // Skip resources that don't require CRDs + if !v.schemaClient.IsCRDRequired(ctx, gvk) { + v.logger.Debug("Skipping built-in resource type, no CRD required", + "gvk", gvk.String()) + + continue + } + + // Try to get the CRD using the client's GetCRD method (which will cache it) + _, err := v.schemaClient.GetCRD(ctx, gvk) + if err != nil { + v.logger.Debug("CRD not found", + "gvk", gvk.String(), + "error", err) + missingCRDs = append(missingCRDs, gvk.String()) + } + } + + // If any CRDs are missing, fail + if len(missingCRDs) > 0 { + return errors.Errorf("unable to find CRDs for: %v", missingCRDs) + } + + v.logger.Debug("Finished ensuring CRDs") + + return nil +} + +// resolveResourceScope reports whether gvk is namespaced or cluster-scoped. +// +// Discovery is consulted first because it knows every kind the API server +// serves, including built-in types like Secret and Namespace that have no CRD +// and so cannot be resolved from one. The CRD is the fallback for when +// discovery can't answer but the kind is a custom resource whose CRD we can +// still read. +// +// If neither source can answer, both failures are reported: discovery failing +// for a reason unrelated to the kind — connectivity, RBAC — is worth +// surfacing rather than leaving it hidden behind the CRD error it causes. +func resolveResourceScope(ctx context.Context, rc k8.ResourceClient, sc k8.SchemaClient, logger logging.Logger, gvk schema.GroupVersionKind) (extv1.ResourceScope, error) { + logger.Debug("Getting resource scope", "gvk", gvk.String()) + + isNamespaced, discoveryErr := rc.IsNamespacedResource(ctx, gvk) + if discoveryErr == nil { + scope := extv1.ClusterScoped + if isNamespaced { + scope = extv1.NamespaceScoped + } + + logger.Debug("Retrieved scope from discovery", "gvk", gvk.String(), "scope", string(scope)) + + return scope, nil + } + + logger.Debug("Failed to get resource scope from discovery; falling back to CRD lookup", "gvk", gvk.String(), "error", discoveryErr) + + crd, err := sc.GetCRD(ctx, gvk) + if err != nil { + logger.Debug("Failed to get CRD for scope lookup", "gvk", gvk.String(), "error", err) + return "", errors.Wrapf(err, "cannot get CRD for %s to determine scope (discovery also failed: %v)", gvk.String(), discoveryErr) + } + + logger.Debug("Retrieved scope from CRD", "gvk", gvk.String(), "scope", string(crd.Spec.Scope)) + + return crd.Spec.Scope, nil +} + +// ValidateScopeConstraints validates that a resource has the appropriate namespace for its scope +// and that namespaced resources match the expected namespace (no cross-namespace refs). +func (v *DefaultSchemaValidator) ValidateScopeConstraints(ctx context.Context, resource *un.Unstructured, expectedNamespace string, isClaimRoot bool) error { + gvk := resource.GroupVersionKind() + resourceID := fmt.Sprintf("%s/%s", gvk.Kind, resource.GetName()) + + scope, err := resolveResourceScope(ctx, v.resourceClient, v.schemaClient, v.logger, gvk) + if err != nil { + return errors.Wrapf(err, "cannot determine scope for %s", resourceID) + } + + resourceNamespace := resource.GetNamespace() + + switch scope { + case extv1.NamespaceScoped: + if resourceNamespace == "" { + return errors.Errorf("namespaced resource %s must have a namespace", resourceID) + } + + if expectedNamespace != "" && resourceNamespace != expectedNamespace { + return errors.Errorf("namespaced resource %s has namespace %s but expected %s (cross-namespace references not supported)", + resourceID, resourceNamespace, expectedNamespace) + } + case extv1.ClusterScoped: + if resourceNamespace != "" { + return errors.Errorf("cluster-scoped resource %s cannot have a namespace", resourceID) + } + + if expectedNamespace != "" && !isClaimRoot { + return errors.Errorf("namespaced XR cannot own cluster-scoped managed resource %s", resourceID) + } + // Claims are allowed to create cluster-scoped managed resources even if the claim is namespaced + if expectedNamespace != "" && isClaimRoot { + v.logger.Debug("Allowing namespaced claim to create cluster-scoped managed resource", + "resource", resourceID, "namespace", resourceNamespace, "claimNamespace", expectedNamespace) + } + default: + v.logger.Debug("Unknown resource scope", "resource", resourceID, "namespace", resourceNamespace, "scope", string(scope)) + } + + return nil +} + +// logResultDetails emits per-resource debug logging for a validation +// result. This replaces the incidental logging that the previous +// stdout-capturing implementation produced via a loggerwriter, and +// surfaces operational signals (defaulting failures, missing schemas) +// even when ResultError reports success overall. +func (v *DefaultSchemaValidator) logResultDetails(result *pkgvalidate.ValidationResult) { + for _, r := range result.Resources { + v.logger.Debug("validation result", + "apiVersion", r.APIVersion, + "kind", r.Kind, + "name", r.Name, + "status", string(r.Status), + "errors", len(r.Errors), + ) + + for _, e := range r.Errors { + v.logger.Debug("validation error", + "apiVersion", r.APIVersion, + "kind", r.Kind, + "name", r.Name, + "type", string(e.Type), + "field", e.Field, + "message", e.Message, + ) + } + } +} + +// formatValidationErrors produces a multi-line, per-resource breakdown +// of the failures in a *ValidationResult. Each resource that has +// something to report contributes a header line " :" +// followed by one indented line per FieldValidationError, with the +// error type appended in brackets and the structured Value rendered as +// "(got )" when it isn't already quoted into the message text. +// Missing-schema resources collapse to a single " : missing +// schema" line. +// +// Callers invoke this only after pkgvalidate.ResultError has already +// returned a non-nil error, so an empty result means "no per-resource +// detail to report" — we return "" and let the wrapping +// SchemaValidationError carry the underlying ResultError message. +func formatValidationErrors(result *pkgvalidate.ValidationResult) string { + var blocks []string + + for _, r := range result.Resources { + switch r.Status { + case pkgvalidate.ValidationStatusMissingSchema: + blocks = append(blocks, formatMissingSchemaBlock(r)) + case pkgvalidate.ValidationStatusInvalid: + blocks = append(blocks, formatInvalidBlock(r)) + case pkgvalidate.ValidationStatusValid, + pkgvalidate.ValidationStatusDefaultingFailed: + // Valid: nothing to report. DefaultingFailed-only resources + // are reported by ResultError as success, so this function + // never sees them via the failure path; the cases are here + // only to keep the switch exhaustive. + } + } + + return strings.Join(blocks, "\n") +} + +// resourceHeader formats the per-resource header used by both the +// missing-schema and invalid blocks. Cluster-scoped resources omit the +// namespace prefix; namespaced resources produce "/". A +// resource without metadata.name collapses to just the GVK regardless +// of namespace — the namespace alone isn't a useful identifier for a +// nameless resource and emitting "/" would leave a stray trailing +// slash. +func resourceHeader(r pkgvalidate.ResourceValidationResult) string { + if r.Name == "" { + return fmt.Sprintf("%s/%s", r.APIVersion, r.Kind) + } + + name := r.Name + if r.Namespace != "" { + name = r.Namespace + "/" + r.Name + } + + return fmt.Sprintf("%s/%s %s", r.APIVersion, r.Kind, name) +} + +// formatMissingSchemaBlock renders a single line for a resource whose +// CRD/XRD wasn't found. The resource was never validated, so there are +// no per-error lines to indent under it. +func formatMissingSchemaBlock(r pkgvalidate.ResourceValidationResult) string { + return resourceHeader(r) + ": missing schema" +} + +// formatInvalidBlock renders a header line plus one indented line per +// surfaced error. +// +// Defaulting entries are suppressed only when an actionable (schema / +// CEL / unknown-field) error is present on the same resource: those +// already convey the failure and the defaulting line would just be +// noise. Upstream's statusFromErrors today guarantees an Invalid +// resource has at least one non-defaulting error, but if a future +// change ever produced an Invalid resource whose Errors are all +// defaulting we'd rather emit them than silently drop everything. +func formatInvalidBlock(r pkgvalidate.ResourceValidationResult) string { + hasActionable := false + + for _, e := range r.Errors { + if e.Type != pkgvalidate.FieldErrorTypeDefaulting { + hasActionable = true + break + } + } + + lines := []string{resourceHeader(r) + ":"} + + for _, e := range r.Errors { + if hasActionable && e.Type == pkgvalidate.FieldErrorTypeDefaulting { + continue + } + + lines = append(lines, " "+formatErrorLine(e)) + } + + return strings.Join(lines, "\n") +} + +// formatErrorLine renders one FieldValidationError as +// "[ (got )] []". The bad value tail is omitted +// when Value is nil or already present in the message. Duplication +// detection is type-aware to avoid false positives: +// +// - String values are checked in their %q (quoted) form, matching +// how k8s validators emit them (`Invalid value: "five"`); the +// surrounding quotes give the search natural delimiters so +// Value="k" against message "spec.kind: Required" doesn't +// suppress the tail just because "k" sits inside "kind". +// +// - Non-string values use a word-boundary regex so Value=42 against +// message "...Invalid value: 420..." doesn't suppress the tail +// just because "42" is a prefix of "420". +func formatErrorLine(e pkgvalidate.FieldValidationError) string { + msg := e.Message + if rendered := renderBadValue(e.Value); rendered != "" && !valueAlreadyInMessage(msg, e.Value, rendered) { + msg = fmt.Sprintf("%s (got %s)", msg, rendered) + } + + return fmt.Sprintf("%s [%s]", msg, e.Type) +} + +// valueAlreadyInMessage reports whether the rendered form of a bad +// value is already present in the validator's message text. The check +// shape depends on the value's Go type — see formatErrorLine's +// comment for the rationale. +func valueAlreadyInMessage(msg string, value any, rendered string) bool { + if _, ok := value.(string); ok { + // rendered already includes surrounding %q quotes; substring + // search is delimiter-safe. + return strings.Contains(msg, rendered) + } + + // For non-strings rendered is unquoted, so anchor the search at + // word boundaries to avoid 42 matching inside 420. + return regexp.MustCompile(`\b` + regexp.QuoteMeta(rendered) + `\b`).MatchString(msg) +} + +// renderBadValue formats a FieldValidationError.Value for display. +// Returns the empty string for a nil Value so callers can use it as a +// presence check. +// +// Strings get %q (quoted), matching how k8s validation messages embed +// them (`Invalid value: "five"`). Other types use %v: numbers and +// booleans read naturally unquoted, and structs render via their +// default Go representation. +// +// Duplication detection against the validator's message text is done +// separately by valueAlreadyInMessage, which knows how to anchor the +// search at the right boundaries for each value type. +func renderBadValue(value any) string { + if value == nil { + return "" + } + + if s, ok := value.(string); ok { + return fmt.Sprintf("%q", s) + } + + return fmt.Sprintf("%v", value) +} diff --git a/internal/diff/diffprocessor/schema_validator_test.go b/internal/diff/diffprocessor/schema_validator_test.go new file mode 100644 index 00000000..36537dca --- /dev/null +++ b/internal/diff/diffprocessor/schema_validator_test.go @@ -0,0 +1,1056 @@ +package diffprocessor + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" +) + +var _ SchemaValidator = (*tu.MockSchemaValidator)(nil) + +const ( + testExampleOrg = "example.org" + testCpdOrg = "cpd.org" +) + +func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { + ctx := t.Context() + + // Create a sample XR and cpd resources for validation + xr := tu.NewResource(testExampleOrg+"/v1", "XR", "test-xr"). + InNamespace("default"). + WithSpecField("field", "value"). + Build() + + composedResource1 := tu.NewResource(testCpdOrg+"/v1", "testComposedResource", "resource1"). + InNamespace("default"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource1"). + WithSpecField("field", "value"). + BuildUComposed() + + composedResource2 := tu.NewResource(testCpdOrg+"/v1", "testComposedResource", "resource2"). + InNamespace("default"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource2"). + WithSpecField("field", "value"). + BuildUComposed() + + // Built-in Kubernetes types have no CRD; SchemaValidate validates them + // against its embedded scheme instead. The second fixture misspells + // "data" so validation has something to catch. + validConfigMap := tu.NewResource("v1", "ConfigMap", "test-config"). + InNamespace("default"). + WithNestedField(map[string]any{"key": "value"}, "data"). + BuildUComposed() + + configMapWithUnknownField := tu.NewResource("v1", "ConfigMap", "test-config"). + InNamespace("default"). + WithNestedField(map[string]any{"key": "value"}, "dataz"). + BuildUComposed() + + // Create sample CRDs for validation + xrCRD := makeCRD("xrs."+testExampleOrg, "XR", testExampleOrg, "v1") + composedCRD := makeCRD("testComposedResources."+testCpdOrg, "testComposedResource", testCpdOrg, "v1") + + // Scope lookups go through discovery, which knows every served kind — + // built-in or custom. Cases that add a resource kind must describe it here. + setupResourceClient := func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource( + schema.GroupVersionKind{Group: testExampleOrg, Version: "v1", Kind: "XR"}, + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, + schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + ). + Build() + } + + tests := map[string]struct { + setupClients func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) + xr *un.Unstructured + composed []cpd.Unstructured + expectedErr bool + expectedErrMsg string + }{ + "SuccessfulValidationWithPreloadedCRDs": { + setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { + sch := tu.NewMockSchemaClient(). + WithFoundCRDs(map[schema.GroupKind]*extv1.CustomResourceDefinition{ + {Group: testExampleOrg, Kind: "XR"}: xrCRD, + {Group: testCpdOrg, Kind: "testComposedResource"}: composedCRD, + }). + WithAllResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() + + return sch, tu.NewMockDefinitionClient().Build() + }, + xr: xr, + composed: []cpd.Unstructured{*composedResource1, *composedResource2}, + expectedErr: false, + }, + "SuccessfulValidationWithFetchedCRDs": { + setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { + sch := tu.NewMockSchemaClient(). + // Add GetCRD implementation for typed CRDs + WithFoundCRDs(map[schema.GroupKind]*extv1.CustomResourceDefinition{ + {Group: testExampleOrg, Kind: "XR"}: xrCRD, + {Group: testCpdOrg, Kind: "testComposedResource"}: composedCRD, + }). + // Implement IsCRDRequired to return true for our test resources + WithAllResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() + def := tu.NewMockDefinitionClient(). + WithEmptyXRDsFetch(). + Build() + + return sch, def + }, + xr: xr, + composed: []cpd.Unstructured{*composedResource1, *composedResource2}, + expectedErr: false, + }, + "MissingCRD": { + setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { + sch := tu.NewMockSchemaClient(). + // Add GetCRD implementation for typed CRDs + WithFoundCRD(testExampleOrg, "XR", xrCRD). + // Add this line to make only Composed resources require CRDs: + WithResourcesRequiringCRDs( + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, + ). + WithCachingBehavior(). + Build() + def := tu.NewMockDefinitionClient(). + WithEmptyXRDsFetch(). + Build() + + return sch, def + }, + xr: xr, + composed: []cpd.Unstructured{*composedResource1, *composedResource2}, + // Now we expect an error because we've configured it to require a CRD but can't find it + expectedErr: true, + expectedErrMsg: "unable to find CRDs for", + }, + "ValidationError": { + setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { + sch := tu.NewMockSchemaClient(). + // Add GetCRD implementation for typed CRDs + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testExampleOrg && gvk.Kind == "XR" { + return createCRDWithStringField(xrCRD), nil + } + + if gvk.Group == testCpdOrg && gvk.Kind == "testComposedResource" { + return composedCRD, nil + } + + return nil, errors.New("CRD not found") + }). + // Setup IsCRDRequired to return true for our test resources + WithAllResourcesRequiringCRDs(). + Build() + + def := tu.NewMockDefinitionClient().Build() + + return sch, def + }, + xr: tu.NewResource(testExampleOrg+"/v1", "XR", "test-xr"). + InNamespace("default"). + WithSpecField("field", int64(123)). + Build(), + composed: []cpd.Unstructured{*composedResource1, *composedResource2}, + expectedErr: true, + expectedErrMsg: "missing schema", + }, + // Built-in Kubernetes types have no CRD. They must still reach + // SchemaValidate, which validates them against its embedded + // Kubernetes scheme rather than reporting a missing schema. + "BuiltInResourceValidatesWithoutCRD": { + setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { + sch := tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "XR", xrCRD). + WithResourcesRequiringCRDs( + schema.GroupVersionKind{Group: testExampleOrg, Version: "v1", Kind: "XR"}, + ). + WithCachingBehavior(). + Build() + + return sch, tu.NewMockDefinitionClient().Build() + }, + xr: xr, + composed: []cpd.Unstructured{*validConfigMap}, + expectedErr: false, + }, + "BuiltInResourceWithUnknownFieldFailsValidation": { + setupClients: func() (*tu.MockSchemaClient, *tu.MockDefinitionClient) { + sch := tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "XR", xrCRD). + WithResourcesRequiringCRDs( + schema.GroupVersionKind{Group: testExampleOrg, Version: "v1", Kind: "XR"}, + ). + WithCachingBehavior(). + Build() + + return sch, tu.NewMockDefinitionClient().Build() + }, + xr: xr, + composed: []cpd.Unstructured{*configMapWithUnknownField}, + expectedErr: true, + expectedErrMsg: `unknown field "dataz"`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + schemaClient, defClient := tt.setupClients() + logger := tu.TestLogger(t, false) + + // Create the schema validator + validator := NewSchemaValidator(schemaClient, setupResourceClient(), defClient, logger) + + // CRDs are now provided via mock SchemaClient + + // Call the function under test + err := validator.ValidateResources(ctx, tt.xr, tt.composed) + + // Check error expectations + if tt.expectedErr { + if err == nil { + t.Errorf("ValidateResources() expected error but got none") + return + } + + if tt.expectedErrMsg != "" && !strings.Contains(err.Error(), tt.expectedErrMsg) { + t.Errorf("ValidateResources() error %q doesn't contain expected message %q", + err.Error(), tt.expectedErrMsg) + } + + return + } + + if err != nil { + t.Errorf("ValidateResources() unexpected error: %v", err) + } + }) + } +} + +func TestDefaultSchemaValidator_EnsureComposedResourceCRDs(t *testing.T) { + ctx := t.Context() + + // Create sample resources + xr := tu.NewResource(testExampleOrg+"/v1", "XR", "test-xr").InNamespace("default").Build() + cmpd := tu.NewResource(testCpdOrg+"/v1", "testComposedResource", "resource1").InNamespace("default").Build() + + // Create sample CRDs + xrCRD := makeCRD("xrs."+testExampleOrg, "XR", testExampleOrg, "v1") + composedCRD := makeCRD("testComposedResources."+testCpdOrg, "testComposedResource", testCpdOrg, "v1") + + tests := map[string]struct { + setupClient func() *tu.MockSchemaClient + initialCRDs []*extv1.CustomResourceDefinition + resources []*un.Unstructured + expectedCRDLen int + }{ + "AllCRDsAlreadyCached": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() + }, + initialCRDs: []*extv1.CustomResourceDefinition{xrCRD, composedCRD}, + resources: []*un.Unstructured{xr, cmpd}, + expectedCRDLen: 0, // No CRDs should be cached since no resources require CRDs + }, + "FetchMissingCRDs": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + // Use the new GetCRD method with typed CRDs + WithFoundCRD(testCpdOrg, "testComposedResource", composedCRD). + // Make sure cpd resources require CRDs + WithResourcesRequiringCRDs( + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, + ). + WithCachingBehavior(). + Build() + }, + initialCRDs: []*extv1.CustomResourceDefinition{xrCRD}, // Only XR CRD is cached + resources: []*un.Unstructured{xr, cmpd}, + expectedCRDLen: 1, // Should fetch the missing cpd CRD (only cpd resource requires CRD) + }, + "SomeCRDsMissing": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithCRDNotFound(). + WithResourcesRequiringCRDs( + schema.GroupVersionKind{Group: testCpdOrg, Version: "v1", Kind: "testComposedResource"}, + ). + WithCachingBehavior(). + Build() + }, + initialCRDs: []*extv1.CustomResourceDefinition{xrCRD}, // Only XR CRD is cached + resources: []*un.Unstructured{xr, cmpd}, + expectedCRDLen: 0, // No CRDs should be fetched successfully since GetCRD returns not found + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + schemaClient := tt.setupClient() + logger := tu.TestLogger(t, false) + + // Create the schema validator - CRDs provided via mock SchemaClient + validator := NewSchemaValidator(schemaClient, tu.NewMockResourceClient().Build(), tu.NewMockDefinitionClient().Build(), logger) + + // Call the function under test + _ = validator.(*DefaultSchemaValidator).EnsureComposedResourceCRDs(ctx, tt.resources) + + // Verify the CRD count + crds := validator.(*DefaultSchemaValidator).GetCRDs() + if diff := cmp.Diff(tt.expectedCRDLen, len(crds)); diff != "" { + t.Errorf("EnsureComposedResourceCRDs() CRD count mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultSchemaValidator_LoadCRDs(t *testing.T) { + ctx := t.Context() + + // Create sample CRDs as un + xrdUn := tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xrd1"). + WithSpecField("group", "testExampleOrg"). + WithSpecField("names", map[string]any{ + "kind": "XR", + "plural": "xrs", + "singular": "xr", + }). + Build() + + tests := map[string]struct { + setupClient func() xp.DefinitionClient + expectedErr bool + expectedErrMsg string + // for caching tests + callTwice bool // Test making two calls to LoadCRDs + expectXRDCalls int // Expected number of calls to GetXRDs + }{ + "SuccessfulLoad": { + setupClient: func() xp.DefinitionClient { + return tu.NewMockDefinitionClient(). + WithSuccessfulXRDsFetch([]*un.Unstructured{xrdUn}). + Build() + }, + expectedErr: false, + }, + "XRDFetchError": { + setupClient: func() xp.DefinitionClient { + return tu.NewMockDefinitionClient(). + WithFailedXRDsFetch("failed to fetch XRDs"). + Build() + }, + expectedErr: true, + }, + "UsesCachedXRDs": { + setupClient: func() xp.DefinitionClient { + // Create a tracking client that counts GetXRDs calls + return &xrdCountingClient{ + MockDefinitionClient: *tu.NewMockDefinitionClient(). + WithSuccessfulXRDsFetch([]*un.Unstructured{xrdUn}). + Build(), + } + }, + expectedErr: false, + callTwice: true, // Make two calls to LoadCRDs + expectXRDCalls: 1, // GetXRDs should only be called once due to caching + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + defClient := tt.setupClient() + logger := tu.TestLogger(t, false) + + // Create the schema validator with caching behavior + validator := NewSchemaValidator(tu.NewMockSchemaClient().WithCachingBehavior().Build(), tu.NewMockResourceClient().Build(), defClient, logger) + + // Call the function under test + err := validator.(*DefaultSchemaValidator).LoadCRDs(ctx) + + // Check error expectations + if tt.expectedErr { + if err == nil { + t.Errorf("LoadCRDs() expected error but got none") + } + + return + } + + if err != nil { + t.Errorf("LoadCRDs() unexpected error: %v", err) + return + } + + // Verify CRDs were loaded (for successful case) + crds := validator.(*DefaultSchemaValidator).GetCRDs() + if len(crds) == 0 { + t.Errorf("LoadCRDs() did not load any CRDs") + } + }) + } +} + +// Helper function to create a simple CRD. +func makeCRD(name string, kind string, group string, version string) *extv1.CustomResourceDefinition { + return tu.NewCRD(name, group, kind). + WithListKind(kind+"List"). + WithPlural(strings.ToLower(kind)+"s"). + WithSingular(strings.ToLower(kind)). + WithVersion(version, true, true). + WithStringFieldSchema("field"). + Build() +} + +// Create a CRD with a string field validation. +func createCRDWithStringField(baseCRD *extv1.CustomResourceDefinition) *extv1.CustomResourceDefinition { + crd := baseCRD.DeepCopy() + // Ensure the schema requires 'field' to be a string + crd.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"].Properties["field"] = extv1.JSONSchemaProps{ + Type: "string", + } + + return crd +} + +// Helper type to track GetXRDs calls. +type xrdCountingClient struct { + tu.MockDefinitionClient + + getXRDsCallCount int +} + +// Override GetXRDs to count calls. +func (c *xrdCountingClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, error) { + c.getXRDsCallCount++ + return c.MockDefinitionClient.GetXRDs(ctx) +} + +// ValidateResources must be read-only. Its results feed the diff calculator, and anything it added to +// a resource would reach the server-side apply payload and claim that field (#503). The CRD here both +// defaults and REQUIRES deletionPolicy, and the resource omits it, so the case also pins that +// validation outcomes do not depend on defaulting the caller's objects: SchemaValidate defaults its +// own copy before validating. +func TestDefaultSchemaValidator_ValidateResources_DoesNotMutateResources(t *testing.T) { + ctx := t.Context() + + // Create a simple managed resource + managedResource := tu.NewResource("provider.example.org/v1", "ManagedResource", "test-managed"). + InNamespace("default"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("managed-resource"). + WithSpecField("field", "value"). + BuildUComposed() + + // Manually add compositionRevisionRef to spec.crossplane to ensure validation can handle it + _ = un.SetNestedMap(managedResource.Object, map[string]any{ + "compositionRevisionRef": map[string]any{ + "name": "some-revision-abc123", + }, + }, "spec", "crossplane") + + // Create XR + xr := tu.NewResource("example.org/v2", "XCompositeResource", "test-xr"). + InNamespace("default"). + WithSpecField("field", "value"). + Build() + + // Create CRD with defaults for the managed resource using OpenAPIV3Schema + managedCRD := makeCRD("managedresources.provider.example.org", "ManagedResource", "provider.example.org", "v1") + // Set a permissive schema with defaults + managedCRD.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties = map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + XPreserveUnknownFields: func() *bool { b := true; return &b }(), // Allow all fields + Required: []string{"deletionPolicy"}, + Properties: map[string]extv1.JSONSchemaProps{ + "deletionPolicy": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"Delete"`)}, + }, + "managementPolicies": { + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + Default: &extv1.JSON{Raw: []byte(`["*"]`)}, + }, + "providerConfigRef": { + Type: "object", + XPreserveUnknownFields: func() *bool { b := true; return &b }(), + Default: &extv1.JSON{Raw: []byte(`{"name":"default"}`)}, + }, + }, + }, + } + + xrCRD := makeCRD("xcompositeresources.example.org", "XCompositeResource", "example.org", "v2") + preserveUnknown := true + specProps := xrCRD.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + specProps.XPreserveUnknownFields = &preserveUnknown + xrCRD.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] = specProps + + schemaClient := tu.NewMockSchemaClient(). + WithFoundCRDs(map[schema.GroupKind]*extv1.CustomResourceDefinition{ + {Group: "provider.example.org", Kind: "ManagedResource"}: managedCRD, + {Group: "example.org", Kind: "XCompositeResource"}: xrCRD, + }). + WithAllResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() + + defClient := tu.NewMockDefinitionClient().Build() + logger := tu.TestLogger(t, false) + + validator := NewSchemaValidator(schemaClient, tu.NewMockResourceClient().Build(), defClient, logger) + + xrBefore := xr.DeepCopy() + managedBefore := managedResource.DeepCopy() + + err := validator.ValidateResources(ctx, xr, []cpd.Unstructured{*managedResource}) + if err != nil { + t.Fatalf("ValidateResources() unexpected error: %v", err) + } + + if d := cmp.Diff(xrBefore, xr); d != "" { + t.Errorf("ValidateResources() mutated the XR (-before +after):\n%s", d) + } + + if d := cmp.Diff(managedBefore, managedResource); d != "" { + t.Errorf("ValidateResources() mutated a composed resource (-before +after):\n%s", d) + } +} + +func TestFormatValidationErrors(t *testing.T) { + tests := map[string]struct { + reason string + result *pkgvalidate.ValidationResult + expected string + }{ + "SingleSchemaError": { + reason: "A single field-level schema error renders as ' :' header plus an indented ' [schema]' line.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Field: "spec.region", + Message: "spec.region: Required value", + }}, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.region: Required value [schema]", + }, + "SingleMissingSchema": { + reason: "A missing-schema resource collapses to a single ' : missing schema' line (no errors[] to expand).", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "other.org/v1", + Kind: "SomeResource", + Name: "thing", + Status: pkgvalidate.ValidationStatusMissingSchema, + }}, + }, + expected: "other.org/v1/SomeResource thing: missing schema", + }, + "MissingSchemaWithoutName": { + reason: "A resource without metadata.name collapses to just the GVK in the header rather than producing a trailing space.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "other.org/v1", + Kind: "SomeResource", + Status: pkgvalidate.ValidationStatusMissingSchema, + }}, + }, + expected: "other.org/v1/SomeResource: missing schema", + }, + "NamespacedResource": { + reason: "Namespaced resources render '/' in the header so cluster-vs-namespaced scope is visible.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "Thing", + Namespace: "production", + Name: "my-thing", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Message: "spec.size: Required value", + }}, + }}, + }, + expected: "example.org/v1/Thing production/my-thing:\n spec.size: Required value [schema]", + }, + "NamespaceWithoutNameCollapsesToGVK": { + reason: "When namespace is set but name is empty, the header collapses to just the GVK rather than rendering '/' with a stray trailing slash.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "Thing", + Namespace: "production", + Status: pkgvalidate.ValidationStatusMissingSchema, + }}, + }, + expected: "example.org/v1/Thing: missing schema", + }, + "MultipleErrorsGroupedUnderResource": { + reason: "Multiple errors on the same resource share a single header rather than producing a header per error.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{ + {Type: pkgvalidate.FieldErrorTypeSchema, Message: "spec.region: Required value"}, + {Type: pkgvalidate.FieldErrorTypeCEL, Message: "spec.replicas: must be > 0"}, + }, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.region: Required value [schema]\n spec.replicas: must be > 0 [cel]", + }, + "BadValueAppendedWhenAbsentFromMessage": { + reason: "When Value is set and the message doesn't already include it (typical for CEL errors), '(got )' is appended so users see what was rejected.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeCEL, + Message: "spec.replicas: must be > 0", + Value: -1, + }}, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.replicas: must be > 0 (got -1) [cel]", + }, + "BadValueOmittedWhenAlreadyInMessage": { + reason: "k8s field errors typically embed the bad value as a quoted string in the message text; the '(got )' tail is omitted to avoid duplication.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Message: `spec.replicas: Invalid value: "five": must be integer`, + Value: "five", + }}, + }}, + }, + expected: `example.org/v1/XR my-xr:` + "\n" + ` spec.replicas: Invalid value: "five": must be integer [schema]`, + }, + "BadValueNotSuppressedByIncidentalSubstring": { + reason: "Regression: the duplication check matches the quoted form for strings (\"k\") so an unquoted incidental substring (\"k\" inside \"kind\") doesn't suppress the tail.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeCEL, + Message: "spec.kind: Required value", + Value: "k", + }}, + }}, + }, + expected: `example.org/v1/XR my-xr:` + "\n" + ` spec.kind: Required value (got "k") [cel]`, + }, + "NumericBadValueRendersUnquoted": { + reason: "Numbers / bools / structs aren't quoted in k8s messages and shouldn't be quoted in our display either.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeCEL, + Message: "spec.replicas: must be > 0", + Value: 42, + }}, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.replicas: must be > 0 (got 42) [cel]", + }, + "NumericBadValueNotSuppressedByDigitPrefix": { + reason: "Regression: a raw substring search would suppress the tail when the rendered number is a digit-prefix of another token (Value=42 vs message containing '420'). Word-boundary matching keeps the tail rendered so the user sees the actual rejected value.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeCEL, + Message: "spec.size: must be at most 420", + Value: 42, + }}, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.size: must be at most 420 (got 42) [cel]", + }, + "NumericBadValueSuppressedAsToken": { + reason: "When the rendered number does appear as a standalone token in the message (delimited by non-word chars), the tail is correctly suppressed to avoid duplication.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Message: "spec.replicas: Invalid value: 42: must be at most 10", + Value: 42, + }}, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.replicas: Invalid value: 42: must be at most 10 [schema]", + }, + "MultipleResourcesEachOnTheirOwnBlock": { + reason: "Failures from distinct resources produce distinct blocks joined by newlines, in input order.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{ + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Message: "spec.region: Required value", + }}, + }, + { + APIVersion: "other.org/v1", + Kind: "SomeResource", + Name: "thing", + Status: pkgvalidate.ValidationStatusMissingSchema, + }, + }, + }, + expected: "example.org/v1/XR my-xr:\n spec.region: Required value [schema]\nother.org/v1/SomeResource thing: missing schema", + }, + "ValidResourcesProduceNoBlock": { + reason: "Only failing resources contribute blocks; valid resources are filtered out so the output is the failure summary, not an audit log.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{ + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "good-xr", + Status: pkgvalidate.ValidationStatusValid, + }, + { + APIVersion: "example.org/v1", + Kind: "XR", + Name: "bad-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{{ + Type: pkgvalidate.FieldErrorTypeSchema, + Message: "spec.field: Invalid value", + }}, + }, + }, + }, + expected: "example.org/v1/XR bad-xr:\n spec.field: Invalid value [schema]", + }, + "NoApplicableEntriesReturnsEmpty": { + reason: "formatValidationErrors is only invoked after ResultError has flagged a failure; an all-valid input is unreachable in production. The contract is empty string (not a generic fallback) so the wrapping ResultError carries the message.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusValid, + }}, + }, + expected: "", + }, + "DefaultingErrorAccompanyingSchemaError": { + reason: "A defaulting entry that co-occurs with a schema-class error is suppressed: the schema entry already conveys the failure, and the defaulting line would just be noise.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{ + {Type: pkgvalidate.FieldErrorTypeDefaulting, Message: "cannot apply defaults"}, + {Type: pkgvalidate.FieldErrorTypeSchema, Message: "spec.field: Required value"}, + }, + }}, + }, + expected: "example.org/v1/XR my-xr:\n spec.field: Required value [schema]", + }, + "InvalidWithOnlyDefaultingErrorsStillEmitted": { + reason: "Defensive: upstream's statusFromErrors won't produce Invalid+only-defaulting today, but if it ever did we surface the defaulting message rather than emit an empty block.", + result: &pkgvalidate.ValidationResult{ + Resources: []pkgvalidate.ResourceValidationResult{{ + APIVersion: "example.org/v1", + Kind: "XR", + Name: "my-xr", + Status: pkgvalidate.ValidationStatusInvalid, + Errors: []pkgvalidate.FieldValidationError{ + {Type: pkgvalidate.FieldErrorTypeDefaulting, Message: "cannot apply defaults"}, + }, + }}, + }, + expected: "example.org/v1/XR my-xr:\n cannot apply defaults [defaulting]", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := formatValidationErrors(tt.result) + if got != tt.expected { + t.Errorf("\n%s\nformatValidationErrors() = %q, want %q", tt.reason, got, tt.expected) + } + }) + } +} + +func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { + ctx := t.Context() + + // Create CRDs with different scopes + namespacedCRD := makeCRD("namespacedresources."+testExampleOrg, "NamespacedResource", testExampleOrg, "v1") + namespacedCRD.Spec.Scope = extv1.NamespaceScoped + + clusterCRD := makeCRD("clusterresources."+testExampleOrg, "ClusterResource", testExampleOrg, "v1") + clusterCRD.Spec.Scope = extv1.ClusterScoped + + // Built-in kinds have no CRD, so their scope can only come from + // discovery. A case that sets setupResourceClient is asserting the + // discovery path is taken; the default mock has no scopes configured, so + // every other case necessarily falls back to the CRD. + secretGVK := schema.GroupVersionKind{Version: "v1", Kind: "Secret"} + namespaceGVK := schema.GroupVersionKind{Version: "v1", Kind: "Namespace"} + + builtInScopes := func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(secretGVK). + WithClusterScopedResource(namespaceGVK). + Build() + } + + // A CRD lookup that fails the test if it happens, for cases asserting + // scope came from discovery alone. + noCRDs := func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.Errorf("GetCRD should not be called for %s when discovery resolves scope", gvk.String()) + }). + Build() + } + + tests := map[string]struct { + reason string + setupClient func() *tu.MockSchemaClient + setupResourceClient func() *tu.MockResourceClient + resource *un.Unstructured + expectedNamespace string + isClaimRoot bool + expectedErr bool + expectedErrMsg string + }{ + "BuiltInNamespacedResourceViaDiscovery": { + reason: "A namespaced built-in resource has no CRD, so its scope must come from discovery.", + setupClient: noCRDs, + setupResourceClient: builtInScopes, + resource: tu.NewResource("v1", "Secret", "creds"). + InNamespace("default"). + Build(), + expectedNamespace: "default", + isClaimRoot: false, + expectedErr: false, + }, + "BuiltInClusterScopedResourceViaDiscovery": { + reason: "A cluster-scoped built-in resource resolves via discovery and still fails the namespaced-XR ownership rule.", + setupClient: noCRDs, + setupResourceClient: builtInScopes, + resource: tu.NewResource("v1", "Namespace", "generated").Build(), + expectedNamespace: "default", + isClaimRoot: false, + expectedErr: true, + expectedErrMsg: "namespaced XR cannot own cluster-scoped managed resource", + }, + "NamespacedResourceValidNamespace": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "NamespacedResource", namespacedCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "NamespacedResource", "test-resource"). + InNamespace("default"). + Build(), + expectedNamespace: "default", + isClaimRoot: false, + expectedErr: false, + }, + "NamespacedResourceMissingNamespace": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "NamespacedResource", namespacedCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "NamespacedResource", "test-resource"). + Build(), // No namespace + expectedNamespace: "default", + isClaimRoot: false, + expectedErr: true, + expectedErrMsg: "namespaced resource NamespacedResource/test-resource must have a namespace", + }, + "NamespacedResourceWrongNamespace": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "NamespacedResource", namespacedCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "NamespacedResource", "test-resource"). + InNamespace("wrong"). + Build(), + expectedNamespace: "default", + isClaimRoot: false, + expectedErr: true, + expectedErrMsg: "cross-namespace references not supported", + }, + "ClusterResourceValidNoNamespace": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). + Build(), // No namespace - correct for cluster-scoped + expectedNamespace: "", + isClaimRoot: false, + expectedErr: false, + }, + "ClusterResourceInvalidNamespace": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). + InNamespace("default"). + Build(), + expectedNamespace: "", + isClaimRoot: false, + expectedErr: true, + expectedErrMsg: "cluster-scoped resource ClusterResource/test-resource cannot have a namespace", + }, + "ClusterResourceFromNamespacedXR": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). + Build(), + expectedNamespace: "default", // XR is namespaced + isClaimRoot: false, + expectedErr: true, + expectedErrMsg: "namespaced XR cannot own cluster-scoped managed resource", + }, + "ClusterResourceFromNamespacedClaim": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD(testExampleOrg, "ClusterResource", clusterCRD). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "ClusterResource", "test-resource"). + Build(), + expectedNamespace: "default", // Claim is namespaced + isClaimRoot: true, // But it's a claim, so allowed + expectedErr: false, + }, + "CRDNotFound": { + setupClient: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.New("CRD not found") + }). + Build() + }, + resource: tu.NewResource(testExampleOrg+"/v1", "UnknownResource", "test-resource"). + Build(), + expectedNamespace: "default", + isClaimRoot: false, + expectedErr: true, + expectedErrMsg: "cannot determine scope", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + schemaClient := tt.setupClient() + logger := tu.TestLogger(t, false) + + // Most cases resolve scope from the CRD; the default mock knows + // no scopes, so discovery errors and the CRD fallback runs. + resourceClient := tu.NewMockResourceClient().Build() + if tt.setupResourceClient != nil { + resourceClient = tt.setupResourceClient() + } + + // Create the schema validator - CRDs provided via mock SchemaClient + validator := NewSchemaValidator(schemaClient, resourceClient, tu.NewMockDefinitionClient().Build(), logger) + + // Call the function under test + err := validator.ValidateScopeConstraints(ctx, tt.resource, tt.expectedNamespace, tt.isClaimRoot) + + // Check error expectations + if tt.expectedErr { + if err == nil { + t.Errorf("\n%s\nValidateScopeConstraints() expected error but got none", tt.reason) + return + } + + if tt.expectedErrMsg != "" && !strings.Contains(err.Error(), tt.expectedErrMsg) { + t.Errorf("\n%s\nValidateScopeConstraints() error %q doesn't contain expected message %q", + tt.reason, err.Error(), tt.expectedErrMsg) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nValidateScopeConstraints() unexpected error: %v", tt.reason, err) + } + }) + } +} diff --git a/internal/diff/diffprocessor/warnings.go b/internal/diff/diffprocessor/warnings.go new file mode 100644 index 00000000..71c020da --- /dev/null +++ b/internal/diff/diffprocessor/warnings.go @@ -0,0 +1,133 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "fmt" + "io" + "maps" + "slices" + "strings" + "sync" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// AdvisoryCollector is the CLI's dt.Advisories: every advisory raised is written to +// stderr immediately, as a WARNING line, and collected for structured output's warnings[]. +// +// # Why a channel of its own, not the logger +// +// Advisories used to be raised as logging.Logger Info calls, with a logger decorator turning every +// Info into a warning. That made ordinary info logging and user-facing advisories one method, so a +// hosting command could not tell them apart, and a library given the logger could raise warnings +// nobody chose. An advisory is now a typed value raised on this channel, which components receive +// alongside their logger; the logger is for tracing only. +// +// The user-facing label is WARNING rather than INFO: an operator reading "INFO: applying this diff +// will assume ownership" would under-weight it. +// +// # Why it writes to stderr itself +// +// Owning the human rendering here means one mechanism produces both views, the same dual-emission +// contract OutputError follows, and the text stays fit for a CLI rather than taking a logger's +// development-mode format (timestamp, level, caller). +// +// # Why identical advisories are collapsed +// +// An advisory is raised where the condition is discovered, and several of those sites sit inside loops +// the caller chose: resolveFunctionCredentials runs once per XR, so a single unfetchable secret on a +// composition affecting thirty XRs would otherwise produce thirty byte-identical WARNING lines and +// thirty identical warnings[] entries. A repeat that is identical in BOTH message and context carries +// no information the user has not already been given, so the collector keeps only the first. Advisories +// whose context differs in any pair are distinct occurrences and every one of them is kept: the +// ownership-theft advisory in resource_manager.go names the composed resource in its context and must +// fire once per resource. So a raising site puts what distinguishes one occurrence from another in the +// context, which it should be doing regardless, since context is what a machine consumer reads. +// +// It is safe for concurrent use. Construct one with NewAdvisoryCollector. +type AdvisoryCollector struct { + mu sync.Mutex + stderr io.Writer + advisories []dt.OutputWarning + // seen holds the identity of every advisory already recorded, so an identical repeat is dropped + // from both channels rather than re-emitted. Keyed by warningKey. + seen map[string]struct{} +} + +// NewAdvisoryCollector returns a collector that writes each advisory to stderr and collects it for +// structured output. A nil stderr only collects. +func NewAdvisoryCollector(stderr io.Writer) *AdvisoryCollector { + return &AdvisoryCollector{stderr: stderr, seen: map[string]struct{}{}} +} + +// Raise records w: written to stderr now, and collected for structured output. An advisory identical +// to one already raised (same message and same context) is dropped from both channels; see the type +// comment. +func (c *AdvisoryCollector) Raise(w dt.OutputWarning) { + c.mu.Lock() + defer c.mu.Unlock() + + key := warningKey(w) + if _, repeat := c.seen[key]; repeat { + return + } + + c.seen[key] = struct{}{} + c.advisories = append(c.advisories, w) + + if c.stderr != nil { + // A failed write to stderr is not worth failing a diff over, and there is nowhere useful to + // report it: the reporting channel is the thing that just failed. + _, _ = fmt.Fprintln(c.stderr, w.FormatWarning()) + } +} + +// Advisories returns the advisories raised so far, sorted by message and then context (by warningKey), +// so the order does not depend on the order concurrent work raised them in. The returned slice is a +// copy, so callers may retain it while more are raised. +func (c *AdvisoryCollector) Advisories() []dt.OutputWarning { + c.mu.Lock() + defer c.mu.Unlock() + + if len(c.advisories) == 0 { + return nil + } + + out := make([]dt.OutputWarning, len(c.advisories)) + copy(out, c.advisories) + slices.SortFunc(out, func(a, b dt.OutputWarning) int { return strings.Compare(warningKey(a), warningKey(b)) }) + + return out +} + +// warningKey builds the dedup identity of a warning: its message followed by its context pairs in +// sorted key order, so a map's randomized iteration and a caller's argument order cannot produce two +// keys for one warning. Every component is rendered with %q, which escapes the quote and backslash +// characters, so a key or value that happens to contain the separators cannot be mistaken for a +// differently split pair — {"a": "b=c"} and {"a=b": "c"} are different warnings and get different keys. +func warningKey(w dt.OutputWarning) string { + var b strings.Builder + + fmt.Fprintf(&b, "%q", w.Message) + + for _, k := range slices.Sorted(maps.Keys(w.Context)) { + fmt.Fprintf(&b, " %q=%q", k, w.Context[k]) + } + + return b.String() +} diff --git a/internal/diff/diffprocessor/warnings_test.go b/internal/diff/diffprocessor/warnings_test.go new file mode 100644 index 00000000..8f93d8c1 --- /dev/null +++ b/internal/diff/diffprocessor/warnings_test.go @@ -0,0 +1,198 @@ +package diffprocessor + +import ( + "bytes" + "strconv" + "strings" + "sync" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// TestAdvisoryCollector_Raise covers the dual emission and the collector's identity rule. Each raised +// advisory becomes a stderr line for humans AND a collected OutputWarning for structured output; +// Advisories returns them sorted by message, then context. An advisory identical to one already raised (same message AND same context) tells +// the user nothing new, so it is collapsed in BOTH channels. One whose context differs in any pair is +// a distinct occurrence and must still fire every time; the ownership-theft advisory in +// resource_manager.go depends on that, since it is raised once per composed resource with the +// resource's identity in its context. +func TestAdvisoryCollector_Raise(t *testing.T) { + tests := map[string]struct { + reason string + raise []dt.OutputWarning + wantStderr string + wantAdvisories []dt.OutputWarning + }{ + "NoContext": { + reason: "A bare message renders as a bare WARNING line", + raise: []dt.OutputWarning{{Message: "something worth knowing"}}, + wantStderr: "WARNING: something worth knowing\n", + wantAdvisories: []dt.OutputWarning{{Message: "something worth knowing"}}, + }, + "ContextSortedInStderr": { + reason: "Context pairs render in sorted key order, so repeated runs produce byte-identical stderr", + raise: []dt.OutputWarning{{ + Message: "credentials missing", + Context: map[string]string{"resource": "XR/one", "attempted": "2", "fetched": "1"}, + }}, + wantStderr: "WARNING: credentials missing (attempted=2, fetched=1, resource=XR/one)\n", + wantAdvisories: []dt.OutputWarning{{ + Message: "credentials missing", + Context: map[string]string{"resource": "XR/one", "attempted": "2", "fetched": "1"}, + }}, + }, + "IdenticalNoContextCollapses": { + reason: "The same bare message repeated adds nothing, so only the first survives", + raise: []dt.OutputWarning{ + {Message: "leftover containers"}, + {Message: "leftover containers"}, + {Message: "leftover containers"}, + }, + wantStderr: "WARNING: leftover containers\n", + wantAdvisories: []dt.OutputWarning{{Message: "leftover containers"}}, + }, + "IdenticalMessageAndContextCollapses": { + reason: "The per-composition credential advisory repeats once per XR; all copies are identical", + raise: []dt.OutputWarning{ + {Message: "credentials missing", Context: map[string]string{"composition": "xbuckets.example.org", "missing": "ns/a"}}, + {Message: "credentials missing", Context: map[string]string{"composition": "xbuckets.example.org", "missing": "ns/a"}}, + }, + wantStderr: "WARNING: credentials missing (composition=xbuckets.example.org, missing=ns/a)\n", + wantAdvisories: []dt.OutputWarning{{ + Message: "credentials missing", + Context: map[string]string{"composition": "xbuckets.example.org", "missing": "ns/a"}, + }}, + }, + "SameMessageDifferentContextBothFire": { + reason: "The ownership advisory is per composed resource; collapsing it would hide resources", + raise: []dt.OutputWarning{ + {Message: "assuming ownership", Context: map[string]string{"resource": "Bucket/one"}}, + {Message: "assuming ownership", Context: map[string]string{"resource": "Bucket/two"}}, + }, + wantStderr: "WARNING: assuming ownership (resource=Bucket/one)\n" + + "WARNING: assuming ownership (resource=Bucket/two)\n", + wantAdvisories: []dt.OutputWarning{ + {Message: "assuming ownership", Context: map[string]string{"resource": "Bucket/one"}}, + {Message: "assuming ownership", Context: map[string]string{"resource": "Bucket/two"}}, + }, + }, + "SameContextDifferentMessageBothFire": { + reason: "Two different advisories about one resource are two facts", + raise: []dt.OutputWarning{ + {Message: "first advisory", Context: map[string]string{"resource": "Bucket/one"}}, + {Message: "second advisory", Context: map[string]string{"resource": "Bucket/one"}}, + }, + wantStderr: "WARNING: first advisory (resource=Bucket/one)\n" + + "WARNING: second advisory (resource=Bucket/one)\n", + wantAdvisories: []dt.OutputWarning{ + {Message: "first advisory", Context: map[string]string{"resource": "Bucket/one"}}, + {Message: "second advisory", Context: map[string]string{"resource": "Bucket/one"}}, + }, + }, + "AdvisoriesSortedNotInRaisingOrder": { + reason: "Concurrent diffs raise advisories in a scheduling-dependent order, so Advisories sorts by message, then context; stderr keeps raising order", + raise: []dt.OutputWarning{ + {Message: "second advisory", Context: map[string]string{"resource": "Bucket/two"}}, + {Message: "second advisory", Context: map[string]string{"resource": "Bucket/one"}}, + {Message: "first advisory"}, + }, + wantStderr: "WARNING: second advisory (resource=Bucket/two)\n" + + "WARNING: second advisory (resource=Bucket/one)\n" + + "WARNING: first advisory\n", + wantAdvisories: []dt.OutputWarning{ + {Message: "first advisory"}, + {Message: "second advisory", Context: map[string]string{"resource": "Bucket/one"}}, + {Message: "second advisory", Context: map[string]string{"resource": "Bucket/two"}}, + }, + }, + "ContextSplitCannotForgeAMatch": { + reason: "A value containing the key/value separator must not collide with a differently split pair", + raise: []dt.OutputWarning{ + {Message: "advisory", Context: map[string]string{"a": "b=c"}}, + {Message: "advisory", Context: map[string]string{"a=b": "c"}}, + }, + wantStderr: "WARNING: advisory (a=b=c)\n" + + "WARNING: advisory (a=b=c)\n", + wantAdvisories: []dt.OutputWarning{ + {Message: "advisory", Context: map[string]string{"a": "b=c"}}, + {Message: "advisory", Context: map[string]string{"a=b": "c"}}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var stderr bytes.Buffer + + c := NewAdvisoryCollector(&stderr) + + for _, w := range tt.raise { + c.Raise(w) + } + + if diff := gcmp.Diff(tt.wantStderr, stderr.String()); diff != "" { + t.Errorf("\n%s\nstderr mismatch (-want +got):\n%s", tt.reason, diff) + } + + if diff := gcmp.Diff(tt.wantAdvisories, c.Advisories()); diff != "" { + t.Errorf("\n%s\nadvisories mismatch (-want +got):\n%s", tt.reason, diff) + } + }) + } +} + +// TestAdvisoryCollector_AdvisoriesIsACopy guards the documented copy semantics: a caller that retained +// an earlier result must not see it grow, or the renderer could serialize a slice being mutated. +func TestAdvisoryCollector_AdvisoriesIsACopy(t *testing.T) { + c := NewAdvisoryCollector(nil) + + c.Raise(dt.OutputWarning{Message: "first"}) + + snapshot := c.Advisories() + + c.Raise(dt.OutputWarning{Message: "second"}) + + if len(snapshot) != 1 { + t.Errorf("retained snapshot should still have 1 advisory, got %d", len(snapshot)) + } + + if got := len(c.Advisories()); got != 2 { + t.Errorf("current advisories should have 2 entries, got %d", got) + } +} + +// TestAdvisoryCollector_ConcurrentRaise exercises the collector's mutex. Advisories are raised from +// clients and processors that a future change could parallelize, so the collector must not corrupt +// under concurrent use. Run with -race for this to be meaningful. +func TestAdvisoryCollector_ConcurrentRaise(t *testing.T) { + var stderr bytes.Buffer + + c := NewAdvisoryCollector(&stderr) + + const goroutines = 50 + + var wg sync.WaitGroup + + wg.Add(goroutines) + + for i := range goroutines { + go func(i int) { + defer wg.Done() + + c.Raise(dt.OutputWarning{Message: "concurrent advisory", Context: map[string]string{"worker": strconv.Itoa(i)}}) + }(i) + } + + wg.Wait() + + if got := len(c.Advisories()); got != goroutines { + t.Errorf("expected %d advisories, got %d", goroutines, got) + } + + if got := strings.Count(stderr.String(), "WARNING: concurrent advisory"); got != goroutines { + t.Errorf("expected %d stderr lines, got %d", goroutines, got) + } +} diff --git a/nix/vendor-hashes.nix b/nix/vendor-hashes.nix index 003a46b6..14fc0514 100644 --- a/nix/vendor-hashes.nix +++ b/nix/vendor-hashes.nix @@ -12,5 +12,5 @@ # hand.) { # Root module: github.com/crossplane/cli/v2 - root = "sha256-xs2qUPloORBdohdEAVJaoIlT6qMUnlblTR48awImEqI="; + root = "sha256-v7V2pPPu7Ym+Y1oSM56yLvpOn8lkeDuCqf91xSe9NOw="; } From ba1e3211e26d913f2b98c2512273fd6020792935 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 18:58:20 -0400 Subject: [PATCH 010/126] refactor(diff): use render.ErrStaleRenderBackend from the cli 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/render_engine.go | 36 +++---------------- .../diff/diffprocessor/render_engine_test.go | 23 +++--------- 2 files changed, 9 insertions(+), 50 deletions(-) diff --git a/internal/diff/diffprocessor/render_engine.go b/internal/diff/diffprocessor/render_engine.go index 407c1747..8fbda8f4 100644 --- a/internal/diff/diffprocessor/render_engine.go +++ b/internal/diff/diffprocessor/render_engine.go @@ -22,7 +22,6 @@ import ( "maps" "os" "slices" - "strings" "sync" corev1 "k8s.io/api/core/v1" @@ -255,7 +254,7 @@ func (e *EngineRenderFn) Render(ctx context.Context, log logging.Logger, in Rend // Augment, never replace: the hint rides along with the original // failure (and everything it wraps) rather than standing in for it. - if hint := e.staleRenderBackendHint(classifyRenderError(renderErr)); hint != "" { + if hint := e.staleRenderBackendHint(renderErr); hint != "" { outErr = errors.Errorf("%w; %s", outErr, hint) } @@ -364,34 +363,9 @@ func (e *EngineRenderFn) startFunctions(ctx context.Context, log logging.Logger, return fnAddrs, nil } -// errStaleRenderBackend is the kong usage error a crossplane CLI predating -// v2.3.x emits when asked to run `crossplane internal render`: the `internal` -// command did not exist, so it is rejected as a stray positional argument. -// -// Matching this phrase is deliberately narrow. It can only originate from a CLI -// that does not know the `internal` verb, so it cannot be produced by a -// composition, pipeline, or connectivity failure against a current backend — the -// failures a misattributed hint would send a user chasing the wrong thing for. -const staleRenderBackendSignature = "unexpected argument internal" - -// ErrStaleRenderBackend reports that the render backend predates `crossplane internal render`. -var ErrStaleRenderBackend = errors.New("render backend predates `crossplane internal render`") - -// classifyRenderError wraps err with ErrStaleRenderBackend when it carries the stale-backend -// signature. The upstream engine surfaces the backend's stderr only as message text, with no -// typed error to match, so this is the single place the text is inspected; everything -// downstream tests identity with errors.Is. The message of err is unchanged. -func classifyRenderError(err error) error { - if err == nil || !strings.Contains(err.Error(), staleRenderBackendSignature) { - return err - } - - return errors.Join(ErrStaleRenderBackend, err) -} - -// staleRenderBackendHint returns remediation text when err carries the signature -// of a render backend older than `crossplane internal render`, or "" when it does -// not. +// staleRenderBackendHint returns remediation text when err is +// render.ErrStaleRenderBackend, which the render engines report for a backend +// older than `crossplane internal render`, or "" when it is not. // // This shape is otherwise a dead end for the user: a bare kong usage error naming // an argument they never passed, with nothing connecting it to a render image @@ -399,7 +373,7 @@ func classifyRenderError(err error) error { // absent, so a locally cached :stable tag is frozen at whatever was first pulled // — for however long that machine lives. func (e *EngineRenderFn) staleRenderBackendHint(err error) string { - if !errors.Is(err, ErrStaleRenderBackend) { + if !errors.Is(err, render.ErrStaleRenderBackend) { return "" } diff --git a/internal/diff/diffprocessor/render_engine_test.go b/internal/diff/diffprocessor/render_engine_test.go index f1d14805..08608a76 100644 --- a/internal/diff/diffprocessor/render_engine_test.go +++ b/internal/diff/diffprocessor/render_engine_test.go @@ -741,8 +741,9 @@ func TestNewEngineRenderFn_UncomparableImageWarns(t *testing.T) { // the user with a bare kong usage error. The hint must augment the original // error, never replace or soften it. func TestEngineRenderFn_StaleRenderImageHint(t *testing.T) { - // The verbatim shape upstream surfaces: the container's stderr, wrapped. - staleErr := errors.New("crossplane internal render in Docker returned error with output: crossplane: error: unexpected argument internal") + // The verbatim shape upstream surfaces: the container's stderr, wrapped and + // marked as render.ErrStaleRenderBackend. + staleErr := errors.Errorf("crossplane internal render in Docker returned error with output: crossplane: error: unexpected argument internal: %w", render.ErrStaleRenderBackend) tests := map[string]struct { image string @@ -759,7 +760,7 @@ func TestEngineRenderFn_StaleRenderImageHint(t *testing.T) { }, "StaleLocalBinaryGetsHint": { image: "", - renderErr: errors.New("crossplane: error: unexpected argument internal"), + renderErr: errors.Errorf("crossplane: error: unexpected argument internal: %w", render.ErrStaleRenderBackend), wantHintFor: MinCrossplaneRenderVersion, }, // Narrowness matters more than coverage here: misattributing an ordinary @@ -989,19 +990,3 @@ func TestEngineRenderFn_RunOwnsFunctionContainers(t *testing.T) { }) } } - -func TestClassifyRenderError(t *testing.T) { - stale := errors.Wrap(errors.New("crossplane: error: unexpected argument internal"), "cannot render") - - if got := classifyRenderError(stale); !errors.Is(got, ErrStaleRenderBackend) || !errors.Is(got, stale) || got.Error() == "" { - t.Errorf("classifyRenderError(stale) = %v, want ErrStaleRenderBackend preserving the original", got) - } - - if got := classifyRenderError(errors.New("boom")); errors.Is(got, ErrStaleRenderBackend) { - t.Errorf("classifyRenderError(unrelated) wrongly classified: %v", got) - } - - if got := classifyRenderError(nil); got != nil { - t.Errorf("classifyRenderError(nil) = %v, want nil", got) - } -} From 8f637f7d1fc880de2fbbf267ec10055fc55763dc Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 20:00:20 -0400 Subject: [PATCH 011/126] refactor(diff): use semver v3 for render version checks Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- go.mod | 1 - go.sum | 2 -- internal/diff/diffprocessor/render_version.go | 2 +- nix/vendor-hashes.nix | 2 +- 4 files changed, 2 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index 56454780..bacc31b4 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,6 @@ require ( charm.land/glamour/v2 v2.0.1 charm.land/lipgloss/v2 v2.0.6 dario.cat/mergo v1.0.2 - github.com/Masterminds/semver v1.5.0 github.com/Masterminds/semver/v3 v3.5.0 github.com/alecthomas/kong v1.16.1 github.com/charmbracelet/x/ansi v0.11.8 diff --git a/go.sum b/go.sum index d2001e5f..236321ea 100644 --- a/go.sum +++ b/go.sum @@ -85,8 +85,6 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= -github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= -github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= diff --git a/internal/diff/diffprocessor/render_version.go b/internal/diff/diffprocessor/render_version.go index 520bab96..9c0fcb5e 100644 --- a/internal/diff/diffprocessor/render_version.go +++ b/internal/diff/diffprocessor/render_version.go @@ -20,7 +20,7 @@ import ( "fmt" "strings" - "github.com/Masterminds/semver" + "github.com/Masterminds/semver/v3" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) diff --git a/nix/vendor-hashes.nix b/nix/vendor-hashes.nix index 14fc0514..003a46b6 100644 --- a/nix/vendor-hashes.nix +++ b/nix/vendor-hashes.nix @@ -12,5 +12,5 @@ # hand.) { # Root module: github.com/crossplane/cli/v2 - root = "sha256-v7V2pPPu7Ym+Y1oSM56yLvpOn8lkeDuCqf91xSe9NOw="; + root = "sha256-xs2qUPloORBdohdEAVJaoIlT6qMUnlblTR48awImEqI="; } From b14c7d299d6515d01abaf76e3db2b7ee95a7cd30 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 20:10:11 -0400 Subject: [PATCH 012/126] feat(diff): add client bundle constructor 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 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/simulate/simulate.go | 22 +------- internal/diff/client/client.go | 82 +++++++++++++++++++++++++++++ internal/kube/config.go | 52 ++++++++++++++++++ 3 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 internal/diff/client/client.go create mode 100644 internal/kube/config.go diff --git a/cmd/crossplane/simulate/simulate.go b/cmd/crossplane/simulate/simulate.go index adaff9c2..a623b354 100644 --- a/cmd/crossplane/simulate/simulate.go +++ b/cmd/crossplane/simulate/simulate.go @@ -41,7 +41,6 @@ import ( "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/restmapper" - "k8s.io/client-go/tools/clientcmd" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -681,26 +680,9 @@ func relevantProviderImages(ctx context.Context, kubeClient client.Client, rmapp } func (c *Cmd) setupKubeClient(logger logging.Logger) (client.Client, meta.RESTMapper, string, error) { - clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( - clientcmd.NewDefaultClientConfigLoadingRules(), - &clientcmd.ConfigOverrides{CurrentContext: c.Context}, - ) - - kubeconfig, err := clientconfig.ClientConfig() + kubeconfig, clientconfig, err := kube.RESTConfig(c.Context, c.Impersonation) if err != nil { - return nil, nil, "", errors.Wrap(err, "failed to get kubeconfig") - } - - c.Impersonation.Apply(kubeconfig) - - // client-go defaults unset QPS/Burst to 5/10 with client-side throttling, - // which is painfully slow for the several reads simulate does per - // resource. Same approach as trace's setupKubeClient. - if kubeconfig.QPS == 0 { - kubeconfig.QPS = 20 - } - if kubeconfig.Burst == 0 { - kubeconfig.Burst = 30 + return nil, nil, "", err } logger.Debug("Found kubeconfig") diff --git a/internal/diff/client/client.go b/internal/diff/client/client.go new file mode 100644 index 00000000..3a118fd9 --- /dev/null +++ b/internal/diff/client/client.go @@ -0,0 +1,82 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package client bundles the Kubernetes and Crossplane clients used by the +// diff engine. +package client + +import ( + "context" + + "k8s.io/client-go/rest" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/client/core" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" +) + +// Clients holds the Kubernetes and Crossplane clients used by the diff engine. +type Clients struct { + K8sClients k8.Clients + XpClients xp.Clients +} + +// New creates the Kubernetes and Crossplane clients for the given config. +func New(config *rest.Config, logger logging.Logger) (*Clients, error) { + coreClients, err := core.NewClients(config) + if err != nil { + // error is already well-decorated + return nil, err + } + + tc := k8.NewTypeConverter(coreClients, logger) + + k8c := k8.Clients{ + Type: tc, + Access: k8.NewAccessClient(coreClients, tc, logger), + Apply: k8.NewApplyClient(coreClients, tc, logger), + Resource: k8.NewResourceClient(coreClients, tc, logger), + Schema: k8.NewSchemaClient(coreClients, tc, logger), + } + + defClient := xp.NewDefinitionClient(k8c.Resource, logger) + + xpc := xp.Clients{ + Composition: xp.NewCompositionClient(k8c.Resource, defClient, logger), + Credential: xp.NewCredentialClient(k8c.Resource, logger), + Definition: defClient, + Environment: xp.NewEnvironmentClient(k8c.Resource, logger), + Function: xp.NewFunctionClient(k8c.Resource, logger), + ResourceTree: xp.NewResourceTreeClient(coreClients.Tree, logger), + } + + return &Clients{ + K8sClients: k8c, + XpClients: xpc, + }, nil +} + +// Initialize initializes all clients. +func (c *Clients) Initialize(ctx context.Context, logger logging.Logger) error { + if err := c.XpClients.Initialize(ctx, logger); err != nil { + return errors.Wrap(err, "cannot initialize Crossplane client") + } + + return nil +} diff --git a/internal/kube/config.go b/internal/kube/config.go new file mode 100644 index 00000000..c8efae35 --- /dev/null +++ b/internal/kube/config.go @@ -0,0 +1,52 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube + +import ( + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// RESTConfig loads the user's kubeconfig, optionally overriding the current +// context, applies impersonation, and raises unset QPS/Burst to 20/30. It also +// returns the ClientConfig so callers can resolve the context's namespace. +func RESTConfig(kubeContext string, imp ImpersonationFlags) (*rest.Config, clientcmd.ClientConfig, error) { + clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{CurrentContext: kubeContext}, + ) + + cfg, err := clientconfig.ClientConfig() + if err != nil { + return nil, nil, errors.Wrap(err, "failed to get kubeconfig") + } + + imp.Apply(cfg) + + // client-go defaults unset QPS/Burst to 5/10 with client-side throttling, + // which is painfully slow for commands that do several reads per resource. + if cfg.QPS == 0 { + cfg.QPS = 20 + } + if cfg.Burst == 0 { + cfg.Burst = 30 + } + + return cfg, clientconfig, nil +} From 47a3cdedb7f96e14d4a9fc80d42fba69fa34b5ff Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 20:11:51 -0400 Subject: [PATCH 013/126] refactor(project): export function loading for reuse by simulate Lift render xr's loadFunctionsFromProject into project.LoadFunctions with no behavior change. Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/render/xr/cmd.go | 79 +------------------ internal/project/functions_load.go | 120 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 78 deletions(-) create mode 100644 internal/project/functions_load.go diff --git a/cmd/crossplane/render/xr/cmd.go b/cmd/crossplane/render/xr/cmd.go index 10cd4332..67403faa 100644 --- a/cmd/crossplane/render/xr/cmd.go +++ b/cmd/crossplane/render/xr/cmd.go @@ -46,15 +46,10 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" "github.com/crossplane/cli/v2/cmd/crossplane/render/contextfn" - "github.com/crossplane/cli/v2/internal/async" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/project" - "github.com/crossplane/cli/v2/internal/project/functions" "github.com/crossplane/cli/v2/internal/project/projectfile" - "github.com/crossplane/cli/v2/internal/schemas/generator" - "github.com/crossplane/cli/v2/internal/schemas/manager" - "github.com/crossplane/cli/v2/internal/schemas/runner" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" xrpkg "github.com/crossplane/cli/v2/pkg/xr" @@ -419,7 +414,7 @@ func (c *Cmd) loadFunctions(ctx context.Context, log logging.Logger, sp terminal } if isProject { - return c.loadFunctionsFromProject(ctx, log, sp, cfg, fs, filePath, fileName) + return project.LoadFunctions(ctx, log, sp, cfg, fs, filePath, fileName, c.MaxConcurrency, c.newClientAndResolver) } return c.loadFunctionsFromConfiguration(ctx, log, fs, fileName) @@ -461,78 +456,6 @@ func (c *Cmd) newClientAndResolver(extraOpts ...clixpkg.ClientOption) (runtimexp return xpkgClient, clixpkg.NewResolver(xpkgClient), nil } -func (c *Cmd) loadFunctionsFromProject(ctx context.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config, projFS afero.Fs, projFilePath, projFileName string) ([]pkgv1.Function, error) { - log.Debug("Loading functions from project", "project-file", projFilePath) - - proj, err := projectfile.Parse(projFS, projFileName) - if err != nil { - return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) - } - - xpkgClient, resolver, err := c.newClientAndResolver(clixpkg.WithImageConfigs(proj.Spec.ImageConfigs)) - if err != nil { - return nil, err - } - - generators := generator.AllLanguages( - generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), - generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), - ) - - depMgr := dependency.NewManager(proj, projFS, - dependency.WithProjectFile(projFileName), - dependency.WithSchemaGenerators(generators), - dependency.WithXpkgClient(xpkgClient), - dependency.WithResolver(resolver), - ) - - var fns []pkgv1.Function - if err := sp.WrapWithSuccessSpinner("Resolving function dependencies", func() error { - var err error - fns, err = project.LoadFunctionDependencies(depMgr, proj) - return err - }); err != nil { - return nil, errors.Wrap(err, "cannot load project functions") - } - - if err := sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { - schemasFS := afero.NewBasePathFs(projFS, proj.Spec.Paths.Schemas) - schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(proj.Spec.ImageConfigs)) - schemaMgr := manager.New(schemasFS, generators, schemaRunner) - - tempDir, err := os.MkdirTemp("", "crossplane-build-") - if err != nil { - return errors.Wrap(err, "failed to create temporary build directory") - } - defer os.RemoveAll(tempDir) //nolint:errcheck // Best-effort cleanup of temporary files. - - b := project.NewBuilder( - project.BuildWithMaxConcurrency(c.MaxConcurrency), - project.BuildWithFunctionIdentifier(functions.DefaultIdentifier), - project.BuildWithSchemaManager(schemaMgr), - project.BuildWithDependencyManager(depMgr), - project.BuildWithTempDir(tempDir), - project.BuildWithBaseImageCacheDir(functions.DefaultBaseImageCacheDir()), - ) - - imgMap, err := b.Build(ctx, proj, projFS, - project.BuildWithLogger(log), - project.BuildWithEventChannel(ch), - ) - if err != nil { - return err - } - - embeddedFns, err := project.EmbeddedFunctionsToDaemon(ctx, imgMap) - fns = append(fns, embeddedFns...) - return err - }); err != nil { - return nil, errors.Wrap(err, "cannot build embedded functions") - } - - return fns, nil -} - func (c *Cmd) loadFunctionsFromConfiguration(ctx context.Context, log logging.Logger, cfgFS afero.Fs, cfgFileName string) ([]pkgv1.Function, error) { log.Debug("Loading functions from configuration file", "configuration-file", cfgFileName) diff --git a/internal/project/functions_load.go b/internal/project/functions_load.go new file mode 100644 index 00000000..3d81e13c --- /dev/null +++ b/internal/project/functions_load.go @@ -0,0 +1,120 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "os" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/config" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project/functions" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/schemas/manager" + "github.com/crossplane/cli/v2/internal/schemas/runner" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// NewXpkgClientFn returns an xpkg client and resolver built with the given +// options. +type NewXpkgClientFn func(opts ...clixpkg.ClientOption) (runtimexpkg.Client, *clixpkg.Resolver, error) + +// LoadFunctions resolves a project's function dependencies and builds its +// embedded functions, loading their images into the docker daemon. It returns +// a Function for each dependency and each embedded function. +func LoadFunctions(ctx context.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config, projFS afero.Fs, projFilePath, projFileName string, maxConcurrency uint, newClient NewXpkgClientFn) ([]pkgv1.Function, error) { + log.Debug("Loading functions from project", "project-file", projFilePath) + + proj, err := projectfile.Parse(projFS, projFileName) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) + } + + xpkgClient, resolver, err := newClient(clixpkg.WithImageConfigs(proj.Spec.ImageConfigs)) + if err != nil { + return nil, err + } + + generators := generator.AllLanguages( + generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors), + generator.WithGoRuntimeObjects(cfg.Features.GenerateGoRuntimeObjects), + ) + + depMgr := dependency.NewManager(proj, projFS, + dependency.WithProjectFile(projFileName), + dependency.WithSchemaGenerators(generators), + dependency.WithXpkgClient(xpkgClient), + dependency.WithResolver(resolver), + ) + + var fns []pkgv1.Function + if err := sp.WrapWithSuccessSpinner("Resolving function dependencies", func() error { + var err error + fns, err = LoadFunctionDependencies(depMgr, proj) + return err + }); err != nil { + return nil, errors.Wrap(err, "cannot load project functions") + } + + if err := sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { + schemasFS := afero.NewBasePathFs(projFS, proj.Spec.Paths.Schemas) + schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(proj.Spec.ImageConfigs)) + schemaMgr := manager.New(schemasFS, generators, schemaRunner) + + tempDir, err := os.MkdirTemp("", "crossplane-build-") + if err != nil { + return errors.Wrap(err, "failed to create temporary build directory") + } + defer os.RemoveAll(tempDir) //nolint:errcheck // Best-effort cleanup of temporary files. + + b := NewBuilder( + BuildWithMaxConcurrency(maxConcurrency), + BuildWithFunctionIdentifier(functions.DefaultIdentifier), + BuildWithSchemaManager(schemaMgr), + BuildWithDependencyManager(depMgr), + BuildWithTempDir(tempDir), + BuildWithBaseImageCacheDir(functions.DefaultBaseImageCacheDir()), + ) + + imgMap, err := b.Build(ctx, proj, projFS, + BuildWithLogger(log), + BuildWithEventChannel(ch), + ) + if err != nil { + return err + } + + embeddedFns, err := EmbeddedFunctionsToDaemon(ctx, imgMap) + fns = append(fns, embeddedFns...) + return err + }); err != nil { + return nil, errors.Wrap(err, "cannot build embedded functions") + } + + return fns, nil +} From 0c4713179d6caaffd8ab57343770c33fb41abea8 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 20:20:04 -0400 Subject: [PATCH 014/126] fix(project): match default excludes when APIs path is the project root 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 Signed-off-by: Jonathan Ogilvie --- internal/project/build.go | 13 ++++++++++- internal/project/build_test.go | 42 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/internal/project/build.go b/internal/project/build.go index 81de2ca7..9fa4b4c6 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -686,9 +686,20 @@ func collectResources(toFS afero.Fs, fromFS afero.Fs, gvks []string, exclude []s } for _, excl := range exclude { - if strings.HasPrefix(path, excl) { + if excl == "" { + continue + } + // Walk paths are rooted at "/" while project paths are usually + // relative, so anchor each exclude at "/" and match the path + // itself or anything beneath it. + e := filepath.Join("/", excl) + if path != e && !strings.HasPrefix(path, e+"/") { + continue + } + if info.IsDir() { return filepath.SkipDir } + return nil } if info.IsDir() { diff --git a/internal/project/build_test.go b/internal/project/build_test.go index a027bb28..5e12d393 100644 --- a/internal/project/build_test.go +++ b/internal/project/build_test.go @@ -20,6 +20,7 @@ import ( "compress/gzip" "fmt" "io" + "io/fs" "os" "path/filepath" "strings" @@ -37,6 +38,8 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/internal/project/functions" ) @@ -747,3 +750,42 @@ func writeRuntimeTarImage(t *testing.T, fsys afero.Fs, path, arch string, img v1 t.Fatal(err) } } + +func TestCollectResourcesRootExcludes(t *testing.T) { + t.Parallel() + + comp := compositionYAML("c", "acme.example.com", "XDatabase") + projFS := afero.NewMemMapFs() + for _, path := range []string{ + "/db/comp.yaml", + "/examples/comp.yaml", + "/examples-foo/comp.yaml", + "/functions/fn/comp.yaml", + "/operations/comp.yaml", + } { + if err := afero.WriteFile(projFS, path, []byte(comp), 0o644); err != nil { + t.Fatal(err) + } + } + + // These are the excludes project build uses when the APIs path is "/". + pkgFS := afero.NewMemMapFs() + gvks := []string{xpv1.CompositionGroupVersionKind.String()} + if err := collectResources(pkgFS, projFS, gvks, []string{"examples", "functions", "operations"}); err != nil { + t.Fatal(err) + } + + var got []string + if err := afero.Walk(pkgFS, "/", func(path string, info fs.FileInfo, err error) error { + if err == nil && !info.IsDir() { + got = append(got, path) + } + return err + }); err != nil { + t.Fatal(err) + } + want := []string{"/db/comp.yaml", "/examples-foo/comp.yaml"} + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("collectResources(): -want, +got:\n%s", diff) + } +} From 61d25859568c73b4dcedb5459dc03f7435ec9fa6 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 20:22:18 -0400 Subject: [PATCH 015/126] feat(project): export composition discovery 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 Signed-off-by: Jonathan Ogilvie --- internal/project/build.go | 54 ++++++++++++++++++++++----- internal/project/build_test.go | 67 +++++++++++++++++++++++++++++++++- 2 files changed, 110 insertions(+), 11 deletions(-) diff --git a/internal/project/build.go b/internal/project/build.go index 9fa4b4c6..b0374bbc 100644 --- a/internal/project/build.go +++ b/internal/project/build.go @@ -35,6 +35,7 @@ import ( "github.com/spf13/afero" "golang.org/x/sync/errgroup" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -214,16 +215,7 @@ func (b *Builder) Build(ctx context.Context, project *devv1alpha1.Project, proje return nil, errors.Wrap(err, "failed to resolve functions") } - apisSource := projectFS - apiExcludes := []string{ - project.Spec.Paths.Examples, - project.Spec.Paths.Functions, - project.Spec.Paths.Operations, - } - if project.Spec.Paths.APIs != "/" { - apisSource = afero.NewBasePathFs(projectFS, project.Spec.Paths.APIs) - apiExcludes = []string{} - } + apisSource, apiExcludes := apiSource(project, projectFS) // Not all projects have operations; ignore them if not present. operationsSource := afero.NewMemMapFs() @@ -679,6 +671,48 @@ func gzipOpener(fsys afero.Fs, path, tempDir string) (tarball.Opener, error) { }, nil } +// apiSource returns the filesystem holding a project's APIs and the paths to +// exclude when walking it. When APIs is "/", the whole project is walked, minus +// the examples, functions and operations. +func apiSource(project *devv1alpha1.Project, projectFS afero.Fs) (afero.Fs, []string) { + if project.Spec.Paths.APIs != "/" { + return afero.NewBasePathFs(projectFS, project.Spec.Paths.APIs), []string{} + } + return projectFS, []string{ + project.Spec.Paths.Examples, + project.Spec.Paths.Functions, + project.Spec.Paths.Operations, + } +} + +// LoadCompositions returns the Compositions under a project's APIs path, +// discovered the same way project build collects them. +func LoadCompositions(project *devv1alpha1.Project, projectFS afero.Fs) ([]*unstructured.Unstructured, error) { + src, excludes := apiSource(project, projectFS) + compFS := afero.NewMemMapFs() + if err := collectResources(compFS, src, []string{xpv1.CompositionGroupVersionKind.String()}, excludes); err != nil { + return nil, errors.Wrap(err, "failed to collect compositions") + } + + var comps []*unstructured.Unstructured + err := afero.Walk(compFS, "/", func(path string, info fs.FileInfo, err error) error { + if err != nil || info.IsDir() { + return err + } + bs, err := afero.ReadFile(compFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + u := &unstructured.Unstructured{} + if err := yaml.Unmarshal(bs, &u.Object); err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + comps = append(comps, u) + return nil + }) + return comps, err +} + func collectResources(toFS afero.Fs, fromFS afero.Fs, gvks []string, exclude []string) error { return afero.Walk(fromFS, "/", func(path string, info fs.FileInfo, err error) error { if err != nil { diff --git a/internal/project/build_test.go b/internal/project/build_test.go index 5e12d393..1a9454b9 100644 --- a/internal/project/build_test.go +++ b/internal/project/build_test.go @@ -388,7 +388,7 @@ func TestResolveFunctions(t *testing.T) { if err != nil { t.Fatalf("resolveFunctions: %v", err) } - if diff := cmp.Diff(tc.want, got); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { t.Errorf("resolveFunctions(...): -want, +got:\n%s", diff) } }) @@ -789,3 +789,68 @@ func TestCollectResourcesRootExcludes(t *testing.T) { t.Errorf("collectResources(): -want, +got:\n%s", diff) } } + +func TestLoadCompositions(t *testing.T) { + t.Parallel() + + comp := compositionYAML("xdatabase-comp", "acme.example.com", "XDatabase") + xrd := xrdYAML("acme.example.com", "xdatabases", "xdatabase", "XDatabase") + + tcs := map[string]struct { + apisPath string + files map[string]string + want []string + }{ + "DefaultAPIsPath": { + files: map[string]string{ + "apis/db/comp.yaml": comp, + "apis/db/xrd.yaml": xrd, + "apis/db/notes.txt": comp, + "examples/comp.yaml": compositionYAML("example-comp", "acme.example.com", "XDatabase"), + "other/comp.yaml": compositionYAML("other-comp", "acme.example.com", "XDatabase"), + }, + want: []string{"xdatabase-comp"}, + }, + "RootAPIsPathExcludesExamplesFunctionsOperations": { + apisPath: "/", + files: map[string]string{ + "/db/comp.yaml": comp, + "/db/xrd.yaml": xrd, + "/examples/comp.yaml": compositionYAML("example-comp", "acme.example.com", "XDatabase"), + "/functions/fn/c.yaml": compositionYAML("fn-comp", "acme.example.com", "XDatabase"), + "/operations/comp.yaml": compositionYAML("op-comp", "acme.example.com", "XDatabase"), + }, + want: []string{"xdatabase-comp"}, + }, + "NoCompositions": { + files: map[string]string{"apis/xrd.yaml": xrd}, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + t.Parallel() + + projFS := afero.NewMemMapFs() + for path, content := range tc.files { + if err := afero.WriteFile(projFS, path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + proj := &devv1alpha1.Project{Spec: devv1alpha1.ProjectSpec{Paths: &devv1alpha1.ProjectPaths{APIs: tc.apisPath}}} + proj.Default() + + comps, err := LoadCompositions(proj, projFS) + if err != nil { + t.Fatal(err) + } + got := make([]string, 0, len(comps)) + for _, c := range comps { + got = append(got, c.GetName()) + } + if diff := cmp.Diff(tc.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("LoadCompositions(): -want, +got:\n%s", diff) + } + }) + } +} From c5fdbd3e97370088e9df499b1d66b40aaae7a78b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 20:33:51 -0400 Subject: [PATCH 016/126] feat(diff): add project function overlay provider 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 Signed-off-by: Jonathan Ogilvie --- .../project_function_provider.go | 143 ++++++++ .../project_function_provider_test.go | 340 ++++++++++++++++++ 2 files changed, 483 insertions(+) create mode 100644 internal/diff/diffprocessor/project_function_provider.go create mode 100644 internal/diff/diffprocessor/project_function_provider_test.go diff --git a/internal/diff/diffprocessor/project_function_provider.go b/internal/diff/diffprocessor/project_function_provider.go new file mode 100644 index 00000000..dad143e4 --- /dev/null +++ b/internal/diff/diffprocessor/project_function_provider.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "context" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" +) + +// ProjectFunctionProvider wraps another FunctionProvider and resolves each +// pipeline step whose function name matches one of a project's functions to +// that project function, instead of the function installed in the cluster. +// Steps naming any other function fall back to the wrapped provider, which is +// asked only for those steps, so a project function that is not installed in +// the cluster does not fail the lookup. +// +// The project functions are used as given: their images must already be +// resolvable by the Docker daemon (project.LoadFunctions loads embedded +// functions into it). Without a pull-policy annotation the docker runtime +// pulls only when the image is absent, so daemon-loaded tags run as loaded. +// +// It is safe for concurrent use, and every call returns its own copy of the +// functions, as CachedFunctionProvider does. +type ProjectFunctionProvider struct { + inner FunctionProvider + project map[string]pkgv1.Function + logger logging.Logger +} + +// NewProjectFunctionProvider wraps inner so that pipeline steps naming one of +// fns resolve to it. fns is copied. When two of fns share a name the later one +// wins; project.LoadFunctions lists embedded functions after dependencies, so +// an embedded function takes precedence. +func NewProjectFunctionProvider(inner FunctionProvider, fns []pkgv1.Function, logger logging.Logger) FunctionProvider { + project := make(map[string]pkgv1.Function, len(fns)) + for i := range fns { + project[fns[i].GetName()] = *fns[i].DeepCopy() + } + + return &ProjectFunctionProvider{ + inner: inner, + project: project, + logger: logger, + } +} + +// NewProjectFunctionProviderFactory returns a factory for +// WithFunctionProviderFactory that overlays fns on the default +// CachedFunctionProvider. +func NewProjectFunctionProviderFactory(fns []pkgv1.Function) func(xp.FunctionClient, logging.Logger) FunctionProvider { + return func(fnClient xp.FunctionClient, logger logging.Logger) FunctionProvider { + return NewProjectFunctionProvider(NewCachedFunctionProvider(fnClient, logger), fns, logger) + } +} + +// GetFunctionsForComposition returns one function per pipeline step, in +// pipeline order: the project function when the step names one, otherwise the +// function the wrapped provider returns for that step. +func (p *ProjectFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + // The wrapped provider owns rejecting non-pipeline compositions. + if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { + return p.inner.GetFunctionsForComposition(comp) + } + + clusterSteps := make([]apiextensionsv1.PipelineStep, 0, len(comp.Spec.Pipeline)) + for _, step := range comp.Spec.Pipeline { + if _, ok := p.project[step.FunctionRef.Name]; !ok { + clusterSteps = append(clusterSteps, step) + } + } + + if len(clusterSteps) == len(comp.Spec.Pipeline) { + return p.inner.GetFunctionsForComposition(comp) + } + + clusterFns := map[string]pkgv1.Function{} + + if len(clusterSteps) > 0 { + // Same name, fewer steps: the wrapped provider caches by composition + // name, and for a given project set a composition always narrows to + // the same steps. + narrowed := comp.DeepCopy() + narrowed.Spec.Pipeline = clusterSteps + + fns, err := p.inner.GetFunctionsForComposition(narrowed) + if err != nil { + return nil, err + } + + for i := range fns { + clusterFns[fns[i].GetName()] = fns[i] + } + } + + out := make([]pkgv1.Function, 0, len(comp.Spec.Pipeline)) + for _, step := range comp.Spec.Pipeline { + name := step.FunctionRef.Name + + if fn, ok := p.project[name]; ok { + p.logger.Debug("Using project function", "composition", comp.GetName(), "step", step.Step, "function", name, "package", fn.Spec.Package) + out = append(out, *fn.DeepCopy()) + + continue + } + + fn, ok := clusterFns[name] + if !ok { + return nil, errors.Errorf("function %q referenced in pipeline step %q not found", name, step.Step) + } + + // The wrapped provider's functions are already this call's own copies, + // but two steps may name the same function. + out = append(out, *fn.DeepCopy()) + } + + return out, nil +} + +// Cleanup delegates to the wrapped provider. +func (p *ProjectFunctionProvider) Cleanup(ctx context.Context) error { + return p.inner.Cleanup(ctx) +} diff --git a/internal/diff/diffprocessor/project_function_provider_test.go b/internal/diff/diffprocessor/project_function_provider_test.go new file mode 100644 index 00000000..492d559b --- /dev/null +++ b/internal/diff/diffprocessor/project_function_provider_test.go @@ -0,0 +1,340 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package diffprocessor + +import ( + "strings" + "sync" + "sync/atomic" + "testing" + + gcmp "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func testFn(name, pkg string) pkgv1.Function { + return pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: pkgv1.FunctionSpec{PackageSpec: pkgv1.PackageSpec{Package: pkg}}, + } +} + +func pipelineComp(name string, fnNames ...string) *apiextensionsv1.Composition { + comp := &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: apiextensionsv1.CompositionSpec{Mode: apiextensionsv1.CompositionModePipeline}, + } + for i, fn := range fnNames { + comp.Spec.Pipeline = append(comp.Spec.Pipeline, apiextensionsv1.PipelineStep{ + Step: "step-" + string(rune('a'+i)), + FunctionRef: apiextensionsv1.FunctionReference{Name: fn}, + }) + } + + return comp +} + +// clusterFnClient resolves pipeline steps against installed, as the real +// function client does, and records the steps it was asked for. +func clusterFnClient(installed map[string]pkgv1.Function, asked *[][]string, mu *sync.Mutex) xp.FunctionClient { + return tu.NewMockFunctionClient(). + WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { + return nil, errors.Errorf("unsupported composition Mode '%s'", comp.Spec.Mode) + } + + names := make([]string, 0, len(comp.Spec.Pipeline)) + out := make([]pkgv1.Function, 0, len(comp.Spec.Pipeline)) + + for _, step := range comp.Spec.Pipeline { + fn, ok := installed[step.FunctionRef.Name] + if !ok { + return nil, errors.Errorf("function %q referenced in pipeline step %q not found", step.FunctionRef.Name, step.Step) + } + + names = append(names, step.FunctionRef.Name) + out = append(out, *fn.DeepCopy()) + } + + if asked != nil { + mu.Lock() + *asked = append(*asked, names) + mu.Unlock() + } + + return out, nil + }). + Build() +} + +func TestProjectFunctionProvider_GetFunctionsForComposition(t *testing.T) { + installed := map[string]pkgv1.Function{ + "function-a": testFn("function-a", "xpkg.io/cluster/function-a:v1"), + "function-b": testFn("function-b", "xpkg.io/cluster/function-b:v1"), + } + + type want struct { + fns []pkgv1.Function + asked [][]string + errSub string + } + + cases := map[string]struct { + reason string + project []pkgv1.Function + comp *apiextensionsv1.Composition + want want + }{ + "OverrideInstalled": { + reason: "A project function replaces the installed function of the same name; the other steps come from the cluster.", + project: []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, + comp: pipelineComp("comp", "function-a", "function-b"), + want: want{ + fns: []pkgv1.Function{ + testFn("function-a", "xpkg.io/project/function-a:local"), + testFn("function-b", "xpkg.io/cluster/function-b:v1"), + }, + asked: [][]string{{"function-b"}}, + }, + }, + "NotInstalled": { + reason: "A project function that is not installed resolves without asking the cluster for it.", + project: []pkgv1.Function{testFn("function-new", "xpkg.io/project/function-new:local")}, + comp: pipelineComp("comp", "function-a", "function-new"), + want: want{ + fns: []pkgv1.Function{ + testFn("function-a", "xpkg.io/cluster/function-a:v1"), + testFn("function-new", "xpkg.io/project/function-new:local"), + }, + asked: [][]string{{"function-a"}}, + }, + }, + "AllProject": { + reason: "When every step is a project function the cluster is not asked.", + project: []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, + comp: pipelineComp("comp", "function-a", "function-a"), + want: want{ + fns: []pkgv1.Function{ + testFn("function-a", "xpkg.io/project/function-a:local"), + testFn("function-a", "xpkg.io/project/function-a:local"), + }, + }, + }, + "Fallback": { + reason: "With no matching project function the whole pipeline comes from the cluster.", + project: []pkgv1.Function{testFn("function-other", "xpkg.io/project/function-other:local")}, + comp: pipelineComp("comp", "function-a", "function-b"), + want: want{ + fns: []pkgv1.Function{ + testFn("function-a", "xpkg.io/cluster/function-a:v1"), + testFn("function-b", "xpkg.io/cluster/function-b:v1"), + }, + asked: [][]string{{"function-a", "function-b"}}, + }, + }, + "ExactNameMatch": { + reason: "Matching is by exact function name, not prefix or package.", + project: []pkgv1.Function{testFn("function", "xpkg.io/project/function-a:local"), testFn("Function-A", "xpkg.io/project/function-a:local")}, + comp: pipelineComp("comp", "function-a"), + want: want{ + fns: []pkgv1.Function{testFn("function-a", "xpkg.io/cluster/function-a:v1")}, + asked: [][]string{{"function-a"}}, + }, + }, + "LaterDuplicateWins": { + reason: "When project functions share a name the later one (an embedded function, after dependencies) wins.", + project: []pkgv1.Function{testFn("function-a", "xpkg.io/dep/function-a:v2"), testFn("function-a", "xpkg.io/project/function-a:local")}, + comp: pipelineComp("comp", "function-a"), + want: want{ + fns: []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, + }, + }, + "ClusterMissing": { + reason: "A step that is neither a project function nor installed fails as it does without the overlay.", + project: []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, + comp: pipelineComp("comp", "function-a", "function-missing"), + want: want{errSub: `function "function-missing" referenced in pipeline step "step-b" not found`}, + }, + "NotPipeline": { + reason: "A non-pipeline composition is left to the wrapped provider to reject.", + project: []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, + comp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{Name: "comp"}, + Spec: apiextensionsv1.CompositionSpec{Mode: "Resources"}, + }, + want: want{errSub: "unsupported composition Mode"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var ( + asked [][]string + mu sync.Mutex + ) + + p := NewProjectFunctionProviderFactory(tc.project)(clusterFnClient(installed, &asked, &mu), tu.TestLogger(t, false)) + + got, err := p.GetFunctionsForComposition(tc.comp) + if tc.want.errSub != "" { + if err == nil || !strings.Contains(err.Error(), tc.want.errSub) { + t.Fatalf("\n%s\nGetFunctionsForComposition(): want error containing %q, got %v", tc.reason, tc.want.errSub, err) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nGetFunctionsForComposition(): %v", tc.reason, err) + } + + if diff := gcmp.Diff(tc.want.fns, got); diff != "" { + t.Errorf("\n%s\nGetFunctionsForComposition(): -want, +got:\n%s", tc.reason, diff) + } + + if diff := gcmp.Diff(tc.want.asked, asked); diff != "" { + t.Errorf("\n%s\nsteps asked of the cluster: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestProjectFunctionProvider_CopyIsolation(t *testing.T) { + installed := map[string]pkgv1.Function{"function-b": testFn("function-b", "xpkg.io/cluster/function-b:v1")} + project := []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")} + project[0].SetAnnotations(map[string]string{"k": "v"}) + + p := NewProjectFunctionProvider(NewCachedFunctionProvider(clusterFnClient(installed, nil, nil), tu.TestLogger(t, false)), project, tu.TestLogger(t, false)) + comp := pipelineComp("comp", "function-a", "function-b", "function-a") + + first, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition(): %v", err) + } + + want, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition(): %v", err) + } + + // Two steps naming the same function must not share a map either. + first[0].Annotations["k"] = "mutated" + if first[2].Annotations["k"] != "v" { + t.Fatal("steps naming the same function share their annotations") + } + + // Mutate everything the render engine and the outer wrappers touch. + for i := range first { + first[i].Spec.Package = "mutated" + if first[i].Annotations == nil { + first[i].Annotations = map[string]string{} + } + first[i].Annotations["k"] = "mutated" + first[i].Annotations[render.AnnotationKeyRuntimeDockerSession] = "mutated" + } + + // Mutating the input after construction must not reach the overlay. + project[0].Spec.Package = "mutated-input" + project[0].Annotations["k"] = "mutated-input" + + got, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Fatalf("GetFunctionsForComposition(): %v", err) + } + + if diff := gcmp.Diff(want, got); diff != "" { + t.Errorf("a mutated result or input leaked into a later call: -want, +got:\n%s", diff) + } +} + +func TestProjectFunctionProvider_PullPolicy(t *testing.T) { + // Daemon-loaded images (project.EmbeddedFunctionsToDaemon) must not be + // pulled: an overlaid function gets no pull-policy annotation, so the + // docker runtime uses its default, which pulls only an absent image. + p := NewProjectFunctionProvider(NewCachedFunctionProvider(clusterFnClient(nil, nil, nil), tu.TestLogger(t, false)), + []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, tu.TestLogger(t, false)) + + fns, err := p.GetFunctionsForComposition(pipelineComp("comp", "function-a")) + if err != nil { + t.Fatalf("GetFunctionsForComposition(): %v", err) + } + + rt, err := render.GetRuntimeDocker(fns[0], tu.TestLogger(t, false)) + if err != nil { + t.Fatalf("GetRuntimeDocker(): %v", err) + } + + if rt.PullPolicy != render.AnnotationValueRuntimeDockerPullPolicyIfNotPresent { + t.Errorf("PullPolicy: want %q, got %q", render.AnnotationValueRuntimeDockerPullPolicyIfNotPresent, rt.PullPolicy) + } +} + +func TestProjectFunctionProvider_Concurrent(t *testing.T) { + installed := map[string]pkgv1.Function{"function-b": testFn("function-b", "xpkg.io/cluster/function-b:v1")} + + var fetches atomic.Int32 + + inner := tu.NewMockFunctionClient(). + WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fetches.Add(1) + return clusterFnClient(installed, nil, nil).GetFunctionsFromPipeline(comp) + }). + Build() + + p := NewProjectFunctionProviderFactory([]pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")})(inner, tu.TestLogger(t, false)) + comp := pipelineComp("comp", "function-a", "function-b") + want := []pkgv1.Function{ + testFn("function-a", "xpkg.io/project/function-a:local"), + testFn("function-b", "xpkg.io/cluster/function-b:v1"), + } + + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + got, err := p.GetFunctionsForComposition(comp) + if err != nil { + t.Errorf("GetFunctionsForComposition(): %v", err) + return + } + + if diff := gcmp.Diff(want, got); diff != "" { + t.Errorf("GetFunctionsForComposition(): -want, +got:\n%s", diff) + } + + // Each caller mutates its own copy, as the render engine does. + for i := range got { + got[i].SetAnnotations(map[string]string{render.AnnotationKeyRuntimeDockerSession: "run"}) + got[i].Spec.Package = "mutated" + } + }) + } + + wg.Wait() + + if n := fetches.Load(); n != 1 { + t.Errorf("cluster fetches: want 1 (cached), got %d", n) + } +} From 3110a158374c0303261ce6e52371ed9bb1d12686 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 21:17:09 -0400 Subject: [PATCH 017/126] feat(project): add alpha project simulate command 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 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 56 ++++ cmd/crossplane/project/project.go | 11 +- cmd/crossplane/project/simulate.go | 268 ++++++++++++++++ cmd/crossplane/project/simulate_test.go | 400 ++++++++++++++++++++++++ 4 files changed, 730 insertions(+), 5 deletions(-) create mode 100644 cmd/crossplane/project/help/simulate.md create mode 100644 cmd/crossplane/project/simulate.go create mode 100644 cmd/crossplane/project/simulate_test.go diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md new file mode 100644 index 00000000..ceee5eef --- /dev/null +++ b/cmd/crossplane/project/help/simulate.md @@ -0,0 +1,56 @@ +The `project simulate` command previews how applying a project's Compositions +would change the composite resources in a cluster. It renders each composite +resource that uses one of the project's Compositions with the project's +version of that Composition, and shows the difference from what's in the +cluster. It doesn't change the cluster. + +This command: + +- Builds the project's embedded functions and loads them into the local + Docker daemon, and resolves the project's function dependencies. +- Collects the Compositions under the project's APIs path. +- Connects to the cluster in your current Kubernetes context, or the one + `--context` names. +- Renders each affected composite resource with the project's functions in + place of the cluster's functions of the same name, and the cluster's + functions for every other pipeline step. +- Prints the difference. + +The command needs Docker, to build and run functions, and read access to the +cluster's Crossplane resources. It verifies the rendered resources with +server-side dry-run requests, so it also needs the `create` and `patch` verbs on +them. Use `--as`, `--as-group`, and `--as-uid` to simulate as another user. + +By default, `simulate` skips composite resources with a `Manual` composition +update policy, because they don't adopt a new Composition revision on their +own. Pass `--include-pinned` to simulate them too. + +The `--timeout` flag bounds the whole simulation, not each composite resource. +It doesn't include the time spent building functions. + +## Examples + +Simulate the project in the current directory against the current context: + +```shell +crossplane project simulate +``` + +Simulate against another context, only for composite resources in one +namespace: + +```shell +crossplane project simulate --context=staging -n team-a +``` + +Simulate a single namespaced composite resource: + +```shell +crossplane project simulate -n team-a --name=my-database +``` + +Print the result as JSON: + +```shell +crossplane project simulate -o json +``` diff --git a/cmd/crossplane/project/project.go b/cmd/crossplane/project/project.go index e3907998..5ec95df7 100644 --- a/cmd/crossplane/project/project.go +++ b/cmd/crossplane/project/project.go @@ -19,9 +19,10 @@ package project // Cmd contains project subcommands. type Cmd struct { - Init initCmd `cmd:"" help:"Initialize a new project."` - Build buildCmd `cmd:"" help:"Build a project into Crossplane packages."` - Push pushCmd `cmd:"" help:"Push a built project to an OCI registry."` - Run runCmd `cmd:"" help:"Build and run a project in a local dev control plane." novale:"Google.WordList[\"admin\"]"` - Stop stopCmd `cmd:"" help:"Tear down a local dev control plane."` + Init initCmd `cmd:"" help:"Initialize a new project."` + Build buildCmd `cmd:"" help:"Build a project into Crossplane packages."` + Push pushCmd `cmd:"" help:"Push a built project to an OCI registry."` + Run runCmd `cmd:"" help:"Build and run a project in a local dev control plane." novale:"Google.WordList[\"admin\"]"` + Simulate simulateCmd `cmd:"" help:"Preview how a project's Compositions would change a cluster's composite resources." maturity:"alpha"` + Stop stopCmd `cmd:"" help:"Tear down a local dev control plane."` } diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go new file mode 100644 index 00000000..fe888316 --- /dev/null +++ b/cmd/crossplane/project/simulate.go @@ -0,0 +1,268 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "io" + "os" + "path/filepath" + "time" + + "github.com/alecthomas/kong" + "github.com/charmbracelet/x/term" + "github.com/spf13/afero" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/config" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/diff/client" + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + "github.com/crossplane/cli/v2/internal/diff/renderer" + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/kube" + "github.com/crossplane/cli/v2/internal/project" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" + + _ "embed" +) + +//go:embed help/simulate.md +var simulateHelp string + +// simulateCmd previews how a project's compositions would change the +// composite resources in a cluster. +type simulateCmd struct { + ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` + MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + + Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` + Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` + Name string `help:"Only simulate the composite resource with this name. Use with --namespace to name a namespaced resource."` + IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` + MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` + Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` + + // CrossplaneVersion and CrossplaneImage select the crossplane render + // backend, as they do for crossplane-diff. When neither is set the render + // engine pulls xpkg.crossplane.io/crossplane/crossplane:stable. + CrossplaneVersion string `help:"Pin the crossplane render version, for example v2.3.4. Minimum v2.3.4." placeholder:"VERSION" xor:"crossplane-render-backend"` + CrossplaneImage string `help:"Override the full crossplane render image reference. Minimum v2.3.4 when its tag is a semantic version." placeholder:"IMAGE" xor:"crossplane-render-backend"` + + Impersonation kube.ImpersonationFlags `embed:""` +} + +func (c *simulateCmd) Help() string { + return simulateHelp +} + +// Validate rejects flag values the simulation can't run with. kong calls it +// during parsing, so an unsupported render backend fails before any build or +// cluster connection. +func (c *simulateCmd) Validate() error { + if c.MaxIterations < 1 { + return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) + } + + // The two flags are mutually exclusive (kong xor), so at most one of + // these has anything to check. + if c.CrossplaneImage != "" { + return dp.ValidateMinRenderImage(c.CrossplaneImage) + } + if c.CrossplaneVersion != "" { + return dp.ValidateMinRenderVersion(c.CrossplaneVersion) + } + + return nil +} + +// Run builds the project's functions and previews its compositions against +// the cluster. +func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config) error { + if err := docker.Check(sigCtx); err != nil { + return errors.Wrap(err, "cannot connect to Docker, which project simulate needs to build and run functions") + } + + // Load the kubeconfig before the slow function build, so a bad context + // fails fast. + restCfg, _, err := kube.RESTConfig(c.Context, c.Impersonation) + if err != nil { + return err + } + + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projFS := afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)) + projFileName := filepath.Base(projFilePath) + + proj, err := projectfile.Parse(projFS, projFileName) + if err != nil { + return errors.Wrapf(err, "failed to parse project file %q", c.ProjectFile) + } + + comps, err := project.LoadCompositions(proj, projFS) + if err != nil { + return errors.Wrap(err, "cannot load compositions from project") + } + if len(comps) == 0 { + return errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) + } + + fns, err := project.LoadFunctions(sigCtx, logger, sp, cliCfg, projFS, projFilePath, projFileName, c.MaxConcurrency, c.newClientAndResolver) + if err != nil { + return err + } + + clients, err := client.New(restCfg, logger) + if err != nil { + return err + } + + opts := c.processorOptions(k, logger, fns) + xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) + proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) + + // ponytail: the timeout bounds the whole simulation because the diff + // engine has no per-XR deadline. Make it per composite resource once the + // engine supports one. + ctx, cancel := context.WithTimeout(sigCtx, c.Timeout) + defer cancel() + + return c.simulate(ctx, logger, clients.Initialize, proc, comps) +} + +// simulate initializes the clients and processor and previews comps, mapping +// the result to the command's exit status. ctx must not be cancelled by its +// owner until simulate returns, so a cancellation reads as an interrupt. +func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initClients func(context.Context, logging.Logger) error, proc dp.CompDiffProcessor, comps []*un.Unstructured) (err error) { + // An interrupted run reports the interruption, not the cancelled calls it + // caused. + defer func() { + if dp.Interrupted(ctx) { + err = exitError{error: dp.ErrInterrupted, code: dp.ExitCodeInterrupted} + } + }() + + // Covers paths that return before rendering; Cleanup is idempotent. + defer dp.CleanupDetached(ctx, proc, logger) + + if err := initClients(ctx, logger); err != nil { + return errors.Wrap(err, "cannot initialize clients") + } + if err := proc.Initialize(ctx); err != nil { + return errors.Wrap(err, "cannot initialize composition diff processor") + } + + ns, refs := c.scope() + _, err = proc.DiffComposition(ctx, comps, ns, refs) + if err != nil { + // DiffComposition has already rendered the result, including its + // errors. A run that failed only schema validation exits 2. + return exitError{error: errors.Wrap(err, "cannot simulate project"), code: dp.DetermineExitCode(err, false)} + } + + return nil +} + +// scope returns the namespace and composite resource refs DiffComposition +// limits its impact analysis to. A --name selects one composite resource, +// namespaced when --namespace is set and cluster scoped otherwise, so the +// namespace moves into its ref. +func (c *simulateCmd) scope() (string, []k8stypes.NamespacedName) { + if c.Name == "" { + return c.Namespace, nil + } + return "", []k8stypes.NamespacedName{{Namespace: c.Namespace, Name: c.Name}} +} + +// processorOptions returns the diff processor options for this run. Both the +// XR and composition processors get the same options, as crossplane-diff +// does, so they share one advisory collector. +func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, fns []pkgv1.Function) []dp.ProcessorOption { + opts := []dp.ProcessorOption{ + dp.WithLogger(logger), + dp.WithAdvisories(dp.NewAdvisoryCollector(k.Stderr)), + dp.WithStdout(k.Stdout), + dp.WithStderr(k.Stderr), + dp.WithColorize(isTerminal(k.Stdout)), + dp.WithOutputFormat(renderer.OutputFormat(c.Output)), + dp.WithIncludeManual(c.IncludePinned), + dp.WithMaxRenderIterations(c.MaxIterations), + dp.WithFunctionProviderFactory(dp.NewProjectFunctionProviderFactory(fns)), + } + + if c.CrossplaneVersion != "" { + opts = append(opts, dp.WithCrossplaneVersion(c.CrossplaneVersion)) + } + if c.CrossplaneImage != "" { + opts = append(opts, dp.WithCrossplaneImage(c.CrossplaneImage)) + } + + return opts +} + +func (c *simulateCmd) newClientAndResolver(extraOpts ...clixpkg.ClientOption) (runtimexpkg.Client, *clixpkg.Resolver, error) { + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + opts := append([]clixpkg.ClientOption{clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir)}, extraOpts...) + xpkgClient, err := clixpkg.NewClient(clixpkg.NewRemoteFetcher(), opts...) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot create xpkg client") + } + return xpkgClient, clixpkg.NewResolver(xpkgClient), nil +} + +// isTerminal reports whether w is a terminal, so diffs are colorized only +// when a person reads them. +func isTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + return ok && term.IsTerminal(f.Fd()) +} + +// exitError is an error that sets the command's exit status. kong's +// FatalIfErrorf honours it. +type exitError struct { + error + + code int +} + +// ExitCode implements kong.ExitCoder. +func (e exitError) ExitCode() int { + return e.code +} + +// Unwrap returns the underlying error. +func (e exitError) Unwrap() error { + return e.error +} diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go new file mode 100644 index 00000000..3db10728 --- /dev/null +++ b/cmd/crossplane/project/simulate_test.go @@ -0,0 +1,400 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "bytes" + "context" + "errors" + "testing" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8stypes "k8s.io/apimachinery/pkg/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + "github.com/crossplane/cli/v2/internal/diff/renderer" +) + +// parseSimulate parses args as `simulate` flags, running kong's validation. +func parseSimulate(t *testing.T, args ...string) (*simulateCmd, error) { + t.Helper() + + var cli struct { + Simulate simulateCmd `cmd:""` + } + parser, err := kong.New(&cli, kong.Vars{"project_file": "crossplane-project.yaml"}, kong.Exit(func(int) {})) + if err != nil { + t.Fatalf("kong.New(...): %v", err) + } + _, err = parser.Parse(append([]string{"simulate"}, args...)) + return &cli.Simulate, err +} + +func TestSimulateFlags(t *testing.T) { + type want struct { + cmd *simulateCmd + err bool + } + + cases := map[string]struct { + reason string + args []string + want want + }{ + "Defaults": { + reason: "With no flags the defaults should apply.", + want: want{cmd: &simulateCmd{ + ProjectFile: "crossplane-project.yaml", + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: time.Minute, + Output: "diff", + }}, + }, + "AllFlags": { + reason: "Every flag should land on its field.", + args: []string{ + "-f", "p.yaml", "-c", "staging", "-n", "team-a", "--name", "db", + "--include-pinned", "--max-iterations", "3", "--timeout", "2m", + "--max-concurrency", "2", "-o", "json", "--crossplane-version", "v2.3.4", + "--as", "jane", "--as-group", "admins", + }, + want: want{cmd: &simulateCmd{ + ProjectFile: "p.yaml", + Context: "staging", + Namespace: "team-a", + Name: "db", + IncludePinned: true, + MaxIterations: 3, + Timeout: 2 * time.Minute, + MaxConcurrency: 2, + Output: "json", + CrossplaneVersion: "v2.3.4", + }}, + }, + "UnknownOutput": { + reason: "Output formats other than diff, json and yaml should be rejected.", + args: []string{"-o", "plan"}, + want: want{err: true}, + }, + "ZeroIterations": { + reason: "A render needs at least one iteration.", + args: []string{"--max-iterations", "0"}, + want: want{err: true}, + }, + "RenderVersionBelowFloor": { + reason: "A pinned render version below the floor should be rejected.", + args: []string{"--crossplane-version", "v2.2.0"}, + want: want{err: true}, + }, + "RenderImageBelowFloor": { + reason: "A render image tagged below the floor should be rejected.", + args: []string{"--crossplane-image", "example.com/crossplane:v2.2.0"}, + want: want{err: true}, + }, + "RenderImageUncomparable": { + reason: "A render image with no comparable version should be accepted.", + args: []string{"--crossplane-image", "example.com/crossplane:stable"}, + want: want{cmd: &simulateCmd{ + ProjectFile: "crossplane-project.yaml", + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: time.Minute, + Output: "diff", + CrossplaneImage: "example.com/crossplane:stable", + }}, + }, + "RenderBackendsExclusive": { + reason: "--crossplane-version and --crossplane-image should be mutually exclusive.", + args: []string{"--crossplane-version", "v2.3.4", "--crossplane-image", "example.com/crossplane:v2.3.4"}, + want: want{err: true}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := parseSimulate(t, tc.args...) + if gotErr := err != nil; gotErr != tc.want.err { + t.Fatalf("\n%s\nParse(%v): want error %t, got %v", tc.reason, tc.args, tc.want.err, err) + } + if tc.want.err { + return + } + if diff := cmp.Diff(tc.want.cmd, got, cmpopts.IgnoreFields(simulateCmd{}, "Impersonation")); diff != "" { + t.Errorf("\n%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) + } + }) + } +} + +func TestSimulateScope(t *testing.T) { + type want struct { + namespace string + refs []k8stypes.NamespacedName + } + + cases := map[string]struct { + reason string + cmd simulateCmd + want want + }{ + "All": { + reason: "No scope flags should simulate every namespace.", + }, + "Namespace": { + reason: "--namespace alone should scope discovery to the namespace.", + cmd: simulateCmd{Namespace: "team-a"}, + want: want{namespace: "team-a"}, + }, + "ClusterScopedName": { + reason: "--name alone should name a cluster-scoped composite resource.", + cmd: simulateCmd{Name: "db"}, + want: want{refs: []k8stypes.NamespacedName{{Name: "db"}}}, + }, + "NamespacedName": { + reason: "--name with --namespace should name a namespaced composite resource, not scope discovery.", + cmd: simulateCmd{Namespace: "team-a", Name: "db"}, + want: want{refs: []k8stypes.NamespacedName{{Namespace: "team-a", Name: "db"}}}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ns, refs := tc.cmd.scope() + if diff := cmp.Diff(tc.want.namespace, ns); diff != "" { + t.Errorf("\n%s\nscope(): -want namespace, +got:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.refs, refs); diff != "" { + t.Errorf("\n%s\nscope(): -want refs, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestSimulateProcessorOptions(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: stderr}} + c := &simulateCmd{ + Output: "yaml", + IncludePinned: true, + MaxIterations: 7, + CrossplaneVersion: "v2.3.4", + } + + cfg := dp.ProcessorConfig{} + for _, o := range c.processorOptions(k, logging.NewNopLogger(), []pkgv1.Function{}) { + o(&cfg) + } + + type got struct { + OutputFormat renderer.OutputFormat + IncludeManual bool + MaxRenderIterations int + CrossplaneVersion string + CrossplaneImage string + Colorize bool + StdoutIsK bool + StderrIsK bool + HasAdvisories bool + HasFunctionProvider bool + } + want := got{ + OutputFormat: renderer.OutputFormatYAML, + IncludeManual: true, + MaxRenderIterations: 7, + CrossplaneVersion: "v2.3.4", + // A buffer is not a terminal. + Colorize: false, + StdoutIsK: true, + StderrIsK: true, + HasAdvisories: true, + HasFunctionProvider: true, + } + g := got{ + OutputFormat: cfg.OutputFormat, + IncludeManual: cfg.IncludeManual, + MaxRenderIterations: cfg.MaxRenderIterations, + CrossplaneVersion: cfg.CrossplaneVersion, + CrossplaneImage: cfg.CrossplaneImage, + Colorize: cfg.Colorize, + StdoutIsK: cfg.Stdout == stdout, + StderrIsK: cfg.Stderr == stderr, + HasAdvisories: cfg.Advisories != nil, + HasFunctionProvider: cfg.Factories.FunctionProvider != nil, + } + if diff := cmp.Diff(want, g); diff != "" { + t.Errorf("processorOptions(...): -want, +got:\n%s", diff) + } +} + +// fakeCompProc is a CompDiffProcessor that records its calls. +type fakeCompProc struct { + initErr error + diffFn func(ctx context.Context) (bool, error) + + gotNamespace string + gotRefs []k8stypes.NamespacedName + gotComps int + cleanups int +} + +func (f *fakeCompProc) Initialize(context.Context) error { return f.initErr } + +func (f *fakeCompProc) DiffComposition(ctx context.Context, comps []*un.Unstructured, ns string, refs []k8stypes.NamespacedName) (bool, error) { + f.gotNamespace, f.gotRefs, f.gotComps = ns, refs, len(comps) + if f.diffFn == nil { + return false, nil + } + return f.diffFn(ctx) +} + +func (f *fakeCompProc) Cleanup(context.Context) error { + f.cleanups++ + return nil +} + +func exitCode(err error) int { + if err == nil { + return 0 + } + var ec kong.ExitCoder + if errors.As(err, &ec) { + return ec.ExitCode() + } + return 1 +} + +func TestSimulateRun(t *testing.T) { + errBoom := errors.New("boom") + noInit := func(context.Context, logging.Logger) error { return nil } + + type args struct { + cmd simulateCmd + initClients func(context.Context, logging.Logger) error + proc *fakeCompProc + cancel bool + } + type want struct { + code int + err error + namespace string + refs []k8stypes.NamespacedName + diffed bool + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "NoChanges": { + reason: "A run with no changes should succeed.", + args: args{initClients: noInit, proc: &fakeCompProc{}}, + want: want{diffed: true}, + }, + "Changes": { + reason: "Changes alone should not fail the run.", + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + return true, nil + }}}, + want: want{diffed: true}, + }, + "Namespaced": { + reason: "--namespace alone should reach DiffComposition as the namespace.", + args: args{cmd: simulateCmd{Namespace: "team-a"}, initClients: noInit, proc: &fakeCompProc{}}, + want: want{diffed: true, namespace: "team-a"}, + }, + "Scoped": { + reason: "--name and --namespace should reach DiffComposition as a ref.", + args: args{cmd: simulateCmd{Namespace: "team-a", Name: "db"}, initClients: noInit, proc: &fakeCompProc{}}, + want: want{diffed: true, refs: []k8stypes.NamespacedName{{Namespace: "team-a", Name: "db"}}}, + }, + "ClientInitError": { + reason: "A client initialization failure is a tool error.", + args: args{ + initClients: func(context.Context, logging.Logger) error { return errBoom }, + proc: &fakeCompProc{}, + }, + want: want{code: dp.ExitCodeToolError, err: errBoom}, + }, + "ProcessorInitError": { + reason: "A processor initialization failure is a tool error.", + args: args{initClients: noInit, proc: &fakeCompProc{initErr: errBoom}}, + want: want{code: dp.ExitCodeToolError, err: errBoom}, + }, + "DiffError": { + reason: "A failed simulation is a tool error.", + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + return true, errBoom + }}}, + want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, + }, + "SchemaValidationError": { + reason: "A simulation that failed only schema validation should exit 2.", + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + return false, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) + }}}, + want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, + }, + "Interrupted": { + reason: "An interrupted run should report the interruption and exit 130, whatever it returned.", + args: args{cancel: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(ctx context.Context) (bool, error) { + return false, ctx.Err() + }}}, + want: want{code: dp.ExitCodeInterrupted, err: dp.ErrInterrupted, diffed: true}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if tc.args.cancel { + cancel() + } + + err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), tc.args.initClients, tc.args.proc, []*un.Unstructured{{}}) + + if diff := cmp.Diff(tc.want.code, exitCode(err)); diff != "" { + t.Errorf("\n%s\nsimulate(...): -want exit code, +got:\n%s", tc.reason, diff) + } + if !errors.Is(err, tc.want.err) { + t.Errorf("\n%s\nsimulate(...): want error %v, got %v", tc.reason, tc.want.err, err) + } + if diff := cmp.Diff(tc.want.namespace, tc.args.proc.gotNamespace); diff != "" { + t.Errorf("\n%s\nsimulate(...): -want namespace, +got:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.refs, tc.args.proc.gotRefs); diff != "" { + t.Errorf("\n%s\nsimulate(...): -want refs, +got:\n%s", tc.reason, diff) + } + if diffed := tc.args.proc.gotComps > 0; diffed != tc.want.diffed { + t.Errorf("\n%s\nsimulate(...): want DiffComposition called %t, got %t", tc.reason, tc.want.diffed, diffed) + } + if tc.args.proc.cleanups == 0 { + t.Errorf("\n%s\nsimulate(...): processor was not cleaned up", tc.reason) + } + }) + } +} From e203e7e8287c7c372dd8d5ef83275d5deff39654 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sun, 18 Oct 2026 21:17:52 -0400 Subject: [PATCH 018/126] feat(project): exit 3 on changes with project simulate --exit-code 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 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 11 +++++++++++ cmd/crossplane/project/simulate.go | 13 ++++++++++++- cmd/crossplane/project/simulate_test.go | 22 +++++++++++++++++++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index ceee5eef..12066e69 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -28,6 +28,11 @@ own. Pass `--include-pinned` to simulate them too. The `--timeout` flag bounds the whole simulation, not each composite resource. It doesn't include the time spent building functions. +The command exits with status 0 when the simulation succeeds, whether or not it +finds changes, 1 when it fails, 2 when only schema validation fails, and 130 +when interrupted. Pass `--exit-code` to exit with status 3 when it finds +changes, for example to gate a CI pipeline. + ## Examples Simulate the project in the current directory against the current context: @@ -54,3 +59,9 @@ Print the result as JSON: ```shell crossplane project simulate -o json ``` + +Fail a CI job when the project would change the cluster: + +```shell +crossplane project simulate --exit-code +``` diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index fe888316..b77da450 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -67,6 +67,7 @@ type simulateCmd struct { MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` + ExitCode bool `help:"Exit with status 3 when the simulation finds changes."` // CrossplaneVersion and CrossplaneImage select the crossplane render // backend, as they do for crossplane-diff. When neither is set the render @@ -181,13 +182,19 @@ func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initC } ns, refs := c.scope() - _, err = proc.DiffComposition(ctx, comps, ns, refs) + hasDiffs, err := proc.DiffComposition(ctx, comps, ns, refs) if err != nil { // DiffComposition has already rendered the result, including its // errors. A run that failed only schema validation exits 2. return exitError{error: errors.Wrap(err, "cannot simulate project"), code: dp.DetermineExitCode(err, false)} } + // Changes are what a simulation is for, so they fail the run only when + // asked, as with git diff --exit-code. + if c.ExitCode && hasDiffs { + return exitError{error: errChangesDetected, code: dp.ExitCodeDiffDetected} + } + return nil } @@ -249,6 +256,10 @@ func isTerminal(w io.Writer) bool { return ok && term.IsTerminal(f.Fd()) } +// errChangesDetected is the error a run with --exit-code returns when it +// finds changes. +var errChangesDetected = errors.New("changes detected") + // exitError is an error that sets the command's exit status. kong's // FatalIfErrorf honours it. type exitError struct { diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 3db10728..de0703f6 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -78,7 +78,7 @@ func TestSimulateFlags(t *testing.T) { args: []string{ "-f", "p.yaml", "-c", "staging", "-n", "team-a", "--name", "db", "--include-pinned", "--max-iterations", "3", "--timeout", "2m", - "--max-concurrency", "2", "-o", "json", "--crossplane-version", "v2.3.4", + "--max-concurrency", "2", "-o", "json", "--crossplane-version", "v2.3.4", "--exit-code", "--as", "jane", "--as-group", "admins", }, want: want{cmd: &simulateCmd{ @@ -91,6 +91,7 @@ func TestSimulateFlags(t *testing.T) { Timeout: 2 * time.Minute, MaxConcurrency: 2, Output: "json", + ExitCode: true, CrossplaneVersion: "v2.3.4", }}, }, @@ -321,6 +322,25 @@ func TestSimulateRun(t *testing.T) { }}}, want: want{diffed: true}, }, + "ChangesWithExitCode": { + reason: "With --exit-code, changes should exit 3.", + args: args{cmd: simulateCmd{ExitCode: true}, initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + return true, nil + }}}, + want: want{code: dp.ExitCodeDiffDetected, err: errChangesDetected, diffed: true}, + }, + "NoChangesWithExitCode": { + reason: "With --exit-code, a run with no changes should still succeed.", + args: args{cmd: simulateCmd{ExitCode: true}, initClients: noInit, proc: &fakeCompProc{}}, + want: want{diffed: true}, + }, + "ErrorWithExitCode": { + reason: "With --exit-code, a tool error should still exit 1, not 3.", + args: args{cmd: simulateCmd{ExitCode: true}, initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + return true, errBoom + }}}, + want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, + }, "Namespaced": { reason: "--namespace alone should reach DiffComposition as the namespace.", args: args{cmd: simulateCmd{Namespace: "team-a"}, initClients: noInit, proc: &fakeCompProc{}}, From 5936cf04ce8eb9e663a6a02a0b04e2c2fc348a5b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 12:05:18 -0400 Subject: [PATCH 019/126] feat(diff): port crossplane-diff integration tests behind a simulate 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 6089 +++++++++++++++++ internal/diff/integration/it_utils_test.go | 1156 ++++ internal/diff/integration/main_test.go | 21 + .../diff/integration/simulate_harness_test.go | 398 ++ .../comp/api-version-updated-composition.yaml | 32 + ...vision-templating-updated-composition.yaml | 37 + .../testdata/comp/composition-no-changes.yaml | 33 + .../comp/crds/xapimigrateresource-crd.yaml | 84 + .../crds/xdownstreamresource-cluster-crd.yaml | 56 + ...xdownstreamresource-legacycluster-crd.yaml | 56 + .../comp/crds/xdownstreamresource-ns-crd.yaml | 68 + .../field-removal-updated-composition.yaml | 32 + .../mixed-status-updated-composition.yaml | 59 + .../testdata/comp/net-new-composition.yaml | 37 + .../api-version-original-composition.yaml | 32 + .../comp/resources/claim-composition.yaml | 32 + ...emplating-composition-kubectl-applied.yaml | 41 + ...claim-revision-templating-composition.yaml | 39 + .../testdata/comp/resources/claim-xrd.yaml | 27 + .../comp/resources/custom-namespace.yaml | 4 + .../resources/cycle/existing-cycle-xr.yaml | 12 + ...isting-api-version-downstream-v1beta1.yaml | 10 + .../resources/existing-api-version-xr.yaml | 11 + .../comp/resources/existing-claim-1-xr.yaml | 21 + .../comp/resources/existing-claim-1.yaml | 18 + .../comp/resources/existing-claim-2-xr.yaml | 21 + .../comp/resources/existing-claim-2.yaml | 18 + .../existing-claim-downstream-1.yaml | 10 + .../existing-claim-downstream-2.yaml | 10 + ...xisting-claim-revision-ref-downstream.yaml | 14 + ...vision-ref-kubectl-applied-downstream.yaml | 12 + ...claim-revision-ref-kubectl-applied-xr.yaml | 15 + ...ng-claim-revision-ref-kubectl-applied.yaml | 20 + .../existing-claim-revision-ref-xr.yaml | 15 + .../existing-claim-revision-ref.yaml | 20 + .../existing-conditional-gating-stage0.yaml | 12 + .../existing-conditional-gating-xr.yaml | 10 + .../existing-custom-ns-downstream.yaml | 11 + .../comp/resources/existing-custom-ns-xr.yaml | 10 + .../comp/resources/existing-downstream-1.yaml | 11 + .../comp/resources/existing-downstream-2.yaml | 11 + .../existing-downstream-deleting.yaml | 11 + .../existing-downstream-drifted.yaml | 14 + .../resources/existing-downstream-manual.yaml | 11 + ...isting-downstream-pinned-revision-ref.yaml | 15 + .../existing-downstream-revision-ref.yaml | 19 + .../resources/existing-downstream-v2.yaml | 13 + .../existing-downstream-with-argocd.yaml | 14 + .../existing-sequencer-gating-stage0.yaml | 12 + .../existing-sequencer-gating-xr.yaml | 10 + .../resources/existing-sha256-downstream.yaml | 11 + .../comp/resources/existing-sha256-xr.yaml | 10 + .../existing-unverified-cluster-xr.yaml | 11 + ...existing-xr-1-kubectl-applied-current.yaml | 13 + .../existing-xr-1-kubectl-applied.yaml | 13 + .../comp/resources/existing-xr-1.yaml | 10 + .../comp/resources/existing-xr-2.yaml | 10 + .../comp/resources/existing-xr-deleting.yaml | 14 + ...ing-xr-listing-uncontrolled-resources.yaml | 22 + .../comp/resources/existing-xr-manual.yaml | 11 + .../existing-xr-pinned-revision-ref.yaml | 18 + .../resources/existing-xr-revision-ref.yaml | 15 + .../resources/existing-xr-selector-match.yaml | 18 + .../existing-xr-selector-mismatch.yaml | 18 + .../comp/resources/existing-xr-v2.yaml | 12 + .../resources/existing-xr-with-argocd.yaml | 14 + .../field-removal/composition-with-field.yaml | 33 + .../field-removal/existing-downstream.yaml | 11 + .../resources/field-removal/existing-xr.yaml | 10 + .../comp/resources/functions-sha256.yaml | 17 + .../testdata/comp/resources/functions.yaml | 32 + .../comp/resources/mixed-downstream-db-1.yaml | 11 + .../comp/resources/mixed-downstream-db-2.yaml | 11 + .../comp/resources/mixed-downstream-db-3.yaml | 12 + .../resources/mixed-downstream-network-1.yaml | 11 + .../resources/mixed-downstream-network-2.yaml | 11 + .../resources/mixed-downstream-network-3.yaml | 11 + .../resources/mixed-downstream-storage-1.yaml | 11 + .../resources/mixed-downstream-storage-2.yaml | 11 + .../resources/mixed-downstream-storage-3.yaml | 11 + .../resources/mixed-status-composition.yaml | 58 + .../testdata/comp/resources/mixed-xr-1.yaml | 10 + .../testdata/comp/resources/mixed-xr-2.yaml | 10 + .../testdata/comp/resources/mixed-xr-3.yaml | 10 + .../collision-composition-revision.yaml | 56 + .../collision-composition.yaml | 52 + .../namespace-collision/configmap-ns-a.yaml | 8 + .../namespace-collision/configmap-ns-b.yaml | 8 + .../existing-downstream-ns-a.yaml | 14 + .../existing-downstream-ns-b.yaml | 14 + .../namespace-collision/existing-xr-ns-a.yaml | 15 + .../namespace-collision/existing-xr-ns-b.yaml | 15 + .../namespace-collision/namespace-ns-a.yaml | 4 + .../namespace-collision/namespace-ns-b.yaml | 4 + .../nested-xr/existing-child-downstream.yaml | 16 + .../nested-xr/existing-nested-child-xr.yaml | 14 + .../existing-parent-direct-downstream.yaml | 12 + .../nested-xr/existing-parent-xr.yaml | 12 + .../nested-xr/parent-composition.yaml | 49 + .../comp/resources/nested-xr/parent-xrd.yaml | 50 + .../resources/original-composition-2.yaml | 33 + ...l-composition-kubectl-applied-current.yaml | 41 + .../original-composition-kubectl-applied.yaml | 40 + .../comp/resources/original-composition.yaml | 33 + .../removal-test-original-composition.yaml | 45 + ...templating-composition-older-revision.yaml | 40 + ...ision-templating-composition-revision.yaml | 39 + .../revision-templating-composition.yaml | 39 + .../comp/resources/sha256-composition.yaml | 33 + .../comp/resources/status-downstream-1.yaml | 11 + .../comp/resources/status-downstream-2.yaml | 11 + .../status-indicator-composition.yaml | 33 + .../testdata/comp/resources/status-xr-1.yaml | 10 + .../testdata/comp/resources/status-xr-2.yaml | 10 + .../comp/resources/test-namespace.yaml | 4 + .../unverified-cluster-composition.yaml | 15 + .../testdata/comp/resources/xrd.yaml | 51 + ...vision-templating-updated-composition.yaml | 43 + .../status-indicator-updated-composition.yaml | 35 + .../comp/updated-claim-composition.yaml | 32 + .../comp/updated-collision-composition.yaml | 50 + .../comp/updated-composition-2-changed.yaml | 36 + .../testdata/comp/updated-composition-2.yaml | 33 + .../comp/updated-composition-labeled.yaml | 35 + .../testdata/comp/updated-composition.yaml | 33 + ...pdated-conditional-gating-composition.yaml | 57 + .../comp/updated-cycle-composition.yaml | 35 + .../comp/updated-parent-composition.yaml | 50 + .../updated-sequencer-gating-composition.yaml | 53 + .../comp/updated-sha256-composition.yaml | 33 + ...pdated-unverified-cluster-composition.yaml | 47 + .../diff/concurrent-xrs/concurrent-xr-1.yaml | 8 + .../diff/concurrent-xrs/concurrent-xr-2.yaml | 8 + .../diff/concurrent-xrs/concurrent-xr-3.yaml | 8 + .../diff/concurrent-xrs/concurrent-xr-4.yaml | 8 + .../diff/concurrent-xrs/concurrent-xr-5.yaml | 8 + ...clusternopresources.nop.crossplane.io.yaml | 61 + .../crds/concurrent-additional-resource.yaml | 34 + .../diff/crds/concurrent-base-resource.yaml | 34 + .../diff/crds/xapimigrateresource-crd.yaml | 84 + .../xdefaulteddownstreamresource-ns-crd.yaml | 32 + .../crds/xdownstreamresource-cluster-crd.yaml | 56 + ...xdownstreamresource-legacycluster-crd.yaml | 56 + .../diff/crds/xdownstreamresource-ns-crd.yaml | 59 + .../diff/crds/xstatusresource-ns-crd.yaml | 66 + .../xversioneddownstreamresource-ns-crd.yaml | 52 + .../existing-xr-revision-ref-omitted.yaml | 16 + .../integration/testdata/diff/first-xr.yaml | 7 + .../testdata/diff/generated-name-xr.yaml | 7 + .../testdata/diff/invalid-schema-xr.yaml | 8 + ...ied-api-version-xr-rev2-changed-field.yaml | 13 + .../diff/modified-api-version-xr-rev2.yaml | 13 + .../testdata/diff/modified-claim-nested.yaml | 12 + ...ified-claim-omitting-defaulted-fields.yaml | 11 + .../testdata/diff/modified-claim.yaml | 10 + .../testdata/diff/modified-cluster-xr.yaml | 6 + .../testdata/diff/modified-cross-ns-xr.yaml | 8 + .../testdata/diff/modified-env-xr.yaml | 7 + ...dified-legacy-xr-manual-upgrade-to-v2.yaml | 11 + ...egacy-xr-manual-v1-omits-revision-ref.yaml | 12 + .../testdata/diff/modified-legacy-xr.yaml | 7 + .../diff/modified-manual-pinned-claim.yaml | 12 + .../testdata/diff/modified-nested-xr.yaml | 8 + .../testdata/diff/modified-xr-automatic.yaml | 11 + .../modified-xr-manual-upgrade-to-v2.yaml | 13 + ...ified-xr-manual-v1-omits-revision-ref.yaml | 16 + .../testdata/diff/modified-xr-manual-v1.yaml | 13 + .../diff/modified-xr-selector-bound.yaml | 13 + .../diff/modified-xr-selector-nomatch.yaml | 14 + .../diff/modified-xr-selector-stable.yaml | 14 + .../diff/modified-xr-switch-to-automatic.yaml | 11 + .../diff/modified-xr-with-external-dep.yaml | 8 + .../testdata/diff/modified-xr.yaml | 7 + .../new-claim-with-claimref-composition.yaml | 10 + .../integration/testdata/diff/new-claim.yaml | 11 + .../testdata/diff/new-cycle-xr.yaml | 8 + .../testdata/diff/new-manual-default-xr.yaml | 15 + .../testdata/diff/new-nested-child-xr.yaml | 9 + .../testdata/diff/new-nested-xr.yaml | 8 + ...arent-claim-omitting-defaulted-fields.yaml | 11 + .../diff/new-rendered-defaults-xrs.yaml | 16 + .../testdata/diff/new-xr-manual-no-ref.yaml | 11 + .../diff/new-xr-missing-namespace-second.yaml | 10 + .../diff/new-xr-missing-namespace.yaml | 13 + .../testdata/diff/new-xr-second.yaml | 7 + .../testdata/diff/new-xr-with-creds.yaml | 10 + .../new-xr-with-cross-ns-external-dep.yaml | 11 + .../integration/testdata/diff/new-xr.yaml | 7 + .../resources/admission-policy-canary.yaml | 15 + .../api-version-composition-revision-v1.yaml | 34 + .../resources/api-version-composition.yaml | 34 + .../claim-composition-with-claimref.yaml | 35 + .../diff/resources/claim-composition.yaml | 31 + .../claim-nested/child-composition.yaml | 34 + .../claim-nested/child-definition.yaml | 29 + .../claim-nested/existing-child-xr.yaml | 11 + .../claim-nested/existing-claim.yaml | 18 + .../existing-managed-resource.yaml | 14 + .../claim-nested/existing-parent-xr.yaml | 24 + .../claim-nested/parent-composition.yaml | 31 + .../parent-defaults-composition.yaml | 35 + .../claim-nested/parent-definition.yaml | 35 + .../testdata/diff/resources/claim-xrd.yaml | 30 + .../testdata/diff/resources/cluster-xrd.yaml | 51 + .../composition-multi-functions.yaml | 80 + .../composition-no-namespace-patch.yaml | 32 + .../composition-revision-preview.yaml | 38 + .../composition-revision-stable.yaml | 38 + .../resources/composition-revision-v1.yaml | 34 + .../diff/resources/composition-v2.yaml | 32 + .../composition-with-builtin-defaults.yaml | 61 + .../resources/composition-with-configmap.yaml | 48 + ...osition-with-crd-defaulted-downstream.yaml | 33 + .../resources/composition-with-defaults.yaml | 16 + ...n-with-non-storage-version-downstream.yaml | 33 + .../resources/composition-with-status.yaml | 41 + .../composition-with-unserved-k8s-group.yaml | 42 + .../testdata/diff/resources/composition.yaml | 33 + .../conditional-gating-composition.yaml | 80 + .../diff/resources/conditional-gating-xr.yaml | 7 + .../credentials/cli-credentials.yaml | 9 + .../credentials/composition-with-creds.yaml | 39 + .../function-credentials-secret.yaml | 14 + .../resources/cross-namespace-configmap.yaml | 21 + .../cross-namespace-fn-composition.yaml | 72 + .../diff/resources/cycle/compositions.yaml | 67 + .../testdata/diff/resources/cycle/xrds.yaml | 100 + .../diff/resources/default-composition.yaml | 36 + .../diff/resources/env-composition.yaml | 46 + .../testdata/diff/resources/env-xrd.yaml | 22 + .../resources/environment-config-v1beta1.yaml | 8 + ...isting-api-version-downstream-v1beta1.yaml | 10 + .../existing-api-version-xr-rev1.yaml | 13 + ...-claim-defaulted-fields-other-manager.yaml | 10 + .../existing-claim-downstream-resource.yaml | 9 + .../diff/resources/existing-claim-xr.yaml | 21 + .../diff/resources/existing-claim.yaml | 18 + .../diff/resources/existing-cluster-xr.yaml | 10 + .../existing-cross-ns-downstream.yaml | 10 + .../diff/resources/existing-cross-ns-xr.yaml | 11 + ...xisting-defaulted-downstream-composed.yaml | 14 + ...ng-defaulted-downstream-other-manager.yaml | 10 + .../existing-downstream-automatic.yaml | 10 + .../existing-downstream-manual-v1.yaml | 10 + ...sting-downstream-resource-with-argocd.yaml | 15 + .../existing-downstream-resource.yaml | 12 + .../existing-downstream-selector-stable.yaml | 10 + ...existing-downstream-with-external-dep.yaml | 11 + ...isting-downstream-with-generated-name.yaml | 13 + .../existing-env-downstream-resource.yaml | 16 + .../diff/resources/existing-env-xr.yaml | 9 + .../resources/existing-generate-name-xr.yaml | 13 + .../existing-legacy-downstream-manual-v1.yaml | 9 + .../existing-legacy-xr-manual-v1.yaml | 11 + .../diff/resources/existing-legacy-xr.yaml | 9 + .../diff/resources/existing-namespace.yaml | 4 + .../existing-other-xr-downstream.yaml | 11 + .../diff/resources/existing-other-xr.yaml | 13 + .../resources/existing-removal-test-xr.yaml | 13 + ...xisting-sequencer-gating-stage0-ready.yaml | 19 + .../existing-sequencer-gating-stage0.yaml | 19 + ...xisting-sequencer-gating-stage1-ready.yaml | 18 + .../existing-sequencer-gating-xr.yaml | 13 + .../diff/resources/existing-sequencer-xr.yaml | 12 + .../existing-v2xrd-v1paths-downstream.yaml | 10 + .../resources/existing-v2xrd-v1paths-xr.yaml | 14 + .../diff/resources/existing-xr-automatic.yaml | 13 + .../diff/resources/existing-xr-deleting.yaml | 14 + ...ing-xr-listing-uncontrolled-resources.yaml | 23 + .../diff/resources/existing-xr-manual-v1.yaml | 13 + .../existing-xr-selector-stable.yaml | 16 + .../resources/existing-xr-with-argocd.yaml | 19 + ...isting-xr-with-defaults-other-manager.yaml | 9 + .../existing-xr-with-defaults-reconciled.yaml | 16 + ...-xr-with-external-dep-multistep-fatal.yaml | 14 + ...isting-xr-with-external-dep-templated.yaml | 13 + ...isting-xr-with-external-dep-unguarded.yaml | 14 + .../existing-xr-with-external-dep.yaml | 11 + .../testdata/diff/resources/existing-xr.yaml | 13 + .../resources/external-named-clusterrole.yaml | 8 + .../external-res-fn-composition.yaml | 64 + .../external-res-gotpl-composition.yaml | 46 + ...ernal-res-matchname-gotpl-composition.yaml | 46 + ...al-resource-configmap-other-namespace.yaml | 22 + .../external-resource-configmap.yaml | 21 + .../testdata/diff/resources/functions.yaml | 39 + .../resources/generated-name-composition.yaml | 32 + .../legacy-composition-revision-v1.yaml | 33 + .../diff/resources/legacy-composition-v2.yaml | 31 + .../testdata/diff/resources/legacy-xrd.yaml | 49 + .../resources/manual-default/definitions.yaml | 96 + .../resources/modified-v2xrd-v1paths-xr.yaml | 13 + ...tep-fatal-after-extra-res-composition.yaml | 65 + .../resources/nested/child-composition.yaml | 33 + ...child-revision-templating-composition.yaml | 38 + .../diff/resources/nested/child-xrd.yaml | 55 + .../existing-child-xr-revision-ref.yaml | 18 + .../existing-child-xr-tier-other-manager.yaml | 9 + .../resources/nested/existing-child-xr.yaml | 13 + ...xisting-managed-resource-revision-ref.yaml | 15 + .../nested/existing-managed-resource.yaml | 11 + .../resources/nested/existing-parent-xr.yaml | 11 + .../resources/nested/parent-composition.yaml | 32 + .../diff/resources/nested/parent-xrd.yaml | 50 + .../resources/pinned-claim/definitions.yaml | 96 + .../existing-automatic-downstream.yaml | 10 + .../pinned-claim/existing-automatic-xr.yaml | 15 + .../pinned-claim/existing-automatic.yaml | 19 + ...existing-lagging-automatic-downstream.yaml | 10 + .../existing-lagging-automatic-xr.yaml | 16 + .../existing-manual-downstream.yaml | 10 + .../pinned-claim/existing-manual-xr.yaml | 17 + .../pinned-claim/existing-manual.yaml | 19 + .../resources/production-composition.yaml | 36 + .../resources/rejecting-admission-policy.yaml | 38 + .../removal-test-cluster-composition.yaml | 31 + ...val-test-cluster-downstream-resource1.yaml | 9 + .../resources/removal-test-composition.yaml | 32 + ...removal-test-legacy-child-composition.yaml | 31 + .../removal-test-legacy-child-xrd.yaml | 24 + .../removal-test-legacy-composition.yaml | 31 + ...removal-test-legacy-nested-xr-managed.yaml | 11 + .../removal-test-legacy-nested-xr.yaml | 13 + ...st-legacycluster-downstream-resource1.yaml | 9 + .../removal-test-nested-xr-managed.yaml | 11 + .../resources/removal-test-nested-xr.yaml | 15 + .../removal-test-ns-downstream-resource1.yaml | 10 + .../removal-test-ns-downstream-resource2.yaml | 10 + ...test-ns-downstream-without-controller.yaml | 15 + .../rendered-defaults/definitions.yaml | 129 + .../selector-bound/existing-downstream.yaml | 11 + .../resources/selector-bound/existing-xr.yaml | 16 + .../diff/resources/selector-bound/setup.yaml | 74 + .../diff/resources/sequencer-composition.yaml | 53 + .../sequencer-gating-composition.yaml | 68 + .../sequencer-stage0-downstream.yaml | 10 + .../sequencer-stage1-downstream.yaml | 10 + .../sequencer-with-envconfig-composition.yaml | 67 + .../sequencer-with-envconfig-xr.yaml | 10 + .../testdata/diff/resources/sequencer-xr.yaml | 7 + .../resources/shared-child-composition.yaml | 35 + .../diff/resources/staging-composition.yaml | 36 + ...uarded-external-res-gotpl-composition.yaml | 44 + .../v2-xrd-with-v1-paths-composition.yaml | 32 + .../diff/resources/v2-xrd-with-v1-paths.yaml | 101 + .../resources/xdownstreamenvresource-xrd.yaml | 31 + .../diff/resources/xrd-concurrent.yaml | 30 + .../diff/resources/xrd-with-defaults.yaml | 79 + .../testdata/diff/resources/xrd.yaml | 51 + .../testdata/diff/shared-child-xrs.yaml | 24 + .../unchanged-automatic-pinned-claim.yaml | 12 + .../diff/unchanged-lagging-automatic-xr.yaml | 11 + .../testdata/diff/unmodified-xr.yaml | 7 + .../diff/xr-with-ambiguous-selector.yaml | 11 + .../diff/xr-with-argocd-annotations.yaml | 13 + .../diff/xr-with-argocd-mixed-changes.yaml | 13 + .../diff/xr-with-composition-ref.yaml | 10 + .../diff/xr-with-composition-selector.yaml | 12 + .../diff/xr-with-missing-defaults.yaml | 9 + .../diff/xr-with-overridden-defaults.yaml | 19 + 360 files changed, 16261 insertions(+) create mode 100644 internal/diff/integration/integration_test.go create mode 100644 internal/diff/integration/it_utils_test.go create mode 100644 internal/diff/integration/main_test.go create mode 100644 internal/diff/integration/simulate_harness_test.go create mode 100644 internal/diff/integration/testdata/comp/api-version-updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/claim-revision-templating-updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/composition-no-changes.yaml create mode 100644 internal/diff/integration/testdata/comp/crds/xapimigrateresource-crd.yaml create mode 100644 internal/diff/integration/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml create mode 100644 internal/diff/integration/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml create mode 100644 internal/diff/integration/testdata/comp/crds/xdownstreamresource-ns-crd.yaml create mode 100644 internal/diff/integration/testdata/comp/field-removal-updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/mixed-status-updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/net-new-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/api-version-original-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/claim-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/claim-xrd.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/custom-namespace.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/cycle/existing-cycle-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-api-version-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-1-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-2-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-downstream-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-downstream-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-conditional-gating-stage0.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-conditional-gating-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-custom-ns-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-custom-ns-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-deleting.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-drifted.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-manual.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-v2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-with-argocd.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-stage0.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-sha256-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-sha256-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-unverified-cluster-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-deleting.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-manual.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-selector-match.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-selector-mismatch.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-v2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/existing-xr-with-argocd.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/field-removal/composition-with-field.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/field-removal/existing-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/field-removal/existing-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/functions-sha256.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/functions.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-db-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-db-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-db-3.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-network-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-network-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-network-3.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-3.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-status-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-xr-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-xr-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/mixed-xr-3.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/nested-xr/existing-child-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-xr.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/nested-xr/parent-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/nested-xr/parent-xrd.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/original-composition-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied-current.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/original-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/removal-test-original-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/revision-templating-composition-older-revision.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/revision-templating-composition-revision.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/revision-templating-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/sha256-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/status-downstream-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/status-downstream-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/status-indicator-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/status-xr-1.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/status-xr-2.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/test-namespace.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/unverified-cluster-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/resources/xrd.yaml create mode 100644 internal/diff/integration/testdata/comp/revision-templating-updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/status-indicator-updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-claim-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-collision-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-composition-2-changed.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-composition-2.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-composition-labeled.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-conditional-gating-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-cycle-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-parent-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-sequencer-gating-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-sha256-composition.yaml create mode 100644 internal/diff/integration/testdata/comp/updated-unverified-cluster-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-1.yaml create mode 100644 internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-2.yaml create mode 100644 internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-3.yaml create mode 100644 internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-4.yaml create mode 100644 internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-5.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/concurrent-additional-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/concurrent-base-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xapimigrateresource-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xdownstreamresource-ns-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xstatusresource-ns-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml create mode 100644 internal/diff/integration/testdata/diff/existing-xr-revision-ref-omitted.yaml create mode 100644 internal/diff/integration/testdata/diff/first-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/generated-name-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/invalid-schema-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-api-version-xr-rev2.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-claim-nested.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-claim-omitting-defaulted-fields.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-claim.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-cluster-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-cross-ns-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-env-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-legacy-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-manual-pinned-claim.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-nested-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-automatic.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-manual-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-selector-bound.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-selector-nomatch.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-selector-stable.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-switch-to-automatic.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr-with-external-dep.yaml create mode 100644 internal/diff/integration/testdata/diff/modified-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/new-claim-with-claimref-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/new-claim.yaml create mode 100644 internal/diff/integration/testdata/diff/new-cycle-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/new-manual-default-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/new-nested-child-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/new-nested-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml create mode 100644 internal/diff/integration/testdata/diff/new-rendered-defaults-xrs.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr-manual-no-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr-missing-namespace-second.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr-missing-namespace.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr-second.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr-with-creds.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr-with-cross-ns-external-dep.yaml create mode 100644 internal/diff/integration/testdata/diff/new-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/admission-policy-canary.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/api-version-composition-revision-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/api-version-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-composition-with-claimref.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/child-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/child-definition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/existing-child-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/existing-claim.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/existing-managed-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/existing-parent-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/parent-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-nested/parent-definition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/claim-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/cluster-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-multi-functions.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-no-namespace-patch.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-revision-preview.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-revision-stable.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-revision-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-v2.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-builtin-defaults.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-configmap.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-defaults.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-status.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-unserved-k8s-group.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/conditional-gating-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/conditional-gating-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/credentials/cli-credentials.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/credentials/composition-with-creds.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/credentials/function-credentials-secret.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/cross-namespace-configmap.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/cross-namespace-fn-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/cycle/compositions.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/cycle/xrds.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/default-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/env-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/env-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/environment-config-v1beta1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-api-version-xr-rev1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-claim-downstream-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-claim-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-claim.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-cluster-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-cross-ns-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-cross-ns-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-composed.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-automatic.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-manual-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-selector-stable.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-with-external-dep.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-downstream-with-generated-name.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-env-downstream-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-env-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-generate-name-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-legacy-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-namespace.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-other-xr-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-other-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-removal-test-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-sequencer-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-automatic.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-deleting.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-manual-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-selector-stable.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-argocd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/existing-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/external-named-clusterrole.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/external-res-fn-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/external-res-gotpl-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/external-resource-configmap-other-namespace.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/external-resource-configmap.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/functions.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/generated-name-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/legacy-composition-revision-v1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/legacy-composition-v2.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/legacy-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/manual-default/definitions.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/child-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/child-revision-templating-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/child-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/existing-child-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/existing-parent-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/parent-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/nested/parent-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/definitions.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/production-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/rejecting-admission-policy.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-cluster-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-legacy-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-nested-xr-managed.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-nested-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/rendered-defaults/definitions.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/selector-bound/existing-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/selector-bound/existing-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/selector-bound/setup.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-gating-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-stage0-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-stage1-downstream.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/sequencer-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/shared-child-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/staging-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/xdownstreamenvresource-xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/xrd-concurrent.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/xrd-with-defaults.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/xrd.yaml create mode 100644 internal/diff/integration/testdata/diff/shared-child-xrs.yaml create mode 100644 internal/diff/integration/testdata/diff/unchanged-automatic-pinned-claim.yaml create mode 100644 internal/diff/integration/testdata/diff/unchanged-lagging-automatic-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/unmodified-xr.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-ambiguous-selector.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-argocd-annotations.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-argocd-mixed-changes.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-composition-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-composition-selector.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-missing-defaults.yaml create mode 100644 internal/diff/integration/testdata/diff/xr-with-overridden-defaults.yaml diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go new file mode 100644 index 00000000..5a0e1396 --- /dev/null +++ b/internal/diff/integration/integration_test.go @@ -0,0 +1,6089 @@ +package integration + +import ( + "bytes" + "context" + "fmt" + "io" + "path/filepath" + run "runtime" + "slices" + "strconv" + "strings" + "sync" + "testing" + "time" + + dclient "github.com/crossplane/cli/v2/internal/diff/client" + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/google/go-cmp/cmp" + "github.com/sergi/go-diff/diffmatchpatch" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + cgoscheme "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + xpextv2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +const ( + defaultTimeout = 60 * time.Second + longTimeout = 180 * time.Second // For tests with multiple iterations (eventual state, concurrent rendering) +) + +// DiffTestType represents the type of diff test to run. +type DiffTestType string + +const ( + XRDiffTest DiffTestType = "diff" + CompositionDiffTest DiffTestType = "comp" +) + +// fieldManagerApply is a manifest to server-side apply under a specific field manager. +type fieldManagerApply struct { + file string + fieldManager string +} + +// IntegrationTestCase represents a common test case structure for both XR and composition diff tests. +type IntegrationTestCase struct { + reason string // Description of what this test validates + setupFiles []string + deleteAfterSetup []string // Files whose resources are deleted after setup; with a finalizer this leaves them Terminating + // crossplaneManagedResources are the XRs that exist in the cluster, with what they compose. Every + // composed resource is server-side applied under Crossplane's composed field manager and given a + // controller ownerReference to its XR's real UID, and each XR lists its composed resources in its + // resourceRefs, as Crossplane leaves them. Use composes() for an XR without nested XRs. Put a + // composed resource in setupFiles instead only when it deliberately has no controlling XR. + crossplaneManagedResources []HierarchicalOwnershipRelation + // fieldManagerApplies are server-side applied, in order, after setupFiles and + // crossplaneManagedResources, each under its own + // field manager. Use it when a test depends on which manager owns a field: a plain Create + // records only the test client as owner, and the apiserver drops managedFields supplied on Create. + fieldManagerApplies []fieldManagerApply + inputFiles []string // Input files to diff (XR YAML files or Composition YAML files) + expectedOutput string + expectedError bool + expectedErrorContains string + expectedStderrContains []string // substrings that must appear on stderr (warnings, error lines) + expectedExitCode int // Expected exit code (0=success, 1=tool error, 2=schema validation, 3=diff detected) + noColor bool + namespace string // For composition tests (optional) + xrdAPIVersion XrdAPIVersion // For XR tests (optional) + ignorePaths []string // Paths to ignore in diffs + functionCredentials string // Path to function credentials file (optional) + eventualState bool // Enable eventual state simulation for XR or composition tests (optional) + timeout time.Duration // Custom timeout for this test (0 = use default) + resources []string // For composition tests: --resource values; each entry passed as one --resource flag + resourcesCSV string // For composition tests: alternative single --resource=a,b style invocation + includeManual bool // For composition tests: pass --include-manual flag + analyzeUnchanged bool // For composition tests: pass the deprecated --analyze-unchanged flag + analyzeOn string // For composition tests: pass --analyze-on= (empty = rely on the default) + // dryRunOn passes --dry-run-on= (empty = rely on the default, which is "all"). + // The flag lives on CommonCmdFields, so it applies to both `xr` and `comp`. + dryRunOn string + // awaitAdmissionDenialFor names a manifest whose resources a correctly-installed + // ValidatingAdmissionPolicy must refuse. Before running the diff, the harness dry-run + // creates each of them until the apiserver rejects it. + // + // This is not belt-and-braces: the apiserver loads admission policies through an informer, + // so a diff issued immediately after the policy is created races the plugin and observes a + // cluster that accepts everything. Measured on envtest 1.32, the policy took ~600ms and four + // attempts to take effect — long enough that without this gate the test would pass or fail + // depending on machine load, and a "pass" would prove nothing. + awaitAdmissionDenialFor string + // assertDeterministicAcrossRuns runs the command twice against the same cluster and requires + // byte-identical stdout, then applies the usual assertions to the second run. Needed for + // server-generated identity: the apiserver runs names.Generator before the dry-run + // short-circuit, so a generateName addition whose identity was not restored would emit a + // different random name on each run — which a single-run test cannot see. + assertDeterministicAcrossRuns bool + skip bool + skipReason string + // JSON output support: set outputFormat to "json" to use structured assertions. + // For XR tests, populate expectedStructuredOutput. For CompositionDiffTest tests, + // populate expectedStructuredCompOutput. Only one should be set per test case. + outputFormat string // "json" or "" (default=visual diff) + // outputVersion, when set, is passed as --output-version (the structured output schema version). + outputVersion string + // Typed as the assertion interfaces rather than the concrete root builders. The assert functions + // have accepted any builder level since those interfaces were introduced, specifically so a chain + // need not climb back to the root — but concrete field types here made that unreachable, which is + // why every case in this file used to end with a trailing And()/AndXR()/AndComp(). A chain can now + // simply end where it ends. + expectedStructuredOutput tu.DiffExpectation // for XR JSON output assertions + expectedStructuredCompOutput tu.CompDiffExpectation // for comp JSON output assertions +} + +type XrdAPIVersion int + +const ( + V2 XrdAPIVersion = iota // v2 comes first so that this is the default value + V1 +) + +var versionNames = map[XrdAPIVersion]string{ + V1: "apiextensions.crossplane.io/v1", + V2: "apiextensions.crossplane.io/v2", +} + +func (s XrdAPIVersion) String() string { + return versionNames[s] +} + +// createTestScheme creates a runtime scheme with all required types registered. +// Each parallel test needs its own scheme because envtest modifies it during CRD installation. +func createTestScheme() *runtime.Scheme { + scheme := runtime.NewScheme() + + // Register Kubernetes types + _ = cgoscheme.AddToScheme(scheme) + + // Register Crossplane types + _ = xpextv1.AddToScheme(scheme) + _ = xpextv2.AddToScheme(scheme) + _ = pkgv1.AddToScheme(scheme) + _ = extv1.AddToScheme(scheme) + + return scheme +} + +// startIntegrationCluster starts a test case's envtest cluster and puts its setup state in place: +// tt's setupFiles, crossplaneManagedResources, fieldManagerApplies and deleteAfterSetup. It returns the +// cluster's config, a client for it, and a context bounded by the case's timeout, which it also returns. +// The cluster is stopped and the context cancelled when the test ends. +func startIntegrationCluster(t *testing.T, testType DiffTestType, tt IntegrationTestCase) (*rest.Config, client.Client, context.Context, time.Duration) { + t.Helper() + + // Create a fresh scheme for each test to avoid concurrent map access. + // Each parallel test needs its own scheme because envtest modifies it during CRD installation. + scheme := createTestScheme() + + // Setup a brand new test environment for each test case + _, thisFile, _, ok := run.Caller(0) + if !ok { + t.Fatal("failed to get caller information") + } + + thisDir := filepath.Dir(thisFile) + + // Plain CRDs are read from these directories: Crossplane's own, at the crossplane version go.mod + // pins (so they match the xcrd and other Crossplane code under test), and stand-ins for managed + // resources. XR and claim CRDs are not: they are generated from the XRDs the test applies. + crdPaths := []string{ + tu.PinnedCrossplaneCRDsDir(t), + filepath.Join(thisDir, "testdata", string(testType), "crds"), + } + + crds, err := envtestCRDs(crdPaths, tt.setupFiles) + if err != nil { + t.Fatalf("failed to assemble CRDs for the test environment: %v", err) + } + + testEnv := &envtest.Environment{ + CRDs: crds, + Scheme: scheme, + // Note: Leaving ControlPlane unset (nil) allows envtest to create its own control plane + // with random ports, which enables parallel test execution without port conflicts. + // Each test gets its own isolated API server and etcd instance on random available ports. + } + + // Start the test environment + cfg, err := testEnv.Start() + if err != nil { + t.Fatalf("failed to start test environment: %v", err) + } + + // Ensure we clean up at the end of the test + t.Cleanup(func() { + err := testEnv.Stop() + if err != nil { + t.Logf("failed to stop test environment: %v", err) + } + }) + + // Create a controller-runtime client for setup operations + k8sClient, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + // Use custom timeout if specified, otherwise use default + testTimeout := defaultTimeout + if tt.timeout > 0 { + testTimeout = tt.timeout + } + + ctx, cancel := context.WithTimeout(t.Context(), testTimeout) + t.Cleanup(cancel) + + // Envtest runs no Crossplane controllers, so the harness fills in what they would have written to the + // setup fixtures, wherever a fixture leaves it out. + defaults := newCrossplaneDefaults(k8sClient, crds) + + // Apply the setup resources + if err := applyResourcesFromFiles(ctx, k8sClient, defaults, tt.setupFiles); err != nil { + t.Fatalf("failed to setup resources: %v", err) + } + + // No composition revision controller runs either: do what it would. + if err := reconcileCompositionRevisions(ctx, k8sClient); err != nil { + t.Fatalf("failed to reconcile CompositionRevisions: %v", err) + } + + // Default to v2 API version for XR resources unless otherwise specified + xrdAPIVersion := V2 + if tt.xrdAPIVersion != V2 { + xrdAPIVersion = tt.xrdAPIVersion + } + + // Apply Crossplane-managed resources (XRs, composed resources) using SSA with Crossplane field manager. + // These resources simulate what Crossplane actually manages in production. They are in place before + // fieldManagerApplies run, since those model another manager writing to objects that already exist. + if len(tt.crossplaneManagedResources) > 0 { + err := applyHierarchicalOwnership(ctx, tu.TestLogger(t, false), k8sClient, defaults, xrdAPIVersion, tt.crossplaneManagedResources) + if err != nil { + t.Fatalf("failed to setup Crossplane-managed resources: %v", err) + } + } + + for _, a := range tt.fieldManagerApplies { + resources, err := readResourcesFromFile(a.file) + if err != nil { + t.Fatalf("failed to read %s: %v", a.file, err) + } + + for _, r := range resources { + if err := applyResourceWithSSA(ctx, k8sClient, r, a.fieldManager); err != nil { + t.Fatalf("failed to setup field-managed resources: %v", err) + } + } + } + + // Issue a delete for resources that should be observed mid-deletion. The manifests must + // carry a finalizer so envtest (which runs no controllers) leaves the object in place with + // metadata.deletionTimestamp set, reproducing a real "Terminating" resource. + if err := deleteResourcesFromFiles(ctx, k8sClient, tt.deleteAfterSetup); err != nil { + t.Fatalf("failed to delete resources: %v", err) + } + + return cfg, k8sClient, ctx, testTimeout +} + +// runIntegrationTest runs a single integration test case for both XR and composition diff commands. +func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestCase) { + t.Helper() + + t.Parallel() // Enable parallel test execution + + // Validate structured-output config up front so misconfiguration fails loudly + // instead of silently routing assertions to the wrong path. + if tt.expectedStructuredOutput != nil && tt.expectedStructuredCompOutput != nil { + t.Fatalf("test case sets both expectedStructuredOutput and expectedStructuredCompOutput; set only one") + } + + if tt.expectedStructuredOutput != nil && testType != XRDiffTest { + t.Fatalf("expectedStructuredOutput is only valid for XRDiffTest (got %q)", testType) + } + + if tt.expectedStructuredCompOutput != nil && testType != CompositionDiffTest { + t.Fatalf("expectedStructuredCompOutput is only valid for CompositionDiffTest (got %q)", testType) + } + + // Resolve a local crossplane binary if one is present. When non-empty, + // it's threaded into the kong arg slice below as + // --crossplane-render-binary= so each parallel subtest has its own + // copy with no shared process state. When empty, the diff command falls + // through to the docker render engine — slower but works wherever Docker + // does (including the Earthfile's go-test target's WITH DOCKER block). + crossplaneBin := localCrossplaneBinary(t) + + // Skip test if requested + if tt.skip { + t.Skip(tt.skipReason) + return + } + + cfg, k8sClient, ctx, testTimeout := startIntegrationCluster(t, testType, tt) + + // Set up the test files + testFiles := make([]string, 0, len(tt.inputFiles)) + + // Handle any additional input files + // Note: NewCompositeLoader handles both individual files and directories, + // so we can pass paths directly without special handling + testFiles = append(testFiles, tt.inputFiles...) + + // Capture stdout and stderr into separate buffers. The diff command + // writes structured output (JSON / YAML / text diff) to stdout and + // human-readable error lines to stderr; mixing them defeats JSON + // parsing for tests that drive the structured assertion path. + var stdout, stderr bytes.Buffer + + // Create command line args that match your pre-populated struct + args := []string{ + fmt.Sprintf("--timeout=%s", testTimeout.String()), + } + + // Only thread --crossplane-render-binary when a local binary exists; an + // empty value would still parse but waste cycles, and a missing flag + // lets the diff command pick the docker render engine cleanly. + if crossplaneBin != "" { + args = append(args, fmt.Sprintf("--crossplane-render-binary=%s", crossplaneBin)) + } + + // Add namespace if specified (for composition tests only) + if tt.namespace != "" && testType == CompositionDiffTest { + args = append(args, fmt.Sprintf("--namespace=%s", tt.namespace)) + } + + // Add no-color flag if true + if tt.noColor { + args = append(args, "--no-color") + } + + // Add JSON output format if specified + if tt.outputFormat == "json" { + args = append(args, "--output=json") + } + + if tt.outputVersion != "" { + args = append(args, fmt.Sprintf("--output-version=%s", tt.outputVersion)) + } + + // Add ignore-paths if specified + if len(tt.ignorePaths) > 0 { + for _, path := range tt.ignorePaths { + args = append(args, fmt.Sprintf("--ignore-paths=%s", path)) + } + } + + // Add function-credentials if specified + if tt.functionCredentials != "" { + args = append(args, fmt.Sprintf("--function-credentials=%s", tt.functionCredentials)) + } + + // Add eventual-state flag if specified (supported by both `xr` and `comp`). + if tt.eventualState { + args = append(args, "--eventual-state") + } + + // --dry-run-on lives on CommonCmdFields, so unlike --analyze-on it is threaded for both + // command types. An empty value relies on the flag's own default. + if tt.dryRunOn != "" { + args = append(args, "--dry-run-on="+tt.dryRunOn) + } + + // Add --resource flags (composition tests only) + if testType == CompositionDiffTest { + for _, r := range tt.resources { + args = append(args, fmt.Sprintf("--resource=%s", r)) + } + + if tt.resourcesCSV != "" { + args = append(args, fmt.Sprintf("--resource=%s", tt.resourcesCSV)) + } + + if tt.includeManual { + args = append(args, "--include-manual") + } + + if tt.analyzeUnchanged { + args = append(args, "--analyze-unchanged") + } + + if tt.analyzeOn != "" { + args = append(args, "--analyze-on="+tt.analyzeOn) + } + } + + // Add files as positional arguments + args = append(args, testFiles...) + + // Skip cases the current simulate surface cannot express, before the cluster starts, so a + // skipped case costs nothing. The gap reasons are greppable; see the harness package comment. + if reason := simulateGap(testType, parseSimulateArgs(t, testType, args)); reason != "" { + t.Skip(reason) + return + } + + // The render engine runs functions as Docker containers. Without a Docker daemon no rendering + // case can run; cases that would not render anyway cannot be told apart upfront, so they skip. + if !dockerAvailable() { + t.Skip("env: no Docker daemon for the function render engine") + return + } + + // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff + // cannot race the apiserver's policy informer. See the field's doc comment. + if err := awaitAdmissionDenial(ctx, k8sClient, tt.awaitAdmissionDenialFor); err != nil { + t.Fatalf("admission policy never took effect: %v", err) + } + + // runOnce executes the command against the running cluster, leaving its output in stdout / + // stderr. Everything the command layer touches is rebuilt per call, so a second run starts + // with cold client caches just as a second invocation of the CLI would. + runOnce := func() (int, error) { + stdout.Reset() + stderr.Reset() + + return runSimulate(t, cfg, testType, args, &stdout, &stderr) + } + + exitCode, err := runOnce() + + if tt.assertDeterministicAcrossRuns { + first := stdout.String() + + exitCode, err = runOnce() + + if second := stdout.String(); second != first { + t.Errorf("output differs between two runs against the same cluster; a value the apiserver generated has leaked into the diff.\nfirst run:\n%s\nsecond run:\n%s", first, second) + } + } + + // Check exit code matches expected + if exitCode != tt.expectedExitCode { + t.Errorf("expected exit code %d, got %d", tt.expectedExitCode, exitCode) + } + + // Assert stderr expectations before any of the early returns below, so they apply in every + // output mode (human-readable, JSON, and expected-error cases alike). + for _, want := range tt.expectedStderrContains { + if !strings.Contains(stderr.String(), want) { + t.Errorf("expected stderr to contain %q, got:\n%s", want, stderr.String()) + } + } + + if tt.expectedError && err == nil { + t.Fatal("expected error but got none") + } + + if !tt.expectedError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + // Check for specific error message if expected. + // + // Three modes: + // 1. expectedError + expectedErrorContains: substring-check the error. + // 2. expectedError + structured assertion: any error is fine here; + // the structured assertion below validates errors[]. + // 3. unexpected error: already caught by the t.Fatalf above. + if err != nil && tt.expectedError { + hasStructuredAssertion := tt.expectedStructuredOutput != nil || tt.expectedStructuredCompOutput != nil + + switch { + case tt.expectedErrorContains != "": + if strings.Contains(err.Error(), tt.expectedErrorContains) { + t.Logf("Got expected error containing: %s", tt.expectedErrorContains) + } else { + t.Errorf("Expected error containing %q, got: %v", tt.expectedErrorContains, err) + } + case hasStructuredAssertion: + t.Logf("Got expected error; structured assertion will validate errors[]") + default: + t.Errorf("expectedError set without expectedErrorContains or structured assertion; got: %v", err) + } + } + + // Skip stdout check for expected-error cases that use the legacy + // substring assertion. Cases that pin a structured assertion fall + // through so the JSON path is exercised against errors[] / changes. + if tt.expectedError && tt.expectedErrorContains != "" && + tt.expectedStructuredOutput == nil && tt.expectedStructuredCompOutput == nil { + return + } + + // Check the output + outputStr := stdout.String() + + // Handle JSON output format with structured assertions + if tt.outputFormat == "json" && tt.expectedStructuredOutput != nil { + tu.AssertStructuredDiff(t, outputStr, tt.expectedStructuredOutput) + return + } + + if tt.outputFormat == "json" && tt.expectedStructuredCompOutput != nil { + tu.AssertStructuredCompDiff(t, outputStr, tt.expectedStructuredCompOutput) + return + } + + // Using TrimSpace because the output might have trailing newlines + if !strings.Contains(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { + // Strings aren't equal, *including* ansi. but we can compare ignoring ansi to determine what output to + // show for the failure. if the difference is only in color codes, we'll show escaped ansi codes. + out := outputStr + + expect := tt.expectedOutput + if tu.CompareIgnoringAnsi(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { + out = strconv.QuoteToASCII(outputStr) + expect = strconv.QuoteToASCII(tt.expectedOutput) + } + + t.Fatalf("expected output to contain:\n%s\n\nbut got:\n%s", expect, out) + } +} + +// TestDiffIntegration runs an integration test for the diff command. +func TestDiffIntegration(t *testing.T) { + t.Parallel() + + // Set up logger for controller-runtime (global setup, once per test function) + tu.SetupKubeTestLogger(t) + + tests := map[string]IntegrationTestCase{ + "NewResourceDiff": { + reason: "Shows color diff for new resources", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + // TODO: For v2, we need to upgrade the XRD apiversion to v2 + put the xr in a namespace. + // this might be better served as a separate test(s). + // since we aren't actually creating resources (there's no crossplane actually running in unit tests), + // we don't need to worry about upgrading providers or anything. + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedOutput: strings.Join([]string{ + `+++ XDownstreamResource/test-resource +`, tu.Green(`+ apiVersion: ns.nop.example.org/v1alpha1 ++ kind: XDownstreamResource ++ metadata: ++ annotations: ++ crossplane.io/composition-resource-name: nop-resource ++ labels: ++ crossplane.io/composite: test-resource ++ name: test-resource ++ namespace: default ++ spec: ++ forProvider: ++ configData: new-value +`), ` +--- ++++ XNopResource/test-resource +`, tu.Green(`+ apiVersion: ns.diff.example.org/v1alpha1 ++ kind: XNopResource ++ metadata: ++ name: test-resource ++ namespace: default ++ spec: ++ coolField: new-value +`), ` +--- +`, + }, ""), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "AutomaticNamespacePropagation": { + reason: "Validates automatic namespace propagation for namespaced managed resources", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-no-namespace-patch.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A built-in Kubernetes type has no CRD, so this exercises the paths + // that would otherwise read scope and schema from one. Before scope + // came from discovery this failed outright, with "cannot determine + // scope for resource ConfigMap/... CRD not found". + "RendersBuiltInResourceWithoutCRD": { + reason: "A composition rendering a built-in Kubernetes resource (no CRD) produces a diff for it", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-configmap.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(3, 0, 0). + WithAddedResource("ConfigMap", "test-resource-config", "default"). + WithField("data.configData", "new-value"). + And(). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "UnservedGroupIsReportedAsAnUnknownTypeNotAMissingNamespace": { + // From the PR #502 review. A resource whose group/version the cluster does not serve must be + // reported as an unknown type; blaming its namespace would send the user after the wrong root + // cause entirely, since the namespace here exists and is irrelevant. + // + // Worth being precise about what this covers, because it is NOT the sentinel branch in + // dryRunCreateAddition. For a *.k8s.io group, two earlier guards let the resource through — + // IsCRDRequired skips discovery for that suffix, and FetchCurrentObject swallows NotFound as + // "this is new" — but removeNamespacesFromClusterScopedResources then fails first, because + // scope determination cannot resolve the GVK either. So this pins the outer behaviour (an + // accurate, type-naming error rather than a namespace one) without depending on which layer + // produces it. + // + // The sentinel branch is what covers the case this CANNOT reach: scope determination falls + // back to a CRD lookup when discovery fails, whereas GVKToGVR is discovery-only, so a CRD + // whose version is not `served` passes the scope check and fails inside DryRunCreate. That + // route is unit-covered in diff_calculator_test.go, since arranging a served:false CRD whose + // scope still resolves is far more setup than the classification warrants. + reason: "An addition whose group/version the cluster does not serve is reported as an unknown type, not as a missing namespace", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-unserved-k8s-group.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedError: true, + expectedErrorContains: "totallynotserved.k8s.io/v1", + expectedExitCode: dp.ExitCodeToolError, + }, + "AdditionInMissingNamespaceDegradesRatherThanFailing": { + // Regression test for the e2e failure the first cut of this feature caused + // (TestDiffConcurrentDirectory diffs 21 XRs into a namespace that is never created). + // + // NamespaceLifecycle admission refuses a dry-run create into a namespace that does not exist. + // That must degrade, not fail the run: a Namespace and its contents are routinely applied + // together, so the namespace's absence at diff time says nothing about whether the apply will + // succeed — which is what separates it from a quota or webhook refusal, where the verdict + // describes the object and will still hold. + // + // The exit code is the real assertion. Before the fix this was exit 1 with no diff at all. + reason: "An addition whose namespace does not exist yet still produces a diff, marked as unverified", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr-missing-namespace.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "test-resource", "nonexistent-namespace"). + WithField("spec.coolField", "new-value"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"). + And(). + WithAddedResource("XDownstreamResource", "test-resource", "nonexistent-namespace"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"). + And(). + // One warning per GVK, and each cause exactly the apiserver's message. Through the REAL + // ApplyClient, whose error is wrapped with "failed to dry-run create resource /": + // if that wrapper leaked into the cause, every resource would be a distinct warning. The unit + // test mirrors the wrapping by hand; this pins it against the client that actually produces it. + WithWarning("their namespace does not exist yet"). + WithWarningContext(map[string]string{ + "gvk": "ns.diff.example.org/v1alpha1, Kind=XNopResource", + "namespace": "nonexistent-namespace", + "cause": `namespaces "nonexistent-namespace" not found`, + "count": "1", + }). + And(). + WithWarning("their namespace does not exist yet"). + WithWarningContext(map[string]string{ + "gvk": "ns.nop.example.org/v1alpha1, Kind=XDownstreamResource", + "namespace": "nonexistent-namespace", + "cause": `namespaces "nonexistent-namespace" not found`, + "count": "1", + }), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Human-readable output shows no dryRun at all, so stderr is the only place a person learns that + // additions went unverified, and how many. Two inputs into one missing namespace share each GVK's + // warning rather than raising one per resource. + "UnverifiedAdditionsAcrossInputsAreCountedInOneWarning": { + reason: "In text mode, additions that could not be verified are summarised on stderr once per GVK + namespace + cause, with a count of the resources behind it", + inputFiles: []string{ + "testdata/diff/new-xr-missing-namespace.yaml", + "testdata/diff/new-xr-missing-namespace-second.yaml", + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + noColor: true, + expectedStderrContains: []string{ + `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=ns.diff.example.org/v1alpha1, Kind=XNopResource, namespace=nonexistent-namespace)`, + `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=ns.nop.example.org/v1alpha1, Kind=XDownstreamResource, namespace=nonexistent-namespace)`, + }, + expectedOutput: "+++ XDownstreamResource/second-resource", + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "BuiltInResourceAdditionPicksUpApiserverDefaults": { + // The choice of a built-in type keeps this test non-vacuous whatever crossplane-diff defaults + // locally. The lenient Defaulter (defaulter.go) predicts CRD-derived `default:` values for an + // addition that gets no apiserver result, and passes anything without a CRD through unchanged. + // Deployment has no CRD, so IsCRDRequired is false, and the fields asserted below can ONLY have + // come from round-tripping the addition through the apiserver. + // + // Every asserted default is string-valued on purpose: assertChangeFields compares with + // reflect.DeepEqual against JSON-decoded values, so a numeric default (revisionHistoryLimit: 10) + // would arrive as float64 and an int literal here would fail for the wrong reason. + reason: "An added built-in resource is dry-run created against the apiserver, so apiserver-side defaults appear in its addition diff", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-builtin-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("Deployment", "test-resource-deploy", "default"). + // Absence of dryRun is the machine-readable claim that this desired state was + // verified against the apiserver. Asserted alongside the defaults so that a + // regression which stopped dry-running additions fails on the contract as well as + // on whichever fields happen to depend on server-side defaulting. + WithDryRunPerformed(). + WithField("spec.strategy.type", "RollingUpdate"). + WithField("spec.template.spec.restartPolicy", "Always"). + WithField("spec.template.spec.dnsPolicy", "ClusterFirst"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithDryRunPerformed(). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The inverse of the case above, and the pair is what makes either meaningful: on its own, + // "the defaults appear" could be satisfied by something other than the apiserver round-trip, + // and "the defaults are absent" could be satisfied by the feature being broken. Together they + // pin the flag as the thing that decides. + // + // This also pins that `existing` reproduces the behaviour from before additions were dry-run + // (#334), which is why the fields are asserted absent rather than merely unasserted. + "BuiltInResourceAdditionSkipsApiserverWhenDryRunOnExisting": { + reason: "With --dry-run-on=existing an added built-in resource is not sent to the apiserver, so its diff carries no server-side defaults and reports skipReason disabled", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-builtin-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("Deployment", "test-resource-deploy", "default"). + // No detail substring: "disabled" is the one skip reason the user asked for, so + // there is no cluster explanation to propagate. + WithDryRunSkipped("disabled", ""). + WithFieldAbsent("spec.strategy.type"). + WithFieldAbsent("spec.template.spec.restartPolicy"). + WithFieldAbsent("spec.template.spec.dnsPolicy"). + // The rendered fields must still be there — `existing` degrades fidelity, it does + // not degrade the diff. + WithField("spec.template.spec.containers[0].image", "nginx:1.27"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A status authored by the composition pipeline must survive the apiserver round-trip. + // XStatusResource declares the status subresource, so the apiserver strips status from the + // dry-run create and returns an object without one; the value below can therefore only come + // from the calculator re-attaching what was rendered. Every other composed kind in this + // testdata tree omits the subresource, which would have made this assertion vacuous. + // + // SKIPPED, and the reason is a finding rather than an environment problem: the re-attach + // works, but nothing renders it. See skipReason. + "AddedResourceRetainsCompositionAuthoredStatus": { + skip: true, + skipReason: "The re-attached status cannot be observed in any output, so this assertion " + + "cannot pass and could not be made to fail by breaking the production code it targets. " + + "cleanupForDiff (renderer/diff_formatter.go) deletes metadata.status unconditionally from " + + "both sides of every diff, in the same block that deletes ownerReferences — the strip whose " + + "identical consequence for ownerReferences this feature's own spec documented (§1a) and then " + + "missed for status. ResourceViews.Clean is the only view any renderer reads " + + "(structured_renderer.go:462-472); ResourceViews.Desired.Raw, which does carry the " + + "re-attached status, has no reader at all. Verified empirically: the apiserver's dry-run " + + "create response for XStatusResource carries no status, the object handed to cleanupForDiff " + + "carries status.phase=authored-by-composition, and the object cleanupForDiff returns does " + + "not. Deciding whether status belongs in diff output is a rendering-design question well " + + "outside a test task, so this is left failing-by-skip with its instrument intact rather " + + "than quietly reworded into an assertion that passes.", + reason: "A status written by the composition pipeline survives the dry-run create of an added resource", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-status.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XStatusResource", "test-resource", "default"). + WithDryRunPerformed(). + WithField("status.phase", "authored-by-composition"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The apiserver runs names.Generator in rest.BeforeCreate, ahead of the dry-run + // short-circuit at the storage layer, so it mints a real random name even for a request it + // never persists. Unless that identity is restored, a generateName addition's diff differs on + // every invocation — which only a repeated run can detect, hence assertDeterministicAcrossRuns. + "GenerateNameAdditionIsDeterministicAcrossRuns": { + reason: "An addition using generateName renders identically across two runs, i.e. no server-generated name leaks into the diff", + outputFormat: "json", + dryRunOn: "all", + assertDeterministicAcrossRuns: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + // Both the XR and its composed resource use generateName, so both exercise the + // identity restore. The pattern is the renderer's display form for a + // not-yet-named object; a leaked server name would be a random suffix instead. + WithAddedResource("XDownstreamResource", "", "default"). + WithDryRunPerformed(). + WithNamePattern(`^test-resource-\(generated\)$`). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "", "default"). + WithDryRunPerformed(). + WithNamePattern(`^generated-xr-\(generated\)$`). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Cluster rejection of an ADDITION. A ValidatingAdmissionPolicy is the instrument rather than a CEL + // x-kubernetes-validations rule on the CRD, because crossplane-diff evaluates CEL rules + // locally before the diff calculator runs: a CEL rule would reject the resource without the + // apiserver ever being consulted, so the test would pass on code that never dry-ran anything. + "AddedResourceRejectedByAdmissionPolicyIsAValidationFailure": { + reason: "An apiserver rejection of an added resource is exit 2 with a typed admission validation failure, not a bare tool error", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/rejecting-admission-policy.yaml", + }, + awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", + expectedError: true, + expectedExitCode: dp.ExitCodeSchemaValidation, + expectedStructuredOutput: tu.ExpectDiff(). + // errors[].resourceID names the input the user supplied, so the rejected composed + // resource has to be identifiable from the message and the validationFailures entry. + WithError("XNopResource/test-resource"). + WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). + WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). + WithStatus("invalid"). + // The "admission" type is what distinguishes a cluster rejection from a local schema + // rejection; field is empty because an admission refusal is not localized to one field. + WithFieldError("admission", ""). + WithMessageContaining("this cluster refuses that configData value"), + }, + // Cluster rejection of a MODIFICATION — the deliberate behaviour change. The identical cluster fact used to + // surface as a plain tool error (exit 1) for an existing resource while an addition's rejection + // would have been exit 2, i.e. the same rejection reported in two different tiers depending + // only on whether the resource happened to exist. This pins the reclassification. + "ModifiedResourceRejectedByAdmissionPolicyIsAValidationFailure": { + reason: "An apiserver rejection of an EXISTING resource's dry-run apply is reported in the same tier as a rejected addition", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/rejecting-admission-policy.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedError: true, + expectedExitCode: dp.ExitCodeSchemaValidation, + expectedStructuredOutput: tu.ExpectDiff(). + WithError("XNopResource/test-resource"). + WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). + WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). + WithStatus("invalid"). + WithFieldError("admission", ""). + WithMessageContaining("this cluster refuses that configData value"), + }, + "MultipleXRsGroupedByInputXR": { + reason: "Two input XRs in one invocation are grouped per input XR in the xrs[] structured view", + outputFormat: "json", + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/new-xr-second.yaml", + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + // Aggregate flat view still reports the merged total (2 adds per XR). + WithSummary(4, 0, 0). + // Grouped view: one XR(...) sub-expression per input XR, each with + // its own changed tree. + WithXRs( + tu.XR("XNopResource", "test-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + Change("added", "XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"), + tu.XR("XNopResource", "second-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "second-resource", "default"). + WithField("spec.coolField", "second-value"). + Change("added", "XDownstreamResource", "second-resource", "default"). + WithField("spec.forProvider.configData", "second-value"), + ). + Build(), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MultipleXRsGroupedByInputXRV2": { + reason: "With --output-version=v2, the xrs[] entries carry each input's changes and the deprecated flat changes[] is gone", + outputFormat: "json", + outputVersion: "v2", + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/new-xr-second.yaml", + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithOutputVersion("v2"). + // The top-level summary stays in v2: it counts distinct resources. + WithSummary(4, 0, 0). + WithXRs( + tu.XR("XNopResource", "test-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + Change("added", "XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"), + tu.XR("XNopResource", "second-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "second-resource", "default"). + WithField("spec.coolField", "second-value"). + Change("added", "XDownstreamResource", "second-resource", "default"). + WithField("spec.forProvider.configData", "second-value"), + ), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ModifiedResourceDiff": { + reason: "Shows color diff for modified resources", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // put an existing XR in the cluster to diff against + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedOutput: ` +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +` + tu.Red("- configData: existing-value") + ` +` + tu.Green("+ configData: modified-value") + ` + +--- +~~~ XNopResource/test-resource + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: +` + tu.Red("- coolField: existing-value") + ` +` + tu.Green("+ coolField: modified-value") + ` + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-b616215 + compositionUpdatePolicy: Automatic + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "IgnorePathsArgoCD": { + reason: "Ignores ArgoCD annotations and labels when --ignore-paths is specified", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // put an existing resource with different ArgoCD annotations + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-with-argocd.yaml", + "testdata/diff/resources/existing-downstream-resource-with-argocd.yaml", + ), + }, + inputFiles: []string{"testdata/diff/xr-with-argocd-annotations.yaml"}, + ignorePaths: []string{ + "metadata.annotations[argocd.argoproj.io/tracking-id]", + "metadata.labels[argocd.argoproj.io/instance]", + }, + outputFormat: "json", + expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedError: false, + }, + "IgnorePathsMixedChanges": { + // Regression test: when a resource has BOTH ignored path changes + // AND non-ignored spec changes, the summary must count the + // resource as modified once and the emitted diff bodies must not + // leak the ignored paths. The renderer-level unit test + // TestStructuredDiffRenderer_RespectsIgnorePaths asserts the + // absence of ignored fields in diff.old / diff.new; this test + // pins the summary counts and the visible non-ignored changes + // through the full CLI stack. + reason: "Ignores ArgoCD annotations/labels while still reporting a non-ignored spec change", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-with-argocd.yaml", + "testdata/diff/resources/existing-downstream-resource-with-argocd.yaml", + ), + }, + inputFiles: []string{"testdata/diff/xr-with-argocd-mixed-changes.yaml"}, + ignorePaths: []string{ + "metadata.annotations[argocd.argoproj.io/tracking-id]", + "metadata.labels[argocd.argoproj.io/instance]", + }, + outputFormat: "json", + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "new-value", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "new-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ModifiedXRCreatesDownstream": { + reason: "Shows diff when modified XR creates new downstream resource", + outputFormat: "json", + // The XR has its composition refs but no composed resource yet: Crossplane selects the + // composition and revision before it composes, so this is an XR whose first compose has not + // created its resource (a function error, say). That is what makes the resource an addition. + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/existing-xr.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 1, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #452, `xr` side, delivered through the warning channel added for #459. Unlike `comp`, + // which excludes deleting XRs from impact analysis, `xr` diffs exactly the resource the user + // named — so it still emits the diff and raises an advisory. This asserts BOTH halves of the + // dual emission: the stderr line for humans and the warnings[] entry for machines. + "DeletingXRWarnsButStillDiffs": { + reason: "xr against an XR whose cluster copy is being deleted still emits the diff, plus a warning on stderr and in warnings[]", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // A Terminating XR still has the resource it composed until garbage collection removes it. + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/diff/resources/existing-xr-deleting.yaml", "testdata/diff/resources/existing-downstream-resource.yaml"), + }, + deleteAfterSetup: []string{"testdata/diff/resources/existing-xr-deleting.yaml"}, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + And(). + WithWarning("being deleted in the cluster"), + expectedStderrContains: []string{ + "WARNING: The resource being diffed is being deleted in the cluster", + "resource=XNopResource/test-resource", + }, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #495: the XR still references a composed resource that was deleted out of band. The + // resource tree records that child as NotFound, which is tolerated: the resource really is + // gone, so it is neither observed nor a removal — but the user is told. The other per-node + // errors (Forbidden, transport) are fatal; envtest grants the harness everything, so those are + // covered by the FetchObservedResources unit tests instead. + "ComposedResourceDeletedOutOfBandWarns": { + reason: "xr against an XR whose resourceRefs name a deleted composed resource omits it from observed state and warns", + outputFormat: "json", + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, + "testdata/diff/resources/removal-test-ns-downstream-resource2.yaml": nil, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + deleteAfterSetup: []string{"testdata/diff/resources/removal-test-ns-downstream-resource2.yaml"}, + inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 0, 0). + WithWarning("was not found in the cluster"). + WithWarningContext(map[string]string{ + "resource": "XDownstreamResource/resource-to-be-removed", + "namespace": "default", + }), + expectedStderrContains: []string{ + "WARNING: A composed resource referenced by its XR was not found in the cluster", + "resource=XDownstreamResource/resource-to-be-removed", + }, + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, + }, + "EnvironmentConfigIncorporation": { + reason: "Validates EnvironmentConfig (v1beta1) incorporation in diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/env-xrd.yaml", + "testdata/diff/resources/env-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/environment-config-v1beta1.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-env-xr.yaml", + "testdata/diff/resources/existing-env-downstream-resource.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/modified-env-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamEnvResource", "test-env-resource", ""). + WithFieldChange("spec.forProvider.configData", "existing-config-value", "modified-config-value"). + And(). + WithModifiedResource("XEnvResource", "test-env-resource", ""). + WithFieldChange("spec.configKey", "existing-config-value", "modified-config-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ExternalResourceDependencies": { + reason: "Validates diff with external resource dependencies via fn-external-resources", + outputFormat: "json", + // this test does a weird thing where it changes the XR but all the downstream changes come from external + // resources, including a field path from the XR itself. + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/external-res-fn-composition.yaml", + "testdata/diff/resources/external-named-clusterrole.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-with-external-dep.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "testing-external-resource-data"). + WithFieldChange("spec.forProvider.roleName", "old-role-name", "external-named-clusterrole"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-with-external-dep"). + WithFieldChange("spec.environment", "staging", "testing"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Same-namespace case: the matchLabels ExtraResources selector omits the + // namespace, and the matched ConfigMap lives in "default" — the SAME + // namespace as the XR. This passed even before the #376 fix, because the + // old resolveNamespace defaulted an empty selector namespace to the XR + // namespace, which happened to be where the ConfigMap was. See + // "TemplatedExtraResourcesCrossNamespace" for the cross-namespace case + // that requires the all-namespaces lookup. + "TemplatedExtraResourcesSameNamespace": { + reason: "Validates diff with templated ExtraResources (matchLabels, no namespace) resolving a resource in the XR's own namespace", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/external-res-gotpl-composition.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-with-external-dep-templated.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-with-external-dep"). + WithFieldChange("spec.forProvider.roleName", "old-role-name", "templated-external-resource-testing"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-with-external-dep"). + WithFieldChange("spec.environment", "staging", "testing"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Reproduction for crossplane-contrib/crossplane-diff#376: a matchLabels + // ExtraResources selector that omits the namespace is a cluster-wide + // (all-namespaces) lookup in Crossplane (internal/xfn/required_resources.go + // lists with client.InNamespace(rs.GetNamespace()); empty = all + // namespaces). Here the matched ConfigMap lives in "other-namespace" while + // the XR is in "default". Pre-fix, resolveNamespace defaulted the empty + // selector namespace to the XR namespace ("default"), so the dynamic + // client listed only "default", found nothing, and the go-templating + // requirement came back empty — the downstream XDownstreamResource would + // not render at all (or a real template reading the value would fatal with + // ValueError). Post-fix the lookup spans all namespaces, finds the + // ConfigMap, and the downstream renders with roleName derived from it. + "TemplatedExtraResourcesCrossNamespace": { + reason: "matchLabels ExtraResources (no namespace) must resolve a resource in a DIFFERENT namespace than the XR via an all-namespaces lookup (issue #376)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap-other-namespace.yaml", + "testdata/diff/resources/external-res-gotpl-composition.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr-with-cross-ns-external-dep.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + // roleName is templated from the matched ConfigMap's name, proving + // the cross-namespace resource was resolved. + WithField("spec.forProvider.roleName", "templated-external-resource-crossns"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + WithField("spec.environment", "crossns"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "TemplatedExtraResourcesMatchNameNotFound": { + // Regression test for crossplane-contrib/crossplane-diff#355: a + // go-templating ExtraResources block using matchName for a + // resource that does not exist in the cluster must NOT abort the + // diff. Pre-fix, the iterative render loop received an error + // from requirements_provider.processNameSelector and surfaced it + // as a per-XR failure. Post-fix, the unmet requirement is silently + // skipped (mirroring upstream xfn.required_resources.go) and the + // composition renders its downstream resource normally. + reason: "matchName requirement for a missing resource must not abort the diff (issue #355)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-res-matchname-gotpl-composition.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + WithField("spec.forProvider.roleName", "static-role"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MultiStepFatalAfterExtraResources": { + // Reproduces the PR #295 bug #1 scenario: a multi-step pipeline where + // step 1 successfully emits ExtraResources requirements and composed + // resources, and a LATER step fatals. Per crossplane/cmd/crank/render/ + // render.go, a fatal in step N still returns the accumulated + // requirements from step 0 + // + // On iteration 2 (after requirements are cached) newReqCount == 0, + // and the OLD condition + // renderErr != nil && newReqCount == 0 && len(output.Requirements) == 0 + // is false — the error falls through to checkStability which declares + // the pipeline "stable" on an Outputs with nil CompositeResource, + // causing a SIGSEGV in CalculateNonRemovalDiffs. + // + // Expected behavior (with PR #295's render-loop fix): a clean fatal + // error bubbles up instead of a segfault. The diff is NOT produced + // because the pipeline legitimately failed. + reason: "Multi-step pipeline with step-1 requirements + later-step fatal should surface the error cleanly, not SIGSEGV", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedError: true, + expectedErrorContains: `"always-fatal" returned a fatal result`, + expectedExitCode: dp.ExitCodeToolError, + }, + "UnguardedTemplatedExtraResources": { + // Regression guard: a user template that accesses .extraResources + // directly (e.g. `index .extraResources "configmaps"`) without a + // `{{- with .extraResources }}` guard fails fatally on the first + // function invocation. function-go-templating's template data has no + // "extraResources" entry on that first call — crossplane's + // FetchingFunctionRunner only populates req.ExtraResources AFTER a + // function returns a non-fatal rsp.Requirements, and the Fatal from + // this template short-circuits before that ever happens. + // + // Verified empirically (2026-04-29) against a kind cluster with + // Crossplane + function-go-templating v0.11.0: a live controller + // produces the identical error, and the XR stays Synced=False with + // ReconcileError. So crossplane-diff must surface the same fatal + // error — not mask it with a diff — to match production behavior. + // + // This test also guards against a hypothetical "fixed" prefetch that + // pre-populated .extraResources for the template: that would make + // crossplane-diff report a successful diff for a composition that + // actually fails in-cluster, which is a false positive. If this test + // ever starts passing with an unexpected exit code or producing a + // diff, something has diverged from prod behavior. + reason: "Unguarded .extraResources access must surface a fatal error matching what a live Crossplane controller produces (verified empirically against kind cluster on 2026-04-29)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedError: true, + expectedErrorContains: `: error calling index: index of untyped nil`, + expectedExitCode: dp.ExitCodeToolError, + }, + "CrossNamespaceResourceDependencies": { + reason: "Validates cross-namespace resource dependencies via fn-external-resources", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/cross-namespace-configmap.yaml", + "testdata/diff/resources/cross-namespace-fn-composition.yaml", + "testdata/diff/resources/external-named-clusterrole.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-cross-ns-xr.yaml", + "testdata/diff/resources/existing-cross-ns-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-cross-ns-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-cross-ns-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-cross-ns-data-existing-named-data-old-cross-ns-role", "cross-namespace-data-another-cross-namespace-data-external-named-clusterrole"). + And(). + WithModifiedResource("XNopResource", "test-cross-ns-resource", "default"). + WithFieldChange("spec.coolField", "existing-cross-ns-value", "modified-cross-ns-value"). + WithFieldChange("spec.environment", "staging", "production"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceRemovalHierarchyV1ClusterScoped": { + reason: "Validates resource removal detection with hierarchy using v1 style resourceRefs and cluster scoped downstreams", + xrdAPIVersion: V1, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-legacy-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-legacy-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/removal-test-legacy-composition.yaml", + "testdata/diff/resources/removal-test-legacy-child-xrd.yaml", + "testdata/diff/resources/removal-test-legacy-child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-legacy-xr.yaml"}, + expectedOutput: ` +~~~ XDownstreamResource/resource-to-be-kept + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + labels: + crossplane.io/composite: test-resource + name: resource-to-be-kept + spec: + forProvider: +- configData: existing-value ++ configData: modified-value + +--- +--- XDownstreamResource/resource-to-be-removed-managed +- apiVersion: legacycluster.nop.example.org/v1alpha1 +- kind: XDownstreamResource +- metadata: +- annotations: +- crossplane.io/composition-resource-name: managed-resource +- labels: +- crossplane.io/composite: test-resource +- name: resource-to-be-removed-managed +- spec: +- forProvider: +- configData: child-value + +--- +--- XLegacyChildResource/resource-to-be-removed +- apiVersion: legacycluster.diff.example.org/v1alpha1 +- kind: XLegacyChildResource +- metadata: +- annotations: +- crossplane.io/composition-resource-name: resource2 +- labels: +- crossplane.io/composite: test-resource +- name: resource-to-be-removed +- spec: +- childField: child-value +- compositionRef: +- name: xlegacychildresources.legacycluster.diff.example.org +- compositionRevisionRef: +- name: xlegacychildresources.legacycluster.diff.example.org-bf2c887 +- compositionUpdatePolicy: Automatic + +--- +~~~ XNopResource/test-resource + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + labels: + crossplane.io/composite: test-resource + name: test-resource + spec: + compositionRef: + name: removal-test-composition + compositionRevisionRef: + name: removal-test-composition-4b6f9f1 + compositionUpdatePolicy: Automatic +- coolField: existing-value ++ coolField: modified-value + +--- + +Summary: 2 modified, 2 removed`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Issue #259: function-sequencer hides resources from later stages, but those + // resources should be shown as removed if they exist in the cluster. + // https://github.com/crossplane-contrib/crossplane-diff/issues/259 + "FunctionSequencerPreservesExistingResources": { + reason: "Function-sequencer should NOT hide resources that already exist in the cluster (fix for issue #259)", + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-sequencer-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + // Stage 0 resource - exists in cluster + "testdata/diff/resources/sequencer-stage0-downstream.yaml": nil, + // Stage 1 resource - exists in cluster, should NOT be hidden by sequencer + // because observed resources are passed correctly to the function pipeline. + // Stage 0 has no Ready condition, so the sequencer would gate stage 1 if it + // were not observed: this case depends on the XR's own composed resources + // reaching render as observed state. + "testdata/diff/resources/sequencer-stage1-downstream.yaml": nil, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + outputFormat: "json", + // Expected: With the fix, observed resources ARE passed to function-sequencer. + // Function-sequencer sees both resources exist and does NOT remove them from desired state. + // Since existing resources match rendered resources, there should be no changes. + // XR diff outputs nothing when there are no changes (unlike comp diff which says "No changes detected"). + expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, + }, + // Issue #259: Validates that removal detection works even when the XR has no changes. + // This is the core fix for the function-sequencer bug where existingXR was nil because + // the XR diff wasn't stored when there were no changes. + // https://github.com/crossplane-contrib/crossplane-diff/issues/259 + "ResourceRemovalWithUnmodifiedXR": { + reason: "Validates resource removal detection works when XR has no changes (uses existingXRFromCluster fallback)", + outputFormat: "json", + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, // XR has NO changes + // Expected: the nested XR resource2 and what it composes are removed, but XR and resource1 have no changes + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 0, 2). + WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). + // dryRun must be absent on a removal. A removal has no desired state to preview, so it can + // never carry a dryRun object, and one appearing would wrongly mark it unverified; WithDryRunPerformed asserts exactly that absence. The method + // name reads oddly here — nothing was dry run — but the claim it makes ("no dryRun + // object is present") is the right one, and having a second spelling for the same + // assertion would be worse. + WithDryRunPerformed(). + WithField("spec.childField", "child-value"). + And(). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed-managed", "default"). + WithDryRunPerformed(). + WithField("spec.forProvider.configData", "child-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #552: removal mirrors Crossplane's garbage collector, which deletes a composed resource only if + // the composite controls it. The XR's resourceRefs name four resources, and its render desires only + // the first: one it controls and renders, one it controls and no longer renders, one another XR + // controls, and one with no controller reference at all. Only the second is removed. + "ResourceRemovalSkipsResourcesTheXRDoesNotControl": { + reason: "A composed resource the XR's resourceRefs name but the XR does not control is not reported removed", + // What this case pins: test-resource's resourceRefs still name two resources it doesn't control. + outputFormat: "json", + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml", + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml", // still rendered: kept + "testdata/diff/resources/removal-test-ns-downstream-resource2.yaml", // no longer rendered: removed + ), + composes( + "testdata/diff/resources/existing-other-xr.yaml", + "testdata/diff/resources/existing-other-xr-downstream.yaml", + ), + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // Deliberately not in crossplaneManagedResources: its controller reference was removed, so + // no XR controls it, though test-resource's resourceRefs still name it. + "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", + }, + inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 0, 1). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed", "default"). + WithField("spec.forProvider.configData", "existing-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceRemovalHierarchyV2Namespaced": { + reason: "Validates resource removal detection with hierarchy using v2 style resourceRefs and namespaced downstreams", + outputFormat: "json", + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 2). + WithModifiedResource("XDownstreamResource", "resource-to-be-kept", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). + WithField("spec.childField", "child-value"). + And(). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed-managed", "default"). + WithField("spec.forProvider.configData", "child-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceRemovalHierarchyV2ClusterScoped": { + reason: "Validates resource removal detection with hierarchy using v2 style resourceRefs and cluster scoped downstreams", + outputFormat: "json", + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-cluster-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/cluster-xrd.yaml", + "testdata/diff/resources/removal-test-cluster-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 2). + WithModifiedResource("XDownstreamResource", "resource-to-be-kept", ""). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). + WithField("spec.childField", "child-value"). + And(). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed-managed", "default"). + WithField("spec.forProvider.configData", "child-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", ""). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceWithGenerateName": { + reason: "Validates handling of resources with generateName", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + // Set up the XR as the owner of the generated composed resource + OwnerFile: "testdata/diff/resources/existing-generate-name-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + // This file has a generated name and is owned by the XR + "testdata/diff/resources/existing-downstream-with-generated-name.yaml": nil, + }, + }, + }, + // Use a composition that uses generateName for composed resources + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The generateName counterpart of NewXRWithExistingComposedResource. A new XR renders its composed + // resource with only a generateName, and nothing is observed for it, so no name identifies the + // existing resource: only its crossplane.io/composite label and composition-resource-name + // annotation can. ResourceWithGenerateName cannot reach that lookup, because there the XR exists and + // render names the resource after the observed one. + "NewXRAdoptsOrphanedGenerateNamedComposedResource": { + reason: "A new XR's generateName composed resource is matched to an existing orphan by its composite label", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + // Deliberately not in crossplaneManagedResources: an orphan has no controller + // ownerReference (the garbage collector strips it when the XR is deleted with + // --cascade=orphan) and no XR lists it, but it keeps its labels and annotations. + "testdata/diff/resources/existing-downstream-with-generated-name.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 1, 0). + WithAddedResource("XNopResource", "test-resource", "default"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A generateName-only input next to an ordinary one is an ordinary invocation. The unnamed input + // has no identity, so input validation must not mistake it for the named XR's claim: an XR with + // no claimRef has an empty reference identity too, and the two must not match. + "GenerateNameInputAlongsideANamedInput": { + reason: "A generateName-only input beside a named one is valid; both are diffed", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml", "testdata/diff/generated-name-xr.yaml"}, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(4, 0, 0). + WithXRs( + tu.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0), + tu.XR("XNopResource", "generated-xr-(generated)", "default").Status("changed").Summary(2, 0, 0), + ), + }, + "NewXRWithGenerateName": { + reason: "Shows diff for new XR with generateName", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // We don't add any existing XR, as we're testing creation of a new one + }, + inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "", "default"). + WithNamePattern(`generated-xr-\(generated\)`). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "", "default"). + WithNamePattern(`generated-xr-\(generated\)`). + WithField("spec.coolField", "new-value"). + And(). + // Issue #477: the grouped view must attribute these changes to a + // named XR. metadata.name is empty on the input, so the identity + // carries the effective (synthesized) name — the same one the + // changes above are keyed by. + WithXRs( + tu.XR("XNopResource", "generated-xr-(generated)", "default"). + Status("changed"). + Summary(2, 0, 0), + ), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #476. The same file passed twice — a fat-fingered command line, or CI enumerating one + // file twice — has one clear intent. It is diffed once, with a warning, and the run succeeds. + "IdenticalDuplicateInputIsDeduplicated": { + reason: "An input identical to an earlier one is diffed once, with a warning, not rejected", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/new-xr.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStderrContains: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, + // The warning channel's contract is both halves: stderr above, and warnings[] here. + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithXRs(tu.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0)). + WithWarning("Ignoring a duplicate input: it is identical to an earlier one"). + WithWarningContext(map[string]string{"resource": "XNopResource/test-resource", "input": "2", "duplicateOf": "1"}), + }, + // The same object twice with different content fails loudly: applying both would leave whichever + // is applied last, and the order inputs are given in is not a statement of intent. + "SameObjectTwiceWithDifferentContentFails": { + reason: "One object given twice with different content is an input error on both inputs", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/modified-xr.yaml", + }, + expectedError: true, + expectedErrorContains: "input 2 defines XNopResource/test-resource differently from input 1", + expectedExitCode: dp.ExitCodeToolError, + }, + // A child XR passed alongside the parent whose composition renders it: the parent's composition + // writes that child, so the child input would be a second writer. Rejected on the child; the + // parent's own diff is unaffected. + "InputComposedByAnotherInputIsRejected": { + reason: "An XR supplied alongside the input whose composition composes it is an input error", + setupFiles: []string{ + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/new-nested-xr.yaml", + "testdata/diff/new-nested-child-xr.yaml", + }, + expectedError: true, + expectedErrorContains: "XChildResource/test-parent-child is composed by input XParentResource/test-parent, " + + "whose composition writes it", + expectedExitCode: dp.ExitCodeToolError, + }, + // Issue #476. Two distinct XRs whose composition gives one child a fixed name render the same + // object — here with byte-identical content. Crossplane gives it to whichever XR creates it first + // and the other fails to reconcile it, so the run must fail however alike the renderings are. + // This depends on real renders carrying the controller reference; unit tests build that by hand. + "TwoXRsComposingOneObjectContend": { + reason: "Two XRs that would both control one object fail, even when their renderings are identical", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/shared-child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/shared-child-xrs.yaml"}, + expectedError: true, + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: `resource "ns.nop.example.org/v1alpha1/XDownstreamResource/default/shared-child" would be ` + + `controlled by more than one XR (XNopResource/shared-child-owner-a, XNopResource/shared-child-owner-b)`, + }, + // Reproduces the PR #294 scenario: a net-new XR is diffed while the + // composed resource it would manage already exists in the cluster + // (e.g. after the prior XR was deleted with --cascade=orphan, or while + // importing existing resources under Crossplane management). The render + // pipeline emits ownerReferences with an empty UID for new XRs; + // without the fallback in CalculateNonRemovalDiffs, UpdateOwnerRefs + // runs as a no-op and the real apiserver rejects the dry-run apply + // with "metadata.ownerReferences.uid: must not be empty". + "NewXRWithExistingComposedResource": { + reason: "Shows diff for new XR whose composed resource already exists in the cluster (PR #294)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // Pre-existing composed resource; no backing XR in the cluster. Deliberately not in + // crossplaneManagedResources: an orphaned resource has no controller ownerReference (the + // garbage collector strips it when the XR is deleted with --cascade=orphan), and adopting + // exactly such a resource is what this case tests. + "testdata/diff/resources/existing-downstream-resource.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 1, 0). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MultipleXRs": { + reason: "Validates diff for multiple XRs", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // Add an existing XR and downstream resource to test modification + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + inputFiles: []string{ + "testdata/diff/first-xr.yaml", + "testdata/diff/modified-xr.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 2, 0). + WithAddedResource("XDownstreamResource", "first-resource", "default"). + WithField("spec.forProvider.configData", "first-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithAddedResource("XNopResource", "first-resource", "default"). + WithField("spec.coolField", "first-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "SelectCompositionByDirectReference": { + reason: "Validates composition selection by direct reference", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + // Add multiple compositions for the same XR type + "testdata/diff/resources/default-composition.yaml", + "testdata/diff/resources/production-composition.yaml", + "testdata/diff/resources/staging-composition.yaml", + }, + inputFiles: []string{ + "testdata/diff/xr-with-composition-ref.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "production"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "test-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "SelectCompositionByLabelSelector": { + reason: "Validates composition selection by label selector", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + // Add multiple compositions for the same XR type + "testdata/diff/resources/default-composition.yaml", + "testdata/diff/resources/production-composition.yaml", + "testdata/diff/resources/staging-composition.yaml", + }, + inputFiles: []string{ + "testdata/diff/xr-with-composition-selector.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "staging"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "test-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "AmbiguousCompositionSelection": { + reason: "Validates error on ambiguous composition selection", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + // Add multiple compositions for the same XR type + "testdata/diff/resources/default-composition.yaml", + "testdata/diff/resources/production-composition.yaml", + "testdata/diff/resources/staging-composition.yaml", + }, + inputFiles: []string{ + "testdata/diff/xr-with-ambiguous-selector.yaml", + }, + expectedError: true, + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: "ambiguous composition selection: multiple compositions match", + noColor: true, + }, + "SchemaValidationError": { + reason: "Validates exit code 2 for schema validation errors and structured per-resource failure detail in JSON output", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/invalid-schema-xr.yaml", + }, + outputFormat: "json", + expectedError: true, + expectedExitCode: dp.ExitCodeSchemaValidation, + // Structured assertion: lock down the typed + // validationFailures payload for both the failing XR and + // its composed resource. Field paths and error types are + // ours to assert; the embedded k8s message wording is + // left unasserted (apimachinery upgrades can shift its + // phrasing without changing our output contract). + expectedStructuredOutput: tu.ExpectDiff(). + WithError("XNopResource/invalid-schema-xr"). + WithValidationFailure("ns.diff.example.org/v1alpha1", "XNopResource", "invalid-schema-xr", "default"). + WithStatus("invalid"). + WithFieldError("schema", "spec.coolField"). + AndFieldError(). + AndValidationFailure(). + WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "invalid-schema-xr", "default"). + WithStatus("invalid"). + WithFieldError("schema", "spec.forProvider.configData"). + AndFieldError(). + AndValidationFailure(). + AndError(), + noColor: true, + }, + "NewClaimShowsDiff": { + reason: "Shows diff for new claim", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + // Add the necessary CRDs and compositions for claim diffing + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-claim.yaml"}, + // The claim is defaulted with its own CRD (generated from claim-xrd.yaml), which shares the XRD's + // user default spec.tier with the XR's CRD but, unlike it, does not default compositionUpdatePolicy. + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("NopClaim", "test-claim", "existing-namespace"). + WithField("spec.coolField", "new-value"). + WithField("spec.tier", "standard"). + WithFieldAbsent("spec.compositionUpdatePolicy"). + And(). + WithAddedResource("XDownstreamResource", "test-claim", ""). + WithField("spec.forProvider.configData", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "NewClaimWithClaimRefComposition": { + reason: "Shows diff for new claim when composition uses spec.claimRef - claimRef is synthesized for new claims", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition-with-claimref.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-claim-with-claimref-composition.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("NopClaim", "test-claim", "existing-namespace"). + WithField("spec.coolField", "new-value"). + And(). + WithAddedResource("XDownstreamResource", "test-claim", ""). + WithField("spec.forProvider.configData", "existing-namespace/test-claim"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ModifiedClaimShowsDiff": { + reason: "Shows diff for modified claim", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + // Add necessary CRDs and composition + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/functions.yaml", + // Add existing resources for comparison + "testdata/diff/resources/existing-claim.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-claim-xr.yaml", + "testdata/diff/resources/existing-claim-downstream-resource.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/modified-claim.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + // The composed resource is labelled with the backing XR the claim's resourceRef names, so it + // already belongs to this claim: no ownership advisory (#534). + WithNoWarnings(). + WithSummary(0, 2, 0). + WithModifiedResource("NopClaim", "test-claim", "existing-namespace"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-claim-82crv", ""). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503, for a claim. A claim is diffed as the claim itself, so its dry-run payload must carry + // none of the defaults its CRD declares for fields the manifest omits. + "XRDDefaultedFieldOnClaimOwnedByAnotherManagerIsNotReportedChanged": { + reason: "An XRD-defaulted field the claim manifest omits keeps another manager's non-default value in the diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/existing-claim.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-claim-xr.yaml", + "testdata/diff/resources/existing-claim-downstream-resource.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + fieldManagerApplies: []fieldManagerApply{ + {file: "testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml", fieldManager: "platform-operator"}, + }, + inputFiles: []string{"testdata/diff/modified-claim-omitting-defaulted-fields.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + // As in ModifiedClaimShowsDiff: the composed resource belongs to the claim's backing XR (#534). + WithNoWarnings(). + WithSummary(0, 2, 0). + WithModifiedResource("NopClaim", "test-claim", "existing-namespace"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + WithFieldChange("spec.compositeDeletePolicy", "Foreground", "Foreground"). + WithFieldChange("spec.compositionUpdatePolicy", "Manual", "Manual"). + And(). + WithModifiedResource("XDownstreamResource", "test-claim-82crv", ""). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A claim is defaulted with its own CRD, never its XR's; only the XR that render consumes gets + // the XR CRD's defaults. The claimnested CRDs mirror what Crossplane generates: both carry the + // XRD's user default spec.tier, only the claim's defaults spec.compositeDeletePolicy, and only + // the XR's defaults spec.compositionUpdatePolicy. The composition writes the render input's + // tier and policy into the composed resource, so each case pins all three views at once: the + // claim's payload, its predicted or apiserver-defaulted form, and the render input. + "NewClaimIsDefaultedWithItsOwnCRDWhenDryRunCreated": { + reason: "A new claim shows its own CRD's defaults, from the apiserver, and render sees its XR's", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/parent-defaults-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). + WithDryRunPerformed(). + WithField("spec.tier", "standard"). + WithField("spec.compositeDeletePolicy", "Background"). + WithFieldAbsent("spec.compositionUpdatePolicy"). + And(). + WithAddedResource("XDownstreamResource", "new-parent-claim-defaults", ""). + WithField("spec.forProvider.configData", "standard/Automatic"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "NewClaimIsPredictedWithItsOwnCRDsDefaultsWithoutDryRun": { + reason: "With --dry-run-on=existing a new claim is predicted with its own CRD's defaults, not its XR's", + outputFormat: "json", + dryRunOn: "existing", + setupFiles: []string{ + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/parent-defaults-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.tier", "standard"). + WithField("spec.compositeDeletePolicy", "Background"). + WithFieldAbsent("spec.compositionUpdatePolicy"). + And(). + WithAddedResource("XDownstreamResource", "new-parent-claim-defaults", ""). + WithDryRunSkipped("disabled", ""). + WithField("spec.forProvider.configData", "standard/Automatic"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "XRDDefaultsAppliedBeforeRendering": { + reason: "Validates that XRD defaults are applied to XR before rendering", + outputFormat: "json", + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 0, 0). + WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). + WithField("spec.region", "us-east-1"). + WithField("spec.size", "large"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The sibling of XRDDefaultsAppliedBeforeRendering, whose composition renders nothing, so it checks + // only the XR's dry run. Here a composition reads the composite's defaulted fields into a composed + // resource: a user default (spec.region) for a v2 and a legacy XR, and for the legacy XR also the + // machinery default spec.compositionUpdatePolicy. Rendering an undefaulted XR writes "". + "XRDDefaultsReachTheRender": { + reason: "Validates that a composition observes an XR's CRD defaults, for both a v2 and a legacy XR", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/rendered-defaults/definitions.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-rendered-defaults-xrs.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(4, 0, 0). + WithAddedResource("XRegionalDatabase", "modern-database", "default"). + WithField("spec.region", "us-east-1"). + And(). + WithAddedResource("XLegacyRegionalDatabase", "legacy-database", ""). + WithField("spec.region", "eu-west-1"). + And(). + WithAddedResource("XDownstreamResource", "modern-database", "default"). + WithField("spec.forProvider.configData", "us-east-1/large"). + And(). + WithAddedResource("XDownstreamResource", "legacy-database", ""). + WithField("spec.forProvider.configData", "eu-west-1/small/Automatic"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503: a composed resource's dry-run apply payload must be exactly what the composition + // rendered. If it carried a locally-defaulted spec.forProvider.size: small, the apply would + // claim the field away from the manager that set it to large, predicting a change real + // Crossplane (which never sends the field) would not make. The composition renders + // everything else identically, so any modification reported for the composed resource could + // only have come from the defaulted field. + "CRDDefaultedFieldOwnedByAnotherManagerIsNotReportedChanged": { + reason: "A CRD-defaulted field the composition does not render keeps another manager's non-default value in the diff", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The composed resource deliberately has no controlling XR, as after its XR was deleted with + // --cascade=orphan: the XR is new, and what this case pins is the composed resource's field + // managers, which an orphan keeps. Giving it an XR would make the XR modified rather than new. + fieldManagerApplies: []fieldManagerApply{ + { + file: "testdata/diff/resources/existing-defaulted-downstream-composed.yaml", + fieldManager: "apiextensions.crossplane.io/composed/2b9d3c4e-0000-4000-8000-000000000503", + }, + { + file: "testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml", + fieldManager: "platform-operator", + }, + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 0, 0). + WithAddedResource("XNopResource", "test-resource", "default"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503, for the XR itself. The XR is defaulted before rendering, and must be: the composition + // has to see the spec Crossplane would. But the XR's own dry-run apply payload must not carry + // those defaults, or it claims spec.region from the manager that set it to eu-west-1 and reports + // a change applying the manifest would not make. Nothing is composed, so the XR is the only + // thing that could be reported changed. + "XRDDefaultedFieldOwnedByAnotherManagerIsNotReportedChanged": { + reason: "An XRD-defaulted field the XR manifest omits keeps another manager's non-default value in the diff", + outputFormat: "json", + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + fieldManagerApplies: []fieldManagerApply{ + {file: "testdata/diff/xr-with-missing-defaults.yaml", fieldManager: "kubectl"}, + {file: "testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml", fieldManager: CompositeReconcilerFieldManager}, + {file: "testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml", fieldManager: "platform-operator"}, + }, + expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, + }, + // With no apiserver result for an addition, its defaults have to be predicted locally. These + // pin that keeping local defaults out of dry-run payloads did not also take them out of the + // additions that are never sent: an XRD default on the XR, and a CRD default on a composed + // resource. + "XRDDefaultsShownForAddedXRWithoutDryRun": { + reason: "With --dry-run-on=existing an added XR still shows the defaults its XRD declares", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 0, 0). + WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.region", "us-east-1"). + WithField("spec.size", "large"). + WithField("spec.tags.environment", "development"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "CRDDefaultsShownForAddedComposedResourceWithoutDryRun": { + reason: "With --dry-run-on=existing an added composed resource still shows the defaults its CRD declares", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDefaultedDownstreamResource", "test-resource-defaulted", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.forProvider.size", "small"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The local prediction defaults an addition against the version it was requested at. Its CRD + // stores another version, whose defaults (and conversion) the cluster may also apply, so the + // prediction cannot be trusted to match and the advisory says so — even under + // --dry-run-on=existing, whose unverified additions are otherwise not warned about. Defaulting + // itself is unchanged: size is the requested version's default, not the storage version's. + // The XR's own XRD has one version, so it raises no warning, which the count pins. + "AdditionAtANonStorageVersionWarnsThatItsDiffOmitsTheStorageVersion": { + reason: "With --dry-run-on=existing an addition requested at a version its CRD does not store warns that the cluster stores it at another version", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XVersionedDownstreamResource", "test-resource-versioned", "default"). + WithDryRunSkipped("disabled", ""). + WithDryRunStorageVersion("v1"). + WithField("spec.forProvider.size", "small"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithDryRunSkipped("disabled", ""). + And(). + WithWarning("they are stored at a version other than the one requested"). + WithWarningContext(map[string]string{ + "gvk": "ns.nop.example.org/v1alpha1, Kind=XVersionedDownstreamResource", + "namespace": "default", + "versions": "requested v1alpha1, stored v1", + "count": "1", + }), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "AdditionAtANonStorageVersionWarnsOnStderr": { + reason: "In text mode the storage-version note reaches stderr, the only place a person sees it", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + noColor: true, + expectedStderrContains: []string{ + `WARNING: did not verify added resources against the apiserver (--dry-run-on=existing); they are stored at a version other than the one requested, ` + + `whose defaults and conversion the cluster may apply but their diffs do not ` + + `(count=1, gvk=ns.nop.example.org/v1alpha1, Kind=XVersionedDownstreamResource, namespace=default, versions=requested v1alpha1, stored v1)`, + }, + expectedOutput: "+++ XVersionedDownstreamResource/test-resource-versioned", + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "XRDDefaultsNoOverride": { + reason: "Validates that XRD defaults do not override user-specified values", + outputFormat: "json", + inputFiles: []string{"testdata/diff/xr-with-overridden-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 0, 0). + WithAddedResource("XTestDefaultResource", "test-resource-with-overrides", "default"). + WithField("spec.region", "us-west-2"). + WithField("spec.size", "xlarge"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ConcurrentRenderingMultipleXRs": { + reason: "Validates concurrent rendering with multiple functions and XRs from directory", + timeout: longTimeout, // Multiple XRs processed concurrently need more time + outputFormat: "json", + // This test reproduces issue #59 - concurrent function startup failures + // when processing multiple XR files from a directory + inputFiles: []string{ + "testdata/diff/concurrent-xrs", // Pass the directory containing all XR files + }, + setupFiles: []string{ + "testdata/diff/resources/xrd-concurrent.yaml", + "testdata/diff/resources/composition-multi-functions.yaml", + "testdata/diff/resources/functions.yaml", + }, + // We expect successful processing of all 5 XRs + // Each XR should produce 3 base resources + 2 additional resources = 5 resources per XR + // Plus the XR itself = 6 additions per XR + // Total: 5 XRs * 6 additions = 30 additions + expectedStructuredOutput: tu.ExpectDiff().WithSummary(30, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "NewNestedXRCreatesChildren": { + reason: "Validates that new nested XR creates child XR and downstream resources", + outputFormat: "json", + setupFiles: []string{ + // XRDs for parent and child + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + // Compositions for parent and child + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + // XRD for downstream managed resource + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-nested-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(3, 0, 0). + WithAddedResource("XChildResource", "test-parent-child", "default"). + WithField("spec.childField", "parent-value"). + And(). + WithAddedResource("XDownstreamResource", "test-parent-child-managed", "default"). + WithField("spec.forProvider.configData", "parent-value"). + And(). + WithAddedResource("XParentResource", "test-parent", "default"). + WithField("spec.parentField", "parent-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #485. The unit test for the depth guard uses a render stub that caps its own recursion, + // so it cannot show what a real cycle does across real renders: real templates, real names that + // grow at every level (kept under the generated CRDs' 63-character limit by a short suffix), real + // schema validation and dry-run at each level. This drives the whole path. It fails at exactly one level past the + // default --max-nested-depth of 10, attributed to the XR the user named. + "CyclicCompositionStopsAtMaxNestedDepth": { + reason: "A composition cycle (XCycleA -> XCycleB -> XCycleA ...) must stop at --max-nested-depth with a clear error, not recurse until the stack overflows (#485)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/cycle/xrds.yaml", + "testdata/diff/resources/cycle/compositions.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-cycle-xr.yaml"}, + expectedError: true, + expectedErrorContains: "maximum nesting depth exceeded: XCycleB/test-cycle-c-c-c-c-c-c-c-c-c-c-c (nested depth 11) is nested 11 levels deep, but --max-nested-depth is 10", + expectedExitCode: dp.ExitCodeToolError, + expectedStructuredOutput: tu.ExpectDiff(). + WithError("XCycleA/test-cycle"). + WithMessageContaining("maximum nesting depth exceeded"), + }, + "ModifiedNestedXRPropagatesChanges": { + reason: "Validates that modified nested XR propagates changes through child XR to downstream resources", + outputFormat: "json", + setupFiles: []string{ + // XRDs for parent and child + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + // Compositions for parent and child + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + // XRD for downstream managed resource + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + // Existing resources: the parent XR composes the child XR, which composes the managed resource + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 3, 0). + WithModifiedResource("XChildResource", "test-parent-child", "default"). + WithFieldChange("spec.childField", "existing-value", "modified-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-parent-child-managed", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XParentResource", "test-parent", "default"). + WithFieldChange("spec.parentField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503, for a nested XR. It is defaulted before its own render, so that render input carries + // spec.tier: standard although the parent's composition never sets it. Sending it would claim + // spec.tier from the manager that set it to premium. + "XRDDefaultedFieldOnNestedXROwnedByAnotherManagerIsNotReportedChanged": { + reason: "An XRD-defaulted field a parent composition omits from a nested XR keeps another manager's non-default value in the diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + fieldManagerApplies: []fieldManagerApply{ + {file: "testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml", fieldManager: "platform-operator"}, + }, + inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 3, 0). + WithModifiedResource("XChildResource", "test-parent-child", "default"). + WithFieldChange("spec.childField", "existing-value", "modified-value"). + WithFieldChange("spec.tier", "premium", "premium"). + And(). + WithModifiedResource("XDownstreamResource", "test-parent-child-managed", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XParentResource", "test-parent", "default"). + WithFieldChange("spec.parentField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Composition Revision tests for v2 XRDs + "V2ManualPolicyPinnedRevision": { + reason: "Validates v2 XR with Manual update policy stays on pinned revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-manual-v1.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2AutomaticPolicyLatestRevision": { + reason: "Validates v2 XR with Automatic update policy uses latest revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + // The XR is still on v1, and its composed resource holds v1's data. + composes( + "testdata/diff/resources/existing-xr-automatic.yaml", + "testdata/diff/resources/existing-downstream-automatic.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-automatic", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-automatic", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2AutomaticPolicyRevisionSelectorPinsMatchingRevision": { + // Issue #388 (Bug B): under Automatic policy WITH a compositionRevisionSelector, the XR + // resolves to the latest revision MATCHING the selector, not the latest overall. Here the + // selector pins channel=stable (revision 1) even though channel=preview (revision 2) is + // newer — so the diff must render against the stable template (stable-*), not preview-*. + reason: "Validates v2 Automatic XR with compositionRevisionSelector resolves to the selector-matching revision, not the newest overall", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-stable.yaml", // revision 1, channel=stable + "testdata/diff/resources/composition-revision-preview.yaml", // revision 2, channel=preview (newest) + "testdata/diff/resources/composition-v2.yaml", // current composition + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-selector-stable.yaml", + "testdata/diff/resources/existing-downstream-selector-stable.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-selector-stable.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-selector", "default"). + // stable-* (not preview-*) proves the selector, not "latest overall", chose the revision. + WithFieldChange("spec.forProvider.configData", "stable-existing-value", "stable-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-selector", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2AutomaticPolicyRevisionSelectorNoMatchErrors": { + // The mirror of the above: a selector matching NO revision must fail the diff rather than + // silently rendering against a non-matching revision (accuracy over guessing). + reason: "Validates v2 Automatic XR whose compositionRevisionSelector matches no revision fails the diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-stable.yaml", + "testdata/diff/resources/composition-revision-preview.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr-selector-nomatch.yaml"}, + expectedError: true, + expectedErrorContains: "match selector", + expectedExitCode: dp.ExitCodeToolError, + }, + "V2ManualRevisionUpgradeDiff": { + reason: "Validates v2 XR changing revision in Manual mode shows upgrade diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-manual-upgrade-to-v2.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "CompositionRevisionUpgradesResourceAPIVersion": { + // NOTE: This test validates that resources are correctly matched across API version changes, + // avoiding delete/recreate. The composition template changes from v1beta1 to v1beta2, but + // Kubernetes automatically converts resources between served API versions. When we query for + // the v1beta2 resource, Kubernetes finds the v1beta1 resource and returns it auto-converted + // to v1beta2. From Kubernetes' perspective, the resource exists as both versions simultaneously, + // so there's no apiVersion field change to show in the diff. The important thing is that the + // resource is matched (unchanged, not ---/+++), preventing delete/recreate operations. + // + // The XR composes the resource as Crossplane leaves it: controlled by the XR, and named in the + // XR's resourceRefs at v1beta1, the version it was composed at. Crossplane's garbage collector + // matches what an XR composed to what it renders by composition resource name, so it keeps the + // resource; removal detection must too, rather than report the v1beta1 reference unmatched by + // the v1beta2 render (#552). + reason: "Validates XR upgrading composition revision that changes resource API version shows as update not remove/add", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + // NOTE: xapimigrate CRD is auto-loaded from testdata/diff/crds/ + // We apply no XRD for XApiMigrateResource because it is a regular managed resource + // (not a composite), and an XRD would make it composite. + "testdata/diff/resources/api-version-composition-revision-v1.yaml", + "testdata/diff/resources/api-version-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-api-version-xr-rev1.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2.yaml"}, + // Key assertion: nothing is added or removed, proving API version migration works. The + // XApiMigrateResource is stored identically at either version, so it renders unchanged; an + // unmatched one would be a +++ addition plus a --- removal. Only the XR's revision ref changes. + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 1, 0). + WithModifiedResource("XNopResource", "test-api-version-xr", "default"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "CompositionRevisionUpgradesResourceAPIVersionAndChangesIt": { + // The same upgrade, with a change to the XR that the new revision renders into the resource too. + // The resource is one object, modified once at the version it is rendered at. Matched by API + // version, the v1beta1 reference was a removal contradicting that modification, and the xr + // command refused to combine the two diffs of one object (#552). + reason: "Validates an API version change plus a field change is one modification, not a removal and a modification", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/api-version-composition-revision-v1.yaml", + "testdata/diff/resources/api-version-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-api-version-xr-rev1.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2-changed-field.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XApiMigrateResource", "test-api-version-xr-api-resource", "default"). + WithFieldChange("spec.forProvider.configData", "test-value", "changed-value"). + And(). + WithModifiedResource("XNopResource", "test-api-version-xr", "default"). + WithFieldChange("spec.coolField", "test-value", "changed-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2SwitchManualToAutomatic": { + reason: "Validates v2 XR switching from Manual to Automatic mode uses latest revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-switch-to-automatic.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2NetNewManualNoRevRef": { + reason: "Validates v2 net new XR with Manual policy but no revision ref uses latest revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr-manual-no-ref.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-manual-no-ref", "default"). + WithField("spec.forProvider.configData", "v2-new-value"). + And(). + WithAddedResource("XNopResource", "test-manual-no-ref", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Composition Revision tests for v1 XRDs (Crossplane 1.20 compatibility) + "V1ManualRevisionUpgradeDiff": { + reason: "Validates v1 XR with Manual update policy changing revision shows upgrade diff", + outputFormat: "json", + xrdAPIVersion: V1, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-legacy-xr-manual-v1.yaml", + "testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #499: Crossplane's composite reconciler writes compositionRevisionRef, so a hand-written + // manifest omits it — and an apply that omits a field leaves it where it is. The render must + // therefore see the cluster copy's ref. Here the composition propagates the revision name into the + // composed resource, so a render that dropped the ref reported configData changing from the real + // revision name to empty for an input identical to what is deployed. + "ExistingXROmittingRevisionRefRendersWithClusterRef": { + reason: "An existing XR whose input omits compositionRevisionRef renders with the cluster's ref, so a template reading the revision name shows no spurious change", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-revision-ref.yaml", + ), + }, + inputFiles: []string{"testdata/diff/existing-xr-revision-ref-omitted.yaml"}, + expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedExitCode: dp.ExitCodeSuccess, + }, + // Issue #499, the consequence for which revision renders. The cluster's ref is inherited before the + // composition is resolved, so a Manual composite whose input omits the ref stays on the revision it + // is pinned to (-911318d, the v1 template), as it would in the cluster, instead of being rendered + // against the latest one (-430f3f3, v2). Compare V2ManualPolicyPinnedRevision, the same change with + // the ref spelled out. + "V2ManualPolicyOmittedRevisionRefStaysPinned": { + reason: "Validates v2 XR with Manual update policy whose input omits compositionRevisionRef stays on the cluster's pinned revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + // v1-* (not v2-*) proves the pinned revision, not the latest, rendered. + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The v1-path (spec.compositionRevisionRef) twin of the above. + "V1ManualPolicyOmittedRevisionRefStaysPinned": { + reason: "Validates v1 XR with Manual update policy whose input omits compositionRevisionRef stays on the cluster's pinned revision", + outputFormat: "json", + xrdAPIVersion: V1, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-legacy-xr-manual-v1.yaml", + "testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #499 for a nested XR. It is rendered from its parent's output, which never carries + // compositionRevisionRef, so it must inherit the cluster copy's, which ProcessNestedXRs hands it + // rather than fetching it again. The child's composition propagates the revision name into the + // managed resource; the parent's change reaches the child's childField but not that name, so the + // managed resource must not be reported. + "NestedXRInheritsClusterRevisionRef": { + reason: "A nested XR renders with its cluster copy's compositionRevisionRef, so a template reading the revision name shows no spurious change", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-revision-templating-composition.yaml", + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml": { + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml": nil, + }, + }, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XChildResource", "test-parent-child", "default"). + WithFieldChange("spec.childField", "existing-value", "modified-value"). + // The nested XR's own reconciler wrote the ref, not the parent's composed apply, so the + // parent's render, which omits it, leaves it in place. + WithFieldChange("spec.crossplane.compositionRevisionRef.name", + "xchildresources.nested.example.org-96afc06", "xchildresources.nested.example.org-96afc06"). + And(). + WithModifiedResource("XParentResource", "test-parent", "default"). + WithFieldChange("spec.parentField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #498, as a user meets it: an Automatic claim's manifest omits the compositionRevisionRef the + // claim syncer copied onto the cluster copy. Under Automatic the claim's ref is never propagated to + // the backing XR anyway; the XR controller owns the XR's own, which is what render must see. Before, + // the backing XR's ref was dropped, so the template read no revision name and configData was + // reported changing to "v2::existing-value" for an unchanged claim. + "AutomaticClaimOmittingRevisionRefRendersWithBackingXRRef": { + reason: "An Automatic claim renders with its backing XR's compositionRevisionRef, so a template reading the revision name shows no spurious change", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/pinned-claim/definitions.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/pinned-claim/existing-automatic.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/unchanged-automatic-pinned-claim.yaml"}, + // No warnings either: the composed resource belongs to the claim's backing XR (#534). + expectedStructuredOutput: tu.ExpectDiff().WithNoWarnings().WithSummary(0, 0, 0), + expectedExitCode: dp.ExitCodeSuccess, + }, + // A Manual claim's pin lives on its backing XR: the claim syncer copies the ref back to a claim only + // under Automatic. Composition resolution and render must both see that pin. Before, the composition + // was resolved from the claim, which names no revision, so the latest one (v2) was chosen, while the + // render read the backing XR's ref (revision 1): configData became "v2:...-3d33597:...", a revision's template + // paired with another revision's name. + "ManualClaimRendersAgainstItsBackingXRsPinnedRevision": { + reason: "A Manual claim whose backing XR is pinned to an older revision renders that revision, with that revision's name", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/pinned-claim/definitions.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/pinned-claim/existing-manual.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/pinned-claim/existing-manual-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/modified-manual-pinned-claim.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + // The composed resource is labelled with the backing XR's name, which the claim's resourceRef + // names, so it already belongs to this claim: no ownership advisory (#534). + WithNoWarnings(). + WithSummary(0, 2, 0). + WithModifiedResource("Pinned", "manual-claim", "existing-namespace"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + And(). + WithModifiedResource("XDownstreamResource", "manual-claim-x1y2z", ""). + WithFieldChange("spec.forProvider.configData", + "v1:xpinneds.pinned.diff.example.org-3d33597:existing-value", + "v1:xpinneds.pinned.diff.example.org-3d33597:modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #536. Crossplane's composite reconciler resolves the revision, writes it to + // compositionRevisionRef, and then composes, so an Automatic composite whose ref lags behind the + // latest revision is composed from the latest one AND sees its name. Before, the latest revision + // rendered while the template read the stale ref: configData became "v2:...-3d33597:...", a revision's + // template paired with another revision's name. + "AutomaticXRWithLaggingRevisionRefRendersLatestRevisionAndName": { + reason: "An Automatic XR whose cluster ref names an older revision renders the latest revision, with that revision's name", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/pinned-claim/definitions.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/unchanged-lagging-automatic-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 1, 0). + WithModifiedResource("XDownstreamResource", "lagging-xr", ""). + WithFieldChange("spec.forProvider.configData", + "v1:xpinneds.pinned.diff.example.org-3d33597:existing-value", + "v2:xpinneds.pinned.diff.example.org-806289b:existing-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // An XRD's defaultCompositionUpdatePolicy applies to a new composite before Crossplane resolves its + // revision, so a new composite that names a revision and leaves the policy to an XRD defaulting to + // Manual renders the revision it names. Before, the composition was resolved before defaulting, as + // Automatic, so the latest revision (v2) rendered. + "NewXRUnderManualDefaultingXRDRendersTheRevisionItNames": { + reason: "A new XR whose XRD defaults its update policy to Manual is resolved as Manual", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/manual-default/definitions.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-manual-default-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XManualDefault", "new-manual-default", "default"). + And(). + WithAddedResource("XDownstreamResource", "new-manual-default", "default"). + WithField("spec.forProvider.configData", "v1-new-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Crossplane selects a composition only for a composite with no compositionRef, and never + // re-selects. A composite whose input relies on a selector therefore keeps the compositionRef + // Crossplane wrote, here composition A, even though the selector now matches two compositions. + // Before, the selector was re-evaluated against the input, and the diff failed as ambiguous. + "ExistingXRSelectingItsCompositionKeepsTheClusterCompositionRef": { + reason: "An existing XR whose input selects its composition by label renders with the composition Crossplane already bound it to", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/selector-bound/setup.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/selector-bound/existing-xr.yaml", + "testdata/diff/resources/selector-bound/existing-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-selector-bound.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "selector-bound", "default"). + WithFieldChange("spec.forProvider.configData", "a-existing-value", "a-modified-value"). + And(). + WithModifiedResource("XNopResource", "selector-bound", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // v2 XRD with v1-style composition paths (issue #206) + "V2XRDWithV1StyleCompositionPaths": { + reason: "Validates v2 XRD using v1-style spec.compositionRef paths are correctly recognized (issue #206)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/v2-xrd-with-v1-paths.yaml", + "testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml", + "testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-v2xrd-v1paths", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-v2xrd-v1paths", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A claim passed alongside its backing XR: Crossplane's claim controller writes that XR (a + // server-side apply with forced ownership, stamping claimRef and the claim labels), so the XR + // input would be a second writer. Rejected on the XR; the claim's diff still runs. The binding + // is read from the claim's cluster copy (spec.resourceRef) and from the XR's claim labels. + "ClaimPassedWithItsBackingXRIsRejected": { + reason: "A claim and the XR bound to it, passed together, is an input error on the XR", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/child-definition.yaml", + "testdata/diff/resources/claim-nested/parent-composition.yaml", + "testdata/diff/resources/claim-nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/claim-nested/existing-claim.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + xrdAPIVersion: V1, // the XRDs are v1, so Crossplane writes the XRs' resourceRefs at spec.resourceRefs + inputFiles: []string{ + "testdata/diff/modified-claim-nested.yaml", + "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + }, + expectedError: true, + expectedErrorContains: "XNopParent/existing-parent-claim-82crv is the XR bound to claim " + + "ParentNopClaim/existing-parent-claim, which Crossplane's claim controller writes", + expectedExitCode: dp.ExitCodeToolError, + }, + "ModifiedClaimWithNestedXRsShowsDiff": { + reason: "Validates that modified Claims with nested XRs show proper diff (3 modified resources)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + // XRDs for parent and child Claims (the harness generates their XR and Claim CRDs) + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/child-definition.yaml", + // Compositions for parent and child + "testdata/diff/resources/claim-nested/parent-composition.yaml", + "testdata/diff/resources/claim-nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + // Claim is set up separately (not via owner refs - it uses spec.resourceRef to link to backing XR) + "testdata/diff/resources/claim-nested/existing-claim.yaml", + }, + // Owner ref hierarchy matches Crossplane's actual ownership model: + // - Backing XR is the root of the owner ref tree (no owner refs) + // - Nested XR has owner ref to backing XR + // - Managed resource has owner ref to nested XR + // Note: Claim is NOT in this hierarchy - it links to backing XR via spec.resourceRef, not owner refs + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + // Backing XR is the root of owner ref tree + OwnerFile: "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + // Nested XR owned by backing XR + "testdata/diff/resources/claim-nested/existing-child-xr.yaml": { + // Managed resource owned by nested XR + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-claim-nested.yaml"}, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 3, 0). + WithModifiedResource("ClusterNopResource", "existing-parent-claim-82crv-nop", ""). + And(). + WithModifiedResource("ParentNopClaim", "existing-parent-claim", "default"). + WithFieldChange("spec.parentField", "existing-parent-value", "modified-parent-value"). + And(). + WithModifiedResource("XNopChild", "existing-parent-claim-82crv-child", ""). + WithFieldChange("spec.childField", "existing-parent-value", "modified-parent-value"). + // The nested XR's own reconciler wrote its composition refs, not the parent's composed + // apply, so the parent's render, which omits them, leaves them in place. + WithFieldChange("spec.compositionRef.name", "child-nop-claim-composition", "child-nop-claim-composition"). + WithFieldChange("spec.compositionRevisionRef.name", "child-nop-claim-composition-db22ad5", "child-nop-claim-composition-db22ad5"), + xrdAPIVersion: V1, // Use V1 style resourceRefs since XRDs have claims + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "FunctionCredentialsAutoFetch": { + reason: "Successfully renders XR when composition references credentials that exist in cluster", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr-with-creds.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/credentials/composition-with-creds.yaml", + "testdata/diff/resources/credentials/function-credentials-secret.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). + WithField("spec.forProvider.configData", "test-value-creds"). + And(). + WithAddedResource("XNopResource", "test-resource-with-creds", "default"). + WithField("spec.coolField", "test-value-creds"), + expectedError: false, + }, + "FunctionCredentialsFromCLI": { + reason: "Successfully renders XR when credentials provided via --function-credentials flag", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr-with-creds.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/credentials/composition-with-creds.yaml", + // Note: NOT setting up the secret in cluster - testing CLI override + "testdata/diff/resources/functions.yaml", + }, + // Credentials loaded from CLI flag file + functionCredentials: "testdata/diff/resources/credentials/cli-credentials.yaml", + expectedExitCode: dp.ExitCodeDiffDetected, + // WithNoWarnings is the point of this case for issue #481: the secret the composition + // references is deliberately absent from the cluster and supplied by --function-credentials + // instead, so the shortfall advisory must NOT fire. It used to, because the advisory was + // raised inside the credential client, which runs before the CLI-supplied secrets are merged + // in — telling a user who had just solved the problem to go and solve it. + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithNoWarnings(). + WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). + WithField("spec.forProvider.configData", "test-value-creds"). + And(). + WithAddedResource("XNopResource", "test-resource-with-creds", "default"). + WithField("spec.coolField", "test-value-creds"), + expectedError: false, + }, + // Paired sequencer gating tests — the fixture sequencer-gating-composition.yaml + // is deliberately correct (rules match composition-resource-names stage0-/stage1- + // AND auto-ready runs BEFORE sequencer so DesiredComposed.Ready is populated + // before sequencer's gating check). Together these two cases discriminate + // --eventual-state from default behavior: only eventual-state surfaces stage1. + "SequencerGatesLaterStageWithoutEventualState": { + reason: "Without --eventual-state, function-sequencer hides stage1 until stage0 is ready", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated). + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "sequencer-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + "SequencerGatedStageAppearsWithEventualState": { + reason: "With --eventual-state, sequencer-gated stage1 surfaces via synthesize-Ready iteration", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(3, 0, 0). + WithAddedResource("XNopResource", "sequencer-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + // Issue #564: an existing XR whose stage0 exists but is not Ready yet, the normal state of an + // XR whose first stage is still provisioning. Nothing renders that is not already observed, so + // eventual state used to stop at once and never synthesize stage0's Ready; but Crossplane + // composes stage1 as soon as stage0 becomes Ready. + "SequencerGatedStageAppearsForExistingXRWithNotReadyStage0WithEventualState": { + reason: "With --eventual-state, stage1 surfaces when stage0 already exists but is not Ready", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/diff/resources/existing-sequencer-gating-xr.yaml", "testdata/diff/resources/existing-sequencer-gating-stage0.yaml"), + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR and stage0 modified, stage1 added (unlocked by stage0's synthesized Ready). + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(1, 2, 0). + WithModifiedResource("XNopResource", "sequencer-test", "default"). + WithFieldChange("spec.coolField", "existing-value", "test-value"). + And(). + WithModifiedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + // The converged counterpart: both stages exist and are Ready, so synthesizing Ready changes + // nothing and eventual state is the first render. Only the spec changes the input makes show. + "EventualStateAddsNothingWhenEveryStageIsReady": { + reason: "With --eventual-state, an XR whose composed resources are all Ready shows only its real changes", + timeout: longTimeout, + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/diff/resources/existing-sequencer-gating-xr.yaml", + "testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml", + "testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml"), + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(0, 3, 0). + WithModifiedResource("XNopResource", "sequencer-test", "default"). + WithFieldChange("spec.coolField", "existing-value", "test-value"). + And(). + WithModifiedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "test-value"). + And(). + WithModifiedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "test-value"), + expectedError: false, + }, + // Paired composition-driven gating tests — the fixture conditional-gating-composition.yaml + // encodes gating in go-templating itself (no function-sequencer): stage1 is only rendered + // when observed stage0 has Ready=True. This isolates eventual-state simulation from + // function-sequencer and exercises the synthesize-Ready path directly. + "ConditionalGatingHidesLaterStageWithoutEventualState": { + reason: "Without --eventual-state, template conditionally gates stage1 because observed stage0 is not Ready", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + inputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "conditional-gating-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + "ConditionalGatedStageAppearsWithEventualState": { + reason: "With --eventual-state, synthesize-Ready flips stage0 Ready=True in observed so the template renders stage1", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added. + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(3, 0, 0). + WithAddedResource("XNopResource", "conditional-gating-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + "EventualStateWithSequencerAndEnvironmentConfigs": { + reason: "Shows eventual state with function-sequencer AND function-environment-configs to verify requirements resolution during simulation", + timeout: longTimeout, // Multiple simulation iterations with requirements resolution need more time + // This test verifies that the eventual state simulation correctly resolves requirements + // (like environment configs) during its iterative rendering, not just in the final render. + // The composition uses: + // 1. function-environment-configs to fetch env config (requires requirements iteration) + // 2. function-go-templating to generate resources using env config data + // 3. function-sequencer to stage resources + // 4. function-auto-ready + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-with-envconfig-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-with-envconfig-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/environment-config-v1beta1.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredOutput: tu.ExpectDiff(). + WithSummary(3, 0, 0). // 2 downstream resources + 1 XR + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "staging"). // From environment config + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "staging"). // From environment config + And(). + WithAddedResource("XNopResource", "sequencer-envconfig-test", "default"). + WithField("spec.coolField", "test-value"), + expectedError: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + runIntegrationTest(t, XRDiffTest, tt) + }) + } +} + +// TestCompDiffIntegration runs an integration test for the composition diff command. +func TestCompDiffIntegration(t *testing.T) { + t.Parallel() + + // Set up logger for controller-runtime (global setup, once per test function) + tu.SetupKubeTestLogger(t) + + tests := map[string]IntegrationTestCase{ + "CompositionChangeImpactsXRs": { + reason: "Validates composition change impacts existing XRs", + // Set up existing XRs that use the original composition + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use the composition + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + // New composition files that will be diffed + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/another-resource (namespace: default) + ⚠ XNopResource/test-resource (namespace: default) + +Summary: 2 resources with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/another-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: another-resource + name: another-resource + namespace: default + spec: + forProvider: +- configData: another-existing-value +- resourceTier: basic ++ configData: updated-another-existing-value ++ resourceTier: premium + +--- +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Issue #452: an XR with a deletionTimestamp is on Crossplane's teardown path and will never + // adopt the diffed composition's revision. Rendering it yields no composed resources (the + // composite reconciler takes its deletion path), so including it produces a meaningless + // impact analysis. It must be excluded and counted, leaving only the live XR evaluated. + "CompositionDiffExcludesDeletingXRs": { + reason: "A deleting XR is excluded from impact analysis and counted as filteredByDeletion; the live XR is still evaluated", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-xr-1.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-1.yaml": nil, + }, + }, + { + OwnerFile: "testdata/comp/resources/existing-xr-deleting.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-deleting.yaml": nil, + }, + }, + }, + deleteAfterSetup: []string{"testdata/comp/resources/existing-xr-deleting.yaml"}, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed + // Total counts both discovered XRs; only the live one is evaluated (1 with changes), and + // the deleting one is reported via filteredByDeletion. In default-discovery mode filtered + // XRs are counted but not surfaced as impact entries, so only test-resource appears. + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 1, 0, 0). + WithFilteredByDeletion(1). + WithXRImpact("XNopResource", "test-resource", "default", "changed"), + }, + // Issue #452, --resource mode: filtered XRs are surfaced explicitly there, so a user who names + // a deleting composite is told why it was skipped rather than getting silence. + "ResourceFilterSurfacesDeletingXR": { + reason: "--resource matching a deleting composite surfaces it as filtered with reason deleting", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-xr-deleting.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-deleting.yaml": nil, + }, + }, + }, + deleteAfterSetup: []string{"testdata/comp/resources/existing-xr-deleting.yaml"}, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/deleting-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithFilteredByDeletion(1). + WithXRImpact("XNopResource", "deleting-resource", "default", "filtered"). + WithFilterReason("deleting"), + }, + // Issue #495, `comp` side: impact analysis renders each affected XR through the same observed- + // resource fetch as `xr`, so a composed resource deleted out of band is tolerated with the same + // warning. Being genuinely absent, it is then reported as something the XR will create. + "CompositionDiffComposedResourceDeletedOutOfBandWarns": { + reason: "comp against an XR whose resourceRefs name a deleted composed resource warns and reports it as an addition", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-xr-1.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-1.yaml": nil, + }, + }, + }, + deleteAfterSetup: []string{"testdata/comp/resources/existing-downstream-1.yaml"}, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithWarning("was not found in the cluster"). + WithWarningContext(map[string]string{ + "resource": "XDownstreamResource/test-resource", + "namespace": "default", + }). + And(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(1, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"), + }, + // The comp side of TestDiffIntegration/ResourceRemovalSkipsResourcesTheXRDoesNotControl (#552): + // the updated composition stops rendering resource2, which the XR controls, so it is removed; + // the XR's resourceRefs also name a resource another XR controls and one with no controller, + // which the composition doesn't render either, and neither of those is removed. + "CompositionDropsResourceRemovingOnlyWhatTheXRControls": { + reason: "A composition that stops rendering resources removes only those the XR controls", + // What this case pins: test-resource's resourceRefs still name two resources it doesn't control. + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/removal-test-original-composition.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/comp/resources/functions.yaml", + // Deliberately not in crossplaneManagedResources: its controller reference was removed, so + // no XR controls it, though test-resource's resourceRefs still name it. + "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml", + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml", // still rendered: kept + "testdata/diff/resources/removal-test-ns-downstream-resource2.yaml", // no longer rendered: removed + ), + composes( + "testdata/diff/resources/existing-other-xr.yaml", + "testdata/diff/resources/existing-other-xr-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/resources/removal-test-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("removal-test-composition"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(0, 0, 1). + WithDownstreamResource("removed", "XDownstreamResource", "resource-to-be-removed", "default"), + }, + "CompositionDiffIgnorePaths": { + reason: "Validates that ArgoCD annotations are ignored in composition diffs", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XR with ArgoCD annotations + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-with-argocd.yaml", + "testdata/comp/resources/existing-downstream-with-argocd.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + // The composition is unchanged, so impact analysis would be skipped by default (issue + // #453). This case is about XR/downstream-level ignore paths, so it opts back in. + analyzeUnchanged: true, + ignorePaths: []string{ + "metadata.annotations[argocd.argoproj.io/tracking-id]", + "metadata.labels[argocd.argoproj.io/instance]", + }, + expectedOutput: ` +=== Composition Changes === + +No changes detected in composition xnopresources.diff.example.org + +=== Affected Composite Resources === + + ✓ XNopResource/test-resource (namespace: default) + +Summary: 1 resource unchanged + +=== Impact Analysis === + +All composite resources are up-to-date. No downstream resource changes detected. + +`, + expectedError: false, + noColor: true, + }, + // Issue #453: any revision an unchanged composition produced would carry the same spec, so no XR + // renders differently and the affected-XR / impact-analysis sections are replaced by a skip note. + // + // These fixtures are deliberately ones that DO produce a downstream delta when evaluated (see + // UnchangedCompositionAnalyzeUnchangedEvaluatesXRs, which asserts exactly that against the same + // setup). So this case proves two things at once: the skip suppresses a report that would + // otherwise be emitted, and the exit code is 0 rather than the 3 that report would have caused + // — a delta not attributable to any composition change must not gate a CI pipeline. + "UnchangedCompositionSkipsImpactAnalysis": { + reason: "An unchanged composition skips impact analysis entirely and says so", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +No changes detected in composition xnopresources.diff.example.org + +Impact analysis skipped: this composition is identical to the cluster's, so applying it creates no new CompositionRevision and no composite resource could change as a result. Pass --analyze-on=always to evaluate them anyway. + +`, + expectedError: false, + // No composition change and no evaluated XRs, so nothing was detected. + expectedExitCode: dp.ExitCodeSuccess, + noColor: true, + }, + // Issue #467: the cluster composition was applied with a client-side `kubectl apply`, so it + // carries kubectl.kubernetes.io/last-applied-configuration; the file being diffed never does. + // That annotation is suppressed from the rendered diff — it is a multi-KB serialization of the + // object, useless to show — but Crossplane's Composition.Hash() covers annotations, so applying + // this DOES create a new CompositionRevision that composites re-point to, and whose name a + // template can read off the XR's compositionRevisionRef. A suppression made for readability + // must not decide that away, so the composites are evaluated. Contrast the sibling above, which + // is byte-identical and needs --analyze-unchanged to evaluate anything. + // + // This runs the real CLI wiring, which the unit coverage in + // TestDefaultCompDiffProcessor_calculateCompositionDiff cannot reach: it pins that + // defaultProcessorOptions does not fold the annotation into --ignore-paths, and that the + // display-only suppression does not leak into the change verdict. + // + // The evaluated composite renders unchanged, because the new revision's spec is identical, so + // nothing renders differently and the exit code is 0 (as with AnalyzeOnSpecChangeSkipsMetadataOnlyChange). + // What proves evaluation is that the composite is counted, rather than impact analysis being skipped. + "CompositionAppliedWithKubectlEvaluatesXRs": { + reason: "A composition differing only in kubectl's last-applied-configuration still counts as changed, because applying it creates a new CompositionRevision", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition-kubectl-applied.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1-kubectl-applied.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStderrContains: []string{ + // Issue #474: the composites are still evaluated, but they are rendered with their existing + // compositionRevisionRef, because the name of the revision this would create cannot be + // predicted for a client-side-applied composition. Saying so is the honest position: if this + // composition's template read the revision name, the resulting change would go undetected. + "Could not predict the name of the CompositionRevision", + }, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + // The same advisory reaches warnings[] for machine consumers, keyed by composition so that two + // compositions declining to predict stay two warnings rather than being merged as duplicates. + WithWarning("Could not predict the name of the CompositionRevision"). + WithWarningContext(map[string]string{"composition": "xnopresources.diff.example.org"}). + And(). + WithComposition("xnopresources.diff.example.org"). + // createsRevision stays true while the name is withheld: the annotation delta is real, and a + // stale annotation genuinely would be rewritten and mint a revision. Only the identity is + // unknowable, which is why the guard makes the weak claim rather than "no revision". + WithRevisionImpact("metadata", true, 1). + WithoutPredictedRevisionName(). + WithAffectedResources(1, 0, 1, 0). + WithXRImpact("XNopResource", "test-resource", "default", "unchanged"), + }, + // Issue #500: the same asymmetry, but the cluster's last-applied-configuration is the one a + // client-side `kubectl apply` of this very file would write. Re-applying it computes the same + // annotation, the patch is empty and kubectl writes nothing, so no CompositionRevision is created. + // The sibling above differs only in that its annotation is stale, which a re-apply does rewrite. + "CompositionReappliedWithKubectlCreatesNoRevision": { + reason: "A composition whose live last-applied-configuration already describes the file is unchanged, because re-applying it writes nothing", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition-kubectl-applied-current.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithNoWarnings(). + WithComposition("xnopresources.diff.example.org"). + WithImpactAnalysisSkipped(). + WithRevisionImpact("none", false, 0), + }, + // Issue #474: the case that makes "creates a revision but renders identically" a claim to be + // verified rather than assumed. The composition edit is metadata-only — one added label, identical + // spec — so the CompositionRevision it mints carries a byte-identical spec. But the template reads + // the revision's own name off the composite and propagates it into the composed resource, so the + // rendered output genuinely changes. + // + // Before the fix, the composite was rendered with its EXISTING compositionRevisionRef: configData + // came out as the old revision's name, matched the cluster, and the tool reported unchanged with + // exit 0. Seeding the predicted ref is what turns that into the exit 3 below. This is the + // regression test for the whole feature — stop comp's composition provider resolving the predicted + // revision, or stop the processor writing the resolved revision to the ref, and this is what fails. + // + // Note also what is NOT in the output: the composite's own + // spec.crossplane.compositionRevisionRef diff. The predicted ref is written only to the render input, + // never to the composite's dry-run payload, which is why the exit code reflects rendering rather than + // bookkeeping; predictedRevisionName is what keeps the downstream change interpretable without it. + "RevisionNamePropagatesToComposedResource": { + reason: "A metadata-only composition edit whose template reads the CompositionRevision name changes rendered output, and is detected rather than assumed away", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-revision-ref.yaml", + ), + }, + inputFiles: []string{"testdata/comp/revision-templating-updated-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + // The name was predictable, so the could-not-predict advisory must stay silent. + WithNoWarnings(). + WithComposition("xrevisionrefs.diff.example.org"). + WithRevisionImpact("metadata", true, 1). + // Matched by pattern, not literally: the suffix is the first 7 hex digits of the + // composition's hash, so pinning it would make any edit to the fixture a test failure. The + // derivation is pinned exactly by TestRevisionIdentity, which is where that belongs. + WithPredictedRevisionNamePattern(`^xrevisionrefs\.diff\.example\.org-[0-9a-f]{7}$`). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "revision-ref-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "revision-ref-resource", "default"). + // The old value is the revision the composite currently tracks; the new one is the predicted + // revision, i.e. the thing this whole feature exists to surface. Both are real revision names, + // so both match the pattern above: only the exact values tell them apart. + WithFieldChange("spec.forProvider.configData", + "xrevisionrefs.diff.example.org-e36719b", "xrevisionrefs.diff.example.org-ec5496c"), + }, + // Issue #536 / #479: the same edit with --include-manual, which keeps a Manual composite for analysis + // without making it adopt the new revision. Only the Automatic composite is rendered with the + // predicted revision name; the Manual one stays pinned, so its template-read name, and with it its + // composed resource, is unchanged. Giving every composite the name would report the pinned one as + // changing to a revision it never uses. + "RevisionNameReachesOnlyRepointingCompositesWithIncludeManual": { + reason: "Under --include-manual, the predicted revision name reaches an Automatic composite but not a Manual one pinned to an older revision", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/comp/resources/revision-templating-composition-older-revision.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-revision-ref.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-pinned-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml", + ), + }, + inputFiles: []string{"testdata/comp/revision-templating-updated-composition.yaml"}, + namespace: "default", + includeManual: true, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithNoWarnings(). + WithComposition("xrevisionrefs.diff.example.org"). + // One composite re-points; the Manual one is kept but adopts nothing. + WithRevisionImpact("metadata", true, 1). + WithPredictedRevisionNamePattern(`^xrevisionrefs\.diff\.example\.org-[0-9a-f]{7}$`). + WithAffectedResources(2, 1, 1, 0). + WithXRImpact("XNopResource", "pinned-revision-ref-resource", "default", "unchanged"). + WithDownstreamSummary(0, 0, 0). + AndComp(). + WithXRImpact("XNopResource", "revision-ref-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "revision-ref-resource", "default"). + WithFieldChange("spec.forProvider.configData", + "xrevisionrefs.diff.example.org-e36719b", "xrevisionrefs.diff.example.org-ec5496c"), + }, + // Issue #498, the claim variant of RevisionNamePropagatesToComposedResource. A claim is rendered from its backing XR with the + // claim's spec synced in. Under Automatic the claim's own ref is never propagated (the XR controller + // owns the backing XR's), so seeding the claim would reach nothing: comp's composition provider is + // handed the backing XR the render consumes, and resolves that to the predicted revision. Without it, + // the backing XR's stale ref renders and the change goes unreported. + "ClaimRevisionNamePropagatesToComposedResource": { + reason: "A metadata-only composition edit is rendered for an Automatic claim with the revision its backing XR would re-point to", + setupFiles: []string{ + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-revision-templating-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/test-namespace.yaml", + "testdata/comp/resources/existing-claim-revision-ref.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-claim-revision-ref-xr.yaml", + "testdata/comp/resources/existing-claim-revision-ref-downstream.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/comp/claim-revision-templating-updated-composition.yaml"}, + namespace: "test-namespace", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + // The claim's composed resource is labelled with its backing XR's name, which is the claim's + // owner, so no "already belongs to another composite" advisory is raised (#534). + WithNoWarnings(). + WithComposition("xrevisionrefclaims.diff.example.org"). + WithRevisionImpact("metadata", true, 1). + WithPredictedRevisionNamePattern(`^xrevisionrefclaims\.diff\.example\.org-[0-9a-f]{7}$`). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("NopClaim", "revision-ref-claim", "test-namespace", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "revision-ref-claim-xr", ""). + // The old value is the revision the backing XR tracks now; the new one is the predicted name. + WithFieldChange("spec.forProvider.configData", + "xrevisionrefclaims.diff.example.org-98b484d", "xrevisionrefclaims.diff.example.org-55c3a3b"), + }, + // Issue #498, the other half: when nothing is seeded, an Automatic claim renders against the + // revision its backing XR tracks now, not against no revision at all. Nothing is seeded here + // because the composition differs from the cluster's only by a stale kubectl annotation, so the + // name of the revision that would mint is unknowable (see CompositionAppliedWithKubectlEvaluatesXRs); + // the claim is still rendered, so the backing XR's ref is what the template reads. + "ClaimRendersWithBackingXRRevisionWhenNothingIsSeeded": { + reason: "An Automatic claim with nothing seeded renders with its backing XR's compositionRevisionRef, so a template reading the revision name shows no spurious change", + setupFiles: []string{ + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/test-namespace.yaml", + "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml", + "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/comp/resources/claim-revision-templating-composition.yaml"}, + namespace: "test-namespace", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStderrContains: []string{"Could not predict the name of the CompositionRevision"}, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + // The could-not-predict advisory is the only warning: the claim's composed resource is labelled + // with its backing XR's name, so it raises no ownership advisory (#534). + WithWarning("Could not predict the name of the CompositionRevision"). + WithWarningContext(map[string]string{"composition": "xrevisionrefclaims.diff.example.org"}). + And(). + WithComposition("xrevisionrefclaims.diff.example.org"). + WithRevisionImpact("metadata", true, 1). + WithoutPredictedRevisionName(). + WithAffectedResources(1, 0, 1, 0). + WithXRImpact("NopClaim", "revision-ref-claim", "test-namespace", "unchanged"). + WithDownstreamSummary(0, 0, 0), + }, + // Issue #472: the same metadata-only change, with the user opting out of paying a render per + // composite for it. The composites go unevaluated — but the mutative consequence is still + // reported, which is what keeps --analyze-on a cost knob rather than a correctness mode. So the + // skip is recorded, and revisionImpact says a CompositionRevision is created and one composite + // would adopt it. Exit code 0: nothing renders differently, and a GitOps loop re-applying the + // same manifests must not fail its gate forever. + "AnalyzeOnSpecChangeSkipsMetadataOnlyChange": { + reason: "--analyze-on=spec-change leaves a metadata-only change unevaluated, while still reporting that it creates a CompositionRevision", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition-kubectl-applied.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1-kubectl-applied.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + analyzeOn: "spec-change", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithImpactAnalysisSkipped(). + WithRevisionImpact("metadata", true, 1). + // Issue #474: no predicted name here, and that absence is the point. The compositions + // differ by nothing but kubectl's last-applied-configuration, whose post-apply value is a + // function of how the user applies rather than of the file — so the hash, and therefore the + // revision's name, is unknowable. createsRevision stays true; only the identity is withheld. + WithoutPredictedRevisionName(), + }, + // The same setup with --analyze-unchanged evaluates the XRs after all (the pre-edit + // convergence-baseline workflow). Here the composed resource has drifted out of band, so the + // evaluation reports a downstream modification even though the composition is byte-identical + // to the cluster's. That delta is not caused by this composition — it is exactly the class of + // finding the default skip keeps out of the "impact of your composition change" report, and why + // opting in is explicit. + "UnchangedCompositionAnalyzeUnchangedEvaluatesXRs": { + reason: "--analyze-unchanged evaluates affected XRs even though the composition is unchanged, surfacing deltas not caused by it", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-drifted.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + analyzeUnchanged: true, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "drifted-value", "existing-value"), + }, + "CompositionDiffCustomNamespace": { + reason: "Validates composition diff with custom namespace", + // Set up existing XRs in both custom and default namespaces to validate filtering + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + // Create the custom namespace first + "testdata/comp/resources/custom-namespace.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + // Add existing XRs in custom namespace (should be included in output) + composes( + "testdata/comp/resources/existing-custom-ns-xr.yaml", + "testdata/comp/resources/existing-custom-ns-downstream.yaml", + ), + // Add existing XRs in default namespace (should be filtered out) + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + // New composition files that will be diffed + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "custom-namespace", + // Expected output should only show custom-namespace resources, NOT default namespace resources + // This validates that namespace filtering works correctly + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/custom-namespace-resource (namespace: custom-namespace) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/custom-namespace-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: custom-namespace-resource + name: custom-namespace-resource + namespace: custom-namespace + spec: + forProvider: +- configData: custom-ns-existing-value +- resourceTier: basic ++ configData: updated-custom-ns-existing-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Two compositions that BOTH genuinely change, each with its own affected XR. This is the + // aggregation case: one CompositionDiff entry per input, each carrying its own impact analysis, + // with hasDiffs ORed across them. MultipleCompositionDiffImpact below covers the mixed + // changed/unchanged variant; before this case existed, nothing covered two compositions that + // both report impacts — the second composition there has no affected XR at all. + "MultipleChangedCompositionsBothReportImpact": { + reason: "Two changed compositions each report their own affected XR's downstream changes", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/original-composition-2.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + // XR using composition 1 + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + // XR using composition 2 + composes( + "testdata/comp/resources/existing-xr-v2.yaml", + "testdata/comp/resources/existing-downstream-v2.yaml", + ), + }, + inputFiles: []string{ + "testdata/comp/updated-composition.yaml", + "testdata/comp/updated-composition-2-changed.yaml", + }, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"). + WithFieldChange("spec.forProvider.resourceTier", "basic", "premium"). + AndComposition("xnopresources-v2.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "v2-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "v2-resource", "default"). + WithFieldChange("spec.forProvider.configData", "v2-updated-v2-existing-value", "v2-changed-v2-existing-value"). + WithFieldChange("spec.forProvider.resourceTier", "enterprise", "premium-plus"), + }, + // Note the second input composition (xnopresources-v2.diff.example.org) is byte-identical to its + // in-cluster version, so this case doubles as the mixed changed/unchanged scenario: the changed + // composition reports its impact while the unchanged one is skipped per-composition (issue + // #453). It is not a broken fixture. + "MultipleCompositionDiffImpact": { + reason: "Validates multiple compositions in one invocation: the changed one reports impact on existing XRs, the unchanged one is skipped", + // Set up existing XRs that use both compositions + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/original-composition-2.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use the compositions + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + // Multiple composition files that will be diffed + inputFiles: []string{ + "testdata/comp/updated-composition.yaml", + "testdata/comp/updated-composition-2.yaml", + }, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/another-resource (namespace: default) + ⚠ XNopResource/test-resource (namespace: default) + +Summary: 2 resources with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/another-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: another-resource + name: another-resource + namespace: default + spec: + forProvider: +- configData: another-existing-value +- resourceTier: basic ++ configData: updated-another-existing-value ++ resourceTier: premium + +--- +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 2 modified + +================================================================================ + +=== Composition Changes === + +No changes detected in composition xnopresources-v2.diff.example.org + +Impact analysis skipped: this composition is identical to the cluster's, so applying it creates no new CompositionRevision and no composite resource could change as a result. Pass --analyze-on=always to evaluate them anyway. +`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionDiffFiltersManualXRs": { + reason: "Validates composition diff filters Manual policy XRs by default", + // Set up existing XRs - one with Automatic policy and one with Manual policy + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + // Add existing XR with Automatic policy (should be included) + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + // Add existing XR with Manual policy (should be filtered out by default) + composes( + "testdata/comp/resources/existing-xr-manual.yaml", + "testdata/comp/resources/existing-downstream-manual.yaml", + ), + }, + // Updated composition + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + // Expected output should only show test-resource (Automatic), NOT manual-resource (Manual) + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/test-resource (namespace: default) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionUpgradesResourceAPIVersion": { + // NOTE: This test validates that resources are correctly matched across API version changes, + // avoiding delete/recreate. The composition template changes from v1beta1 to v1beta2, but + // Kubernetes automatically converts resources between served API versions. When we query for + // the v1beta2 resource, Kubernetes finds the v1beta1 resource and returns it auto-converted + // to v1beta2. From Kubernetes' perspective, the resource exists as both versions simultaneously, + // so there's no apiVersion field change to show in the diff. The important thing is that the + // resource is matched, preventing delete/recreate operations: since the stored object is + // identical at either version it renders unchanged, where an unmatched one would show as a + // +++ addition and a --- removal. The composition diff itself WILL show the template change + // from v1beta1 to v1beta2. + // + // As in TestDiffIntegration/CompositionRevisionUpgradesResourceAPIVersion, the XR composes the + // resource as Crossplane leaves it, named in its resourceRefs at v1beta1, so this also checks + // that it is not reported as removed (#552). + reason: "Validates composition upgrade that changes resource API version shows as update not remove/add", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + // NOTE: xapimigrate CRD is auto-loaded from testdata/comp/crds/ + // We apply no XRD for XApiMigrateResource because it is a regular managed resource + // (not a composite), and an XRD would make it composite, causing infinite recursion. + "testdata/comp/resources/api-version-original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-api-version-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/comp/api-version-updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xapimigrateresources.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xapimigrateresources.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | +- apiVersion: comp.example.org/v1beta1 ++ apiVersion: comp.example.org/v1beta2 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + kind: GoTemplate + source: Inline + step: generate-api-versioned-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ✓ XNopResource/test-api-version (namespace: default) + +Summary: 1 resource unchanged + +=== Impact Analysis === + +All composite resources are up-to-date. No downstream resource changes detected. +`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "NetNewCompositionNoImpact": { + reason: "Validates net-new composition with no downstream impact", + // Set up cluster with existing resources but no composition that matches the new one + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use different compositions (won't be affected) + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + // Net-new composition file that doesn't exist in cluster and targets different XR type + inputFiles: []string{"testdata/comp/net-new-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + ++++ Composition/xnewresources.diff.example.org ++ apiVersion: apiextensions.crossplane.io/v1 ++ kind: Composition ++ metadata: ++ labels: ++ environment: staging ++ provider: aws ++ name: xnewresources.diff.example.org ++ spec: ++ compositeTypeRef: ++ apiVersion: staging.diff.example.org/v1alpha1 ++ kind: XNewResource ++ mode: Pipeline ++ pipeline: ++ - functionRef: ++ name: function-go-templating ++ input: ++ apiVersion: template.fn.crossplane.io/v1beta1 ++ inline: ++ template: | ++ apiVersion: staging.nop.example.org/v1alpha1 ++ kind: XStagingResource ++ metadata: ++ name: {{ .observed.composite.resource.metadata.name }} ++ namespace: {{ .observed.composite.resource.metadata.namespace }} ++ annotations: ++ gotemplating.fn.crossplane.io/composition-resource-name: staging-resource ++ spec: ++ forProvider: ++ environment: staging ++ configData: new-{{ .observed.composite.resource.spec.newField }} ++ resourceTier: premium ++ kind: GoTemplate ++ source: Inline ++ step: generate-new-resources ++ - functionRef: ++ name: function-auto-ready ++ step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 added + +No XRs found using composition xnewresources.diff.example.org`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionChangeWithUnchangedDownstreamResources": { + reason: "Validates status indicators for unchanged downstream resources", + // Set up existing XRs that use the original composition + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/status-indicator-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use the composition + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/status-xr-1.yaml", + "testdata/comp/resources/status-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/status-xr-2.yaml", + "testdata/comp/resources/status-downstream-2.yaml", + ), + }, + // Updated composition with only metadata change (no downstream impact) + inputFiles: []string{"testdata/comp/status-indicator-updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xstatus.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: ++ labels: ++ environment: production + name: xstatus.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-value + resourceTier: standard + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ✓ XNopResource/status-test-xr-1 (namespace: default) + ✓ XNopResource/status-test-xr-2 (namespace: default) + +Summary: 2 resources unchanged + +=== Impact Analysis === + +All composite resources are up-to-date. No downstream resource changes detected.`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionChangeWithMixedStatusAndColors": { + reason: "Validates status indicators with colorization for mixed changed/unchanged XRs", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/mixed-status-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + // XR 1 with downstream resources (standard tier - will change) + composes( + "testdata/comp/resources/mixed-xr-1.yaml", + "testdata/comp/resources/mixed-downstream-db-1.yaml", + "testdata/comp/resources/mixed-downstream-storage-1.yaml", + "testdata/comp/resources/mixed-downstream-network-1.yaml", + ), + // XR 2 with downstream resources (standard tier - will change) + composes( + "testdata/comp/resources/mixed-xr-2.yaml", + "testdata/comp/resources/mixed-downstream-db-2.yaml", + "testdata/comp/resources/mixed-downstream-storage-2.yaml", + "testdata/comp/resources/mixed-downstream-network-2.yaml", + ), + // XR 3 with downstream resources (already premium tier - no change) + composes( + "testdata/comp/resources/mixed-xr-3.yaml", + "testdata/comp/resources/mixed-downstream-db-3.yaml", + "testdata/comp/resources/mixed-downstream-storage-3.yaml", + "testdata/comp/resources/mixed-downstream-network-3.yaml", + ), + }, + inputFiles: []string{"testdata/comp/mixed-status-updated-composition.yaml"}, + namespace: "default", + expectedOutput: strings.Join([]string{ + ` +=== Composition Changes === + +~~~ Composition/xmixed.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xmixed.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-database + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + resourceType: database +`, tu.Red(`- tier: standard`), ` +`, tu.Green(`+ tier: premium`), ` +`, tu.Green(`+ backupEnabled: true`), ` + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-storage + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: storage + spec: + forProvider: + resourceType: storage + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-network + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: network + spec: + forProvider: + resourceType: network + tier: standard + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 3 composites would adopt. + +=== Affected Composite Resources === + +`, tu.Yellow(` ⚠ XNopResource/mixed-test-xr-1 (namespace: default)`), ` +`, tu.Yellow(` ⚠ XNopResource/mixed-test-xr-2 (namespace: default)`), ` +`, tu.Green(` ✓ XNopResource/mixed-test-xr-3 (namespace: default)`), ` + +Summary: 2 resources with changes, 1 resource unchanged + +=== Impact Analysis === + +~~~ XDownstreamResource/mixed-test-xr-1-database + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: database + labels: + crossplane.io/composite: mixed-test-xr-1 + name: mixed-test-xr-1-database + namespace: default + spec: + forProvider: +`, tu.Red(`- resourceType: database`), ` +`, tu.Red(`- tier: standard`), ` +`, tu.Green(`+ backupEnabled: true`), ` +`, tu.Green(`+ resourceType: database`), ` +`, tu.Green(`+ tier: premium`), ` + + + +--- +~~~ XDownstreamResource/mixed-test-xr-2-database + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: database + labels: + crossplane.io/composite: mixed-test-xr-2 + name: mixed-test-xr-2-database + namespace: default + spec: + forProvider: +`, tu.Red(`- resourceType: database`), ` +`, tu.Red(`- tier: standard`), ` +`, tu.Green(`+ backupEnabled: true`), ` +`, tu.Green(`+ resourceType: database`), ` +`, tu.Green(`+ tier: premium`), ` + + + +--- + +Summary: 2 modified +`, + }, ""), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: false, + }, + "CompositionChangeImpactsClaims": { + reason: "Validates composition change impacts existing Claims (issue #120)", + // Set up existing Claims and their XRs that use the original composition + setupFiles: []string{ + // XRD, composition, and functions + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-composition.yaml", + "testdata/comp/resources/functions.yaml", + // Test namespace + "testdata/comp/resources/test-namespace.yaml", + // Existing Claims and their corresponding XRs + "testdata/comp/resources/existing-claim-1.yaml", + "testdata/comp/resources/existing-claim-2.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-claim-1-xr.yaml", + "testdata/comp/resources/existing-claim-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-claim-2-xr.yaml", + "testdata/comp/resources/existing-claim-downstream-2.yaml", + ), + }, + xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + // Updated composition that will be diffed + inputFiles: []string{"testdata/comp/updated-claim-composition.yaml"}, + namespace: "test-namespace", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/nopclaims.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: nopclaims.diff.example.org + spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ⚠ NopClaim/test-claim-1 (namespace: test-namespace) + ⚠ NopClaim/test-claim-2 (namespace: test-namespace) + +Summary: 2 resources with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/test-claim-1-xr + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/claim-name: test-claim-1 + crossplane.io/claim-namespace: test-namespace + crossplane.io/composite: test-claim-1-xr + name: test-claim-1-xr + spec: + forProvider: +- configData: claim-value-1 +- resourceTier: basic ++ configData: updated-claim-value-1 ++ resourceTier: premium + +--- +~~~ XDownstreamResource/test-claim-2-xr + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/claim-name: test-claim-2 + crossplane.io/claim-namespace: test-namespace + crossplane.io/composite: test-claim-2-xr + name: test-claim-2-xr + spec: + forProvider: +- configData: claim-value-2 +- resourceTier: basic ++ configData: updated-claim-value-2 ++ resourceTier: premium + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "SSAFieldRemovalDetection": { + reason: "Validates that field removals are correctly detected when resources are created via Server-Side Apply with Crossplane's field manager (issue #121)", + // Set up XRD, functions, and original composition with the removable field + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/field-removal/composition-with-field.yaml", + }, + // XR and downstream resource applied via SSA with Crossplane field manager + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/field-removal/existing-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/field-removal/existing-downstream.yaml": nil, + }, + }, + }, + // Updated composition that removes the removableField + inputFiles: []string{"testdata/comp/field-removal-updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xfieldremoval.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xfieldremoval.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} +- removableField: will-be-removed + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/field-removal-test (namespace: default) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/field-removal-test + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: field-removal-test + name: field-removal-test + namespace: default + spec: + forProvider: + configData: test-value +- removableField: will-be-removed + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "SHA256DigestFunctionReference": { + reason: "Validates composition diff works with functions referenced by SHA256 digest instead of tag", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/sha256-composition.yaml", + "testdata/comp/resources/functions-sha256.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-sha256-xr.yaml", + "testdata/comp/resources/existing-sha256-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-sha256-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources-sha256.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources-sha256.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating-sha256 + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/sha256-test-resource (namespace: default) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/sha256-test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: sha256-test-resource + name: sha256-test-resource + namespace: default + spec: + forProvider: +- configData: sha256-value +- resourceTier: basic ++ configData: updated-sha256-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Issue #485, comp's half. comp re-renders each discovered composite through DiffSingleResource, + // the same recursion xr uses, so one fix covers both — this pins that for comp rather than + // assuming it. comp's own error only counts the failures, so the assertion that matters is that + // the depth message reaches the failing composite's impact entry: without it, a user would be + // told something failed but not that the composition recurses into itself. + "CyclicCompositionStopsAtMaxNestedDepth": { + reason: "comp shares xr's recursion path (it enters via DiffSingleResource), so a composition cycle must stop at --max-nested-depth here too, and say so on the failing composite (#485)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/cycle/xrds.yaml", + "testdata/diff/resources/cycle/compositions.yaml", + "testdata/comp/resources/functions.yaml", + // Composed nothing: with a cyclic composition, Crossplane would nest XRs without end, so + // the case models the XR before its first compose rather than an endless tree. + "testdata/comp/resources/cycle/existing-cycle-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-cycle-composition.yaml"}, + namespace: "default", + expectedError: true, + expectedErrorContains: "impact analysis failed for 1 XR(s)", + expectedExitCode: dp.ExitCodeToolError, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xcycleas.cycle.example.org"). + WithAffectedResources(1, 0, 0, 1). + WithXRImpact("XCycleA", "test-cycle", "default", "error"). + WithErrorContaining("maximum nesting depth exceeded"), + }, + // The same failure under --output-version=v2: the impact entry is the shared per-XR entry, and + // its errors[] holds the same OutputError as the top-level errors[]. + "CyclicCompositionStopsAtMaxNestedDepthV2": { + reason: "With --output-version=v2, a failing composite's impactAnalysis entry carries its error as a structured errors[] entry", + outputFormat: "json", + outputVersion: "v2", + setupFiles: []string{ + "testdata/diff/resources/cycle/xrds.yaml", + "testdata/diff/resources/cycle/compositions.yaml", + "testdata/comp/resources/functions.yaml", + // Composed nothing, as in CyclicCompositionStopsAtMaxNestedDepth. + "testdata/comp/resources/cycle/existing-cycle-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-cycle-composition.yaml"}, + namespace: "default", + expectedError: true, + expectedErrorContains: "impact analysis failed for 1 XR(s)", + expectedExitCode: dp.ExitCodeToolError, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithOutputVersion("v2"). + WithError("XCycleA/test-cycle"). + WithComposition("xcycleas.cycle.example.org"). + WithAffectedResources(1, 0, 0, 1). + WithXRImpact("XCycleA", "test-cycle", "default", "error"). + WithErrorContaining("maximum nesting depth exceeded"). + WithErrorResourceID("XCycleA/test-cycle"), + }, + "NestedXRUsesOwnComposition": { + reason: "Validates that nested XRs use their own composition from the cluster, not the parent's CLI composition", + // Set up XRDs, compositions, and functions + setupFiles: []string{ + // Child XRD and composition (XNopResource uses xnopresources.diff.example.org) + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + // Parent XRD and composition + "testdata/comp/resources/nested-xr/parent-xrd.yaml", + "testdata/comp/resources/nested-xr/parent-composition.yaml", + }, + // Use crossplaneManagedResources to set up proper ownership hierarchy + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + // Parent XR owns both direct downstream and nested child XR + OwnerFile: "testdata/comp/resources/nested-xr/existing-parent-xr.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + // Direct downstream owned by parent XR + "testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml": nil, + // Nested child XR owned by parent XR, which owns its own downstream + "testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml": { + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + // Child's downstream owned by nested child XR + "testdata/comp/resources/nested-xr/existing-child-downstream.yaml": nil, + }, + }, + }, + }, + }, + // Diff the updated parent composition + inputFiles: []string{"testdata/comp/updated-parent-composition.yaml"}, + namespace: "default", + // Expected: Only the parent's direct downstream shows changes (tier change). + // The nested child's downstream should NOT change because: + // 1. The nested XR (XNopResource) uses its own composition (xnopresources.diff.example.org) + // 2. That child composition was NOT modified + // Bug scenario: If nested XR incorrectly uses parent composition, we'd see wrong changes to child's downstream. + // Expected output: Only the parent's direct downstream should show in the Impact Analysis. + // The child's downstream (XDownstreamResource/test-parent-nested) should NOT appear here. + // If it did, it would mean the nested XR incorrectly used the parent's CLI composition. + expectedOutput: `=== Impact Analysis === + +~~~ XDownstreamResource/test-parent-direct + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: direct-resource + labels: + crossplane.io/composite: test-parent + name: test-parent-direct + namespace: default + spec: + forProvider: + configData: parent-parent-value +- resourceTier: parent-tier ++ resourceTier: premium-tier + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CrossNamespaceResourceCollision": { + reason: "Validates that resources with the same name in different namespaces are correctly distinguished", + skip: true, + skipReason: "Blocked on https://github.com/crossplane-contrib/function-extra-resources/issues/106. " + + "function-extra-resources (v0.2.0/v0.3.0) emits ResourceSelector{Namespace=\"\"} for " + + "by-name references that omit `ref.namespace` — it only forwards user-yaml verbatim, " + + "never inferring from the XR's namespace. Crossplane v2.3+'s render binary then does " + + "InMemoryClient.Get(name, namespace=\"\"), a strict (GVK, ns, name) match that doesn't " + + "find our resolved ConfigMap (stored at namespace=ns-{a,b}). By-label selectors aren't " + + "affected — List(InNamespace(\"\")) lists across all namespaces — which is why our " + + "other extra-resources tests still pass. Re-enable once a function release defaults " + + "selector.Namespace to the XR's namespace.", + timeout: longTimeout, // Test involves multiple namespaces and can be slow with Docker contention + // This test catches a bug where the cache key didn't include namespace, causing + // ConfigMaps with the same name in different namespaces to collide. + // The XR in ns-a should get ConfigMap from ns-a (value: from-ns-a) + // The XR in ns-b should get ConfigMap from ns-b (value: from-ns-b) + // Bug: Both XRs would get the same ConfigMap (whichever was cached first) + setupFiles: []string{ + // Create namespaces first + "testdata/comp/resources/namespace-collision/namespace-ns-a.yaml", + "testdata/comp/resources/namespace-collision/namespace-ns-b.yaml", + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/namespace-collision/collision-composition.yaml", + "testdata/comp/resources/namespace-collision/collision-composition-revision.yaml", + // ConfigMaps with same name in different namespaces + "testdata/comp/resources/namespace-collision/configmap-ns-a.yaml", + "testdata/comp/resources/namespace-collision/configmap-ns-b.yaml", + }, + // XRs and their downstream resources in different namespaces + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml": nil, + }, + }, + { + OwnerFile: "testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml", + OwnedFiles: map[string]*HierarchicalOwnershipRelation{ + "testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/comp/updated-collision-composition.yaml"}, + // Expected: Each XR's downstream should show the correct prefix applied to its namespace's ConfigMap value. + // XR in ns-a: from-ns-a → prefix-from-ns-a + // XR in ns-b: from-ns-b → prefix-from-ns-b + // Bug scenario: If namespace collision occurs, ns-b would incorrectly show: + // from-ns-b → prefix-from-ns-a (wrong ConfigMap cached from ns-a) + expectedOutput: `=== Impact Analysis === + +~~~ XDownstreamResource/xr-in-ns-a + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-a + name: xr-in-ns-a + namespace: ns-a + spec: + forProvider: +- configData: from-ns-a ++ configData: prefix-from-ns-a + +--- +~~~ XDownstreamResource/xr-in-ns-b + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-b + name: xr-in-ns-b + namespace: ns-b + spec: + forProvider: +- configData: from-ns-b ++ configData: prefix-from-ns-b + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Paired comp sequencer-gating tests — mirror of the XR pair. Verifies that + // --eventual-state on `comp` surfaces downstream resources hidden by + // function-sequencer in the impact analysis. + "CompSequencerGatesLaterStageWithoutEventualState": { + reason: "`comp` without --eventual-state: sequencer-gated stage1 is absent from the impact analysis", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR references sequencer-gating-composition by name, so comp discovers it. It has + // composed stage0, which isn't Ready, so stage1 stays gated. + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/comp/resources/existing-sequencer-gating-xr.yaml", "testdata/comp/resources/existing-sequencer-gating-stage0.yaml"), + }, + inputFiles: []string{"testdata/comp/updated-sequencer-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: impact shows stage0 modified (its configData changed); stage1 is absent from + // downstream impact because sequencer gates it. + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("sequencer-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"), + expectedError: false, + }, + "CompSequencerGatedStageAppearsWithEventualState": { + reason: "`comp` with --eventual-state: sequencer-gated stage1 surfaces in the impact analysis", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR has composed stage0, which isn't Ready, so stage1 stays gated until eventual + // state synthesizes stage0's Ready (#564). + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/comp/resources/existing-sequencer-gating-xr.yaml", "testdata/comp/resources/existing-sequencer-gating-stage0.yaml"), + }, + inputFiles: []string{"testdata/comp/updated-sequencer-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: impact shows stage0 modified and stage1 added (eventual-state unlocks stage1). + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("sequencer-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). + WithDownstreamSummary(1, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"). + AndXR(). + WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). + // dryRun absence through `comp`: a downstream addition that reached the apiserver + // carries no dryRun object. Piggy-backed on an existing case rather than given its + // own envtest instance, since it is a pure additional assertion. + WithDryRunPerformed(). + WithField("spec.forProvider.configData", "updated-existing-value"), + expectedError: false, + }, + // Paired comp composition-driven gating tests — gating encoded in go-templating, + // no function-sequencer. Mirrors the XR composition-driven pair. + "CompConditionalGatingHidesLaterStageWithoutEventualState": { + reason: "`comp` without --eventual-state: template-gated stage1 is absent from the impact analysis", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR has composed stage0, which isn't Ready, so stage1 stays gated. + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/comp/resources/existing-conditional-gating-xr.yaml", "testdata/comp/resources/existing-conditional-gating-stage0.yaml"), + }, + inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("conditional-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"), + expectedError: false, + }, + "CompConditionalGatedStageAppearsWithEventualState": { + reason: "`comp` with --eventual-state: template-gated stage1 surfaces in the impact analysis", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR has composed stage0, which isn't Ready, so stage1 stays gated until eventual + // state synthesizes stage0's Ready (#564). + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/comp/resources/existing-conditional-gating-xr.yaml", "testdata/comp/resources/existing-conditional-gating-stage0.yaml"), + }, + inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("conditional-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). + WithDownstreamSummary(1, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"). + AndXR(). + WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"), + expectedError: false, + }, + // dryRun presence through `comp`. dryRun lives on ChangeDetail, which `xr` reaches via + // changes[] and `comp` via impactAnalysis[].downstreamChanges.changes[] — one shared wire + // shape, but only an assertion through the comp renderer proves comp actually populates it. + // The composed resource is deliberately absent from the setup so the impact is an ADDITION, + // which is the only kind of change the dryRun field applies to: the XR has its composition + // refs, but its first compose has not created its resource. + "CompDownstreamAdditionReportsDryRunSkippedWhenDryRunOnExisting": { + reason: "`comp` structured output carries dryRun.skipReason on a downstream addition when --dry-run-on=existing", + outputFormat: "json", + dryRunOn: "existing", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + // existing-downstream-1.yaml is intentionally NOT applied: with no composed resource + // in the cluster, the impact analysis reports an addition rather than a modification. + "testdata/comp/resources/existing-xr-1.yaml", + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(1, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.forProvider.configData", "updated-existing-value"), + expectedError: false, + }, + // The unverified-addition warning through `comp`: one run-wide summary in the top-level + // warnings[], counting the downstream additions behind it, rather than one per composition. + // A cluster-scoped XR is what lets the composition reach a namespace that does not exist. + "CompDownstreamAdditionsInMissingNamespaceAreCountedInOneWarning": { + reason: "`comp` summarises downstream additions it could not verify as one warning per GVK + namespace + cause, with a count", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/cluster-xrd.yaml", + "testdata/comp/resources/unverified-cluster-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/existing-unverified-cluster-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-unverified-cluster-composition.yaml"}, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStderrContains: []string{ + `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=/v1, Kind=ConfigMap, namespace=nonexistent-namespace)`, + }, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithWarning("their namespace does not exist yet"). + WithWarningContext(map[string]string{ + "gvk": "/v1, Kind=ConfigMap", + "namespace": "nonexistent-namespace", + "cause": `namespaces "nonexistent-namespace" not found`, + "count": "2", + }). + And(). + WithComposition("xnopresources-unverified.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "unverified-xr", "", "changed"). + WithDownstreamSummary(2, 0, 0). + WithDownstreamResource("added", "ConfigMap", "unverified-xr-first", "nonexistent-namespace"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"). + AndXR(). + WithDownstreamResource("added", "ConfigMap", "unverified-xr-second", "nonexistent-namespace"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"), + expectedError: false, + }, + // --resource flag tests (issue #321) + "ResourceFilterScopesAffectedXRs": { + reason: "--resource limits impact analysis to the named composites and ignores the rest", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( // test-resource (default ns) + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( // another-resource (default ns) + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/test-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"), + }, + "ResourceFilterCommaSeparated": { + reason: "--resource accepts comma-separated values via kong's auto-parsing", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resourcesCSV: "default/test-resource,default/another-resource", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 2, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + AndComp(). + WithXRImpact("XNopResource", "another-resource", "default", "changed"), + }, + "ResourceFilterUnmatched_FailsBeforeRendering": { + reason: "--resource naming a non-existent composite fails fast with an error before any rendering", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/does-not-exist"}, + expectedError: true, + expectedErrorContains: "does-not-exist", + expectedExitCode: dp.ExitCodeToolError, + }, + "ResourceFilterRespectsManualPolicy_WithoutIncludeManual": { + reason: "--resource matching a Manual-policy composite surfaces it as filtered (reason manual_policy) when --include-manual is unset", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-manual.yaml", + "testdata/comp/resources/existing-downstream-manual.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/manual-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "manual-resource", "default", "filtered"). + WithFilterReason("manual_policy"), + }, + "ResourceFilterRespectsManualPolicy_WithIncludeManual": { + reason: "--include-manual evaluates the Manual-policy composite normally instead of marking it filtered", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-manual.yaml", + "testdata/comp/resources/existing-downstream-manual.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/manual-resource"}, + includeManual: true, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "manual-resource", "default", "changed"), + }, + // Issue #388 (Bug A): an Automatic-policy XR whose compositionRevisionSelector does not match + // the edited composition's labels would not adopt the resulting revision, so it must be + // surfaced as filtered (reason revision_selector_mismatch), NOT evaluated for changes. + "ResourceFilterRespectsRevisionSelectorMismatch": { + reason: "--resource matching an Automatic XR whose compositionRevisionSelector does not match the composition's labels surfaces it as filtered (reason revision_selector_mismatch)", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/existing-xr-selector-mismatch.yaml", + }, + inputFiles: []string{"testdata/comp/updated-composition-labeled.yaml"}, + resources: []string{"default/selector-mismatch-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). + WithFilterReason("revision_selector_mismatch"), + // Note: that --include-manual does NOT rescue a selector-mismatched Automatic XR is + // proven at the unit level (TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy / + // IncludeManualTrue_StillDropsSelectorMismatchedAutomaticXRs). A second slow + // docker-render integration case for that flag interaction would be redundant. + }, + // Issue #388 (Bug A, positive): an Automatic XR whose compositionRevisionSelector DOES match + // the edited composition's labels is kept and evaluated for downstream changes (not filtered). + "ResourceFilterRespectsRevisionSelectorMatch": { + reason: "--resource matching an Automatic XR whose compositionRevisionSelector matches the composition's labels keeps it and reports downstream changes", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/existing-xr-selector-match.yaml", + }, + inputFiles: []string{"testdata/comp/updated-composition-labeled.yaml"}, + resources: []string{"default/selector-match-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: tu.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "selector-match-resource", "default", "changed"), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + runIntegrationTest(t, CompositionDiffTest, tt) + }) + } +} + +// TestDiffSingleResourceConcurrent diffs several XRs from concurrent goroutines with one processor, +// against a real apiserver and real functions, and checks each gets the diffs and the run the +// warnings a sequential run of the same XRs gets. Run it with -race: it is what shows the processor, +// its caches and its render engine are safe for concurrent use (#579). +func TestDiffSingleResourceConcurrent(t *testing.T) { + t.Parallel() + + tu.SetupKubeTestLogger(t) + + cfg, _, ctx, _ := startIntegrationCluster(t, XRDiffTest, IntegrationTestCase{ + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + }) + + // One modified and one new XR, each diffed several times at once. + var inputs []*un.Unstructured + + for _, f := range []string{"testdata/diff/modified-xr.yaml", "testdata/diff/new-xr-second.yaml"} { + res, err := readResourcesFromFile(f) + if err != nil { + t.Fatalf("cannot read %s: %v", f, err) + } + + inputs = append(inputs, res...) + } + + inputs = slices.Concat(inputs, inputs, inputs) + + // diffAll diffs inputs with a new processor, concurrently or one at a time, and returns each + // input's diffs (by index) and the warnings the run raised. + diffAll := func(t *testing.T, concurrent bool) ([]map[string]lineDiffs, []dt.OutputWarning) { + t.Helper() + + log := tu.TestLogger(t, false) + + clients, err := dclient.New(cfg, log) + if err != nil { + t.Fatalf("client.New: %v", err) + } + + if err := clients.Initialize(ctx, log); err != nil { + t.Fatalf("Clients.Initialize: %v", err) + } + + advisories := dp.NewAdvisoryCollector(io.Discard) + // The processor the xr command builds, with its flags' defaults. + opts := []dp.ProcessorOption{ + dp.WithLogger(log), + dp.WithAdvisories(advisories), + dp.WithColorize(false), + dp.WithMaxNestedDepth(10), + dp.WithMaxRenderIterations(20), + dp.WithDryRunOn(dp.DryRunOnAll), + } + if bin := localCrossplaneBinary(t); bin != "" { + opts = append(opts, dp.WithCrossplaneRenderBinary(bin)) + } + + proc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) + t.Cleanup(func() { + if err := proc.Cleanup(context.WithoutCancel(ctx)); err != nil { + t.Errorf("Cleanup: %v", err) + } + }) + + if err := proc.Initialize(ctx); err != nil { + t.Fatalf("Initialize: %v", err) + } + + got := make([]map[string]lineDiffs, len(inputs)) + diff := func(i int) { + diffs, err := proc.DiffSingleResource(ctx, inputs[i].DeepCopy(), clients.XpClients.Composition.FindMatchingComposition) + if err != nil { + t.Errorf("DiffSingleResource(%s): %v", inputs[i].GetName(), err) + return + } + + got[i] = make(map[string]lineDiffs, len(diffs)) + for k, d := range diffs { + got[i][k] = lineDiffs{Type: d.DiffType, Lines: d.LineDiffs} + } + } + + var wg sync.WaitGroup + + for i := range inputs { + if !concurrent { + diff(i) + continue + } + + wg.Go(func() { diff(i) }) + } + + wg.Wait() + + return got, advisories.Advisories() + } + + wantDiffs, wantWarnings := diffAll(t, false) + gotDiffs, gotWarnings := diffAll(t, true) + + if diff := cmp.Diff(wantDiffs, gotDiffs); diff != "" { + t.Errorf("concurrent diffs differ from sequential diffs (-sequential +concurrent):\n%s", diff) + } + + if diff := cmp.Diff(wantWarnings, gotWarnings); diff != "" { + t.Errorf("concurrent warnings differ from sequential warnings (-sequential +concurrent):\n%s", diff) + } +} + +// lineDiffs is what TestDiffSingleResourceConcurrent compares of a resource's diff. +type lineDiffs struct { + Type dt.DiffType + Lines []diffmatchpatch.Diff +} diff --git a/internal/diff/integration/it_utils_test.go b/internal/diff/integration/it_utils_test.go new file mode 100644 index 00000000..15baa66f --- /dev/null +++ b/internal/diff/integration/it_utils_test.go @@ -0,0 +1,1156 @@ +package integration + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "sort" + "strings" + "testing" + "time" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + gyaml "gopkg.in/yaml.v3" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + + xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +// CrossplaneFieldManager is the field manager used for SSA when setting up Crossplane-managed resources. +// This simulates how Crossplane applies composed resources in production. +const CrossplaneFieldManager = "apiextensions.crossplane.io/composed/integration-test" + +// CompositeReconcilerFieldManager stands for Crossplane's composite reconciler, which records the +// composition and revision it selects for an XR with an Update of the XR (crossplane +// internal/controller/apiextensions/composite/api.go), not as part of any composed apply. +const CompositeReconcilerFieldManager = "crossplane" + +// compositeReconcilerFields are the fields the composite reconciler writes that way, at the v2 and v1 +// paths: compositionRef and compositionRevisionRef. +var compositeReconcilerFields = [][]string{ + {"spec", "crossplane", "compositionRef"}, + {"spec", "crossplane", "compositionRevisionRef"}, + {"spec", "compositionRef"}, + {"spec", "compositionRevisionRef"}, +} + +// Testing data for integration tests + +// createTestCompositionWithExtraResources creates a test Composition with a function-extra-resources step. +func createTestCompositionWithExtraResources() (*xpextv1.Composition, error) { + pipelineMode := xpextv1.CompositionModePipeline + + // Create the extra resources function input + extraResourcesInput := map[string]any{ + "apiVersion": "function.crossplane.io/v1beta1", + "kind": "ExtraResources", + "spec": map[string]any{ + "extraResources": []any{ + map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ExtraResource", + "selector": map[string]any{ + "matchLabels": map[string]any{ + "app": "test-app", + }, + }, + }, + }, + }, + } + + extraResourcesRaw, err := json.Marshal(extraResourcesInput) + if err != nil { + return nil, err + } + + // Create template function input to create composed resources + templateInput := map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1", + "kind": "Composition", + "spec": map[string]any{ + "resources": []any{ + map[string]any{ + "name": "composed-resource", + "base": map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "test-composed-resource", + "labels": map[string]any{ + "app": "crossplane", + }, + }, + "spec": map[string]any{ + "coolParam": "{{ .observed.composite.spec.coolParam }}", + "replicas": "{{ .observed.composite.spec.replicas }}", + "extraData": "{{ index .observed.resources \"extra-resource-0\" \"spec\" \"data\" }}", + }, + }, + }, + }, + }, + } + + templateRaw, err := json.Marshal(templateInput) + if err != nil { + return nil, err + } + + return &xpextv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-composition", + }, + Spec: xpextv1.CompositionSpec{ + CompositeTypeRef: xpextv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XExampleResource", + }, + Mode: pipelineMode, + Pipeline: []xpextv1.PipelineStep{ + { + Step: "extra-resources", + FunctionRef: xpextv1.FunctionReference{Name: "function-extra-resources"}, + Input: &runtime.RawExtension{Raw: extraResourcesRaw}, + }, + { + Step: "templating", + FunctionRef: xpextv1.FunctionReference{Name: "function-patch-and-transform"}, + Input: &runtime.RawExtension{Raw: templateRaw}, + }, + }, + }, + }, nil +} + +// createTestXRD creates a test XRD for the XR. +func createTestXRD() *xpextv1.CompositeResourceDefinition { + return tu.NewXRD("xexampleresources.example.org", "example.org", "XExampleResource"). + WithPlural("xexampleresources"). + WithSingular("xexampleresource"). + WithRawSchema([]byte(`{ + "type": "object", + "properties": { + "spec": { + "type": "object", + "properties": { + "coolParam": { + "type": "string" + }, + "replicas": { + "type": "integer" + } + } + }, + "status": { + "type": "object", + "properties": { + "coolStatus": { + "type": "string" + } + } + } + } + }`)). + Build() +} + +// createExtraResource creates a test extra resource. +func createExtraResource() *un.Unstructured { + return &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ExtraResource", + "metadata": map[string]any{ + "name": "test-extra-resource", + "labels": map[string]any{ + "app": "test-app", + }, + }, + "spec": map[string]any{ + "data": "extra-resource-data", + }, + }, + } +} + +// createExistingComposedResource creates an existing composed resource with different values. +func createExistingComposedResource() *un.Unstructured { + return &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "test-xr-composed-resource", + "labels": map[string]any{ + "app": "crossplane", + "crossplane.io/composite": "test-xr", + }, + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "composed-resource", + }, + "ownerReferences": []any{ + map[string]any{ + "apiVersion": "example.org/v1", + "kind": "XExampleResource", + "name": "test-xr", + "controller": true, + "blockOwnerDeletion": true, + }, + }, + }, + "spec": map[string]any{ + "coolParam": "old-value", // Different from what will be rendered + "replicas": 2, // Different from what will be rendered + "extraData": "old-data", // Different from what will be rendered + }, + }, + } +} + +// createMatchingComposedResource creates a composed resource that matches what would be rendered. +func createMatchingComposedResource() *un.Unstructured { + return &un.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "test-xr-composed-resource", + "labels": map[string]any{ + "app": "crossplane", + "crossplane.io/composite": "test-xr", + }, + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "composed-resource", + }, + "ownerReferences": []any{ + map[string]any{ + "apiVersion": "example.org/v1", + "kind": "XExampleResource", + "name": "test-xr", + "controller": true, + "blockOwnerDeletion": true, + }, + }, + }, + "spec": map[string]any{ + "coolParam": "test-value", // Matches what would be rendered + "replicas": 3, // Matches what would be rendered + "extraData": "extra-resource-data", // Matches what would be rendered + }, + }, + } +} + +// HierarchicalOwnershipRelation represents an ownership tree structure. +type HierarchicalOwnershipRelation struct { + OwnerFile string // The file containing the owner resource + OwnedFiles map[string]*HierarchicalOwnershipRelation // Map of owned file paths to their own relationships +} + +// composes is the one-level HierarchicalOwnershipRelation: an XR and the composed resources it +// controls. Spell out the struct only for a tree deeper than that, i.e. one with nested XRs. +func composes(xrFile string, composedFiles ...string) HierarchicalOwnershipRelation { + owned := make(map[string]*HierarchicalOwnershipRelation, len(composedFiles)) + for _, f := range composedFiles { + owned[f] = nil + } + + return HierarchicalOwnershipRelation{OwnerFile: xrFile, OwnedFiles: owned} +} + +// setOwnerReference adds an owner reference to the resource. +func setOwnerReference(resource, owner *un.Unstructured) { + // Create owner reference + ownerRef := metav1.OwnerReference{ + APIVersion: owner.GetAPIVersion(), + Kind: owner.GetKind(), + Name: owner.GetName(), + UID: owner.GetUID(), + Controller: new(true), + BlockOwnerDeletion: new(true), + } + + // Set the owner reference + resource.SetOwnerReferences([]metav1.OwnerReference{ownerRef}) +} + +// addResourceRef adds a reference to the child resource in the parent's resourceRefs array. +func addResourceRef(parent, child *un.Unstructured, xrAPIVersion XrdAPIVersion) error { + // Create the resource reference + ref := map[string]any{ + "apiVersion": child.GetAPIVersion(), + "kind": child.GetKind(), + "name": child.GetName(), + } + + // A namespaced XR composes only in its own namespace, so its resourceRefs have no namespace field + // (xcrd omits it from the schema); a cluster-scoped XR's name the namespace of a namespaced child. + if ns := child.GetNamespace(); ns != "" && parent.GetNamespace() == "" { + ref["namespace"] = ns + } + + path := resourceRefsPath(xrAPIVersion) + + // Get current resourceRefs or initialize if not present + resourceRefs, found, err := un.NestedSlice(parent.Object, path...) + if err != nil { + return errors.Wrap(err, "cannot get resourceRefs from parent") + } + + if !found || resourceRefs == nil { + resourceRefs = []any{} + } + + // A fixture may already list the reference, as Crossplane would have written it; don't repeat it. + for _, existing := range resourceRefs { + if m, ok := existing.(map[string]any); ok && + m["apiVersion"] == ref["apiVersion"] && m["kind"] == ref["kind"] && + m["name"] == ref["name"] && m["namespace"] == ref["namespace"] { + return nil + } + } + + // Add the new reference and update the parent + resourceRefs = append(resourceRefs, ref) + + return un.SetNestedSlice(parent.Object, resourceRefs, path...) +} + +// applyResourcesFromFiles creates the resources in YAML files in order, each filled in by d just before +// it is created, so it can derive from what precedes it. No resource should already exist. +func applyResourcesFromFiles(ctx context.Context, c client.Client, d *crossplaneDefaults, paths []string) error { + for _, path := range paths { + resources, err := readResourcesFromFile(path) + if err != nil { + return fmt.Errorf("failed to read resources from %s: %w", path, err) + } + + for _, r := range resources { + if err := d.fill(ctx, r, nil); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + + if err := createResources(ctx, c, []*un.Unstructured{r}); err != nil { + return err + } + } + } + + return nil +} + +// deleteResourcesFromFiles issues a delete for every resource in the supplied files. Resources +// carrying a finalizer survive the call with metadata.deletionTimestamp set, which is how tests +// observe a resource mid-deletion under envtest (no controllers run to clear the finalizer). +func deleteResourcesFromFiles(ctx context.Context, c client.Client, paths []string) error { + for _, path := range paths { + resources, err := readResourcesFromFile(path) + if err != nil { + return fmt.Errorf("failed to read resources from %s: %w", path, err) + } + + for _, resource := range resources { + if err := c.Delete(ctx, resource.DeepCopy()); err != nil { + return fmt.Errorf("failed to delete resource %s/%s: %w", + resource.GetNamespace(), resource.GetName(), err) + } + } + } + + return nil +} + +// awaitAdmissionDenial blocks until every resource in the supplied manifest is refused by a +// ValidatingAdmissionPolicy on a dry-run create. An empty path is a no-op. +// +// A VAP does not take effect the instant it is created: the apiserver's admission plugin learns +// about policies and bindings through an informer, so for a short window after setup the cluster +// still accepts what the policy will later refuse. Any test that depends on a rejection must wait +// for that window to close, or it silently becomes a test of nothing. +// +// The gate is deliberately narrow: only an error naming ValidatingAdmissionPolicy counts as +// "enforcing". Accepting any rejection would let an unrelated failure (a schema error in the canary, +// a missing namespace) masquerade as a working policy. +func awaitAdmissionDenial(ctx context.Context, c client.Client, path string) error { + if path == "" { + return nil + } + + resources, err := readResourcesFromFile(path) + if err != nil { + return fmt.Errorf("failed to read resources from %s: %w", path, err) + } + + // Bounded independently of the caller's context so a slow policy rollout fails here with a + // clear message instead of consuming the whole test's budget and timing out inside the diff. + waitCtx, cancel := context.WithTimeout(ctx, admissionPolicyPropagationTimeout) + defer cancel() + + for _, r := range resources { + if err := awaitOneAdmissionDenial(waitCtx, c, r); err != nil { + return err + } + } + + return nil +} + +// admissionPolicyPropagationTimeout bounds the wait for a ValidatingAdmissionPolicy to start +// enforcing. Measured at ~600ms on envtest 1.32; the margin is for loaded CI machines. +const admissionPolicyPropagationTimeout = 30 * time.Second + +func awaitOneAdmissionDenial(ctx context.Context, c client.Client, canary *un.Unstructured) error { + for attempts := 1; ; attempts++ { + err := c.Create(ctx, canary.DeepCopy(), client.DryRunAll) + + switch { + case err != nil && strings.Contains(err.Error(), "ValidatingAdmissionPolicy"): + return nil + case err != nil: + return fmt.Errorf("dry-run create of canary %s/%s failed for a reason unrelated to admission policy: %w", + canary.GetKind(), canary.GetName(), err) + } + + if ctx.Err() != nil { + return fmt.Errorf("canary %s/%s was still accepted after %d attempts; no ValidatingAdmissionPolicy is enforcing: %w", + canary.GetKind(), canary.GetName(), attempts, ctx.Err()) + } + + time.Sleep(100 * time.Millisecond) + } +} + +// readResourcesFromFile reads YAML resources from a file. +func readResourcesFromFile(path string) ([]*un.Unstructured, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read file %s: %w", path, err) + } + + // Use a YAML decoder to handle multiple documents + decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewReader(data), 4096) + + var resources []*un.Unstructured + + for { + resource := &un.Unstructured{} + + err := decoder.Decode(resource) + if err != nil { + if errors.Is(err, io.EOF) { + break + } + + return nil, fmt.Errorf("failed to decode YAML document from %s: %w", path, err) + } + + // Skip empty documents + if len(resource.Object) == 0 { + continue + } + + resources = append(resources, resource) + } + + return resources, nil +} + +// xrdCRDName is the CRD that defines CompositeResourceDefinitions. It must be among the plain CRDs. +const xrdCRDName = "compositeresourcedefinitions.apiextensions.crossplane.io" + +// envtestCRDs returns every CRD an integration test's apiserver must serve: the plain CRDs under crdDirs, +// plus the CRDs Crossplane generates from each XRD that setupFiles declare. In a real cluster an XR's or a +// claim's CRD exists only because its XRD does, and Crossplane generates it; deriving them here the same +// way keeps test CRDs from drifting away from what a cluster would serve. +// +// A CRD declared twice is an error rather than a silent overwrite (envtest would install the later copy over +// the earlier one). Most likely it is a hand-written copy of a CRD that is now generated, which must go. +func envtestCRDs(crdDirs, setupFiles []string) ([]*extv1.CustomResourceDefinition, error) { + opts := envtest.CRDInstallOptions{Paths: crdDirs, ErrorIfPathMissing: true} + if err := envtest.ReadCRDFiles(&opts); err != nil { + return nil, fmt.Errorf("cannot read CRDs from %v: %w", crdDirs, err) + } + + var xrdCRD *extv1.CustomResourceDefinition + + // Keyed by both CRD name and kind.group: two CRDs of different names that serve the same kind + // collide just as surely (the apiserver never establishes the second). + sources := make(map[string]string, 2*len(opts.CRDs)) + for _, crd := range opts.CRDs { + source := fmt.Sprintf("a CRD file under %v", crdDirs) + sources[crd.GetName()] = source + sources[crd.Spec.Names.Kind+"."+crd.Spec.Group] = source + + if crd.GetName() == xrdCRDName { + xrdCRD = crd + } + } + + if xrdCRD == nil { + return nil, fmt.Errorf("no CRD %q under %v", xrdCRDName, crdDirs) + } + + crds := opts.CRDs + + for _, path := range setupFiles { + resources, err := readResourcesFromFile(path) + if err != nil { + return nil, err + } + + for _, r := range resources { + if r.GroupVersionKind().GroupKind() != xpextv1.CompositeResourceDefinitionGroupVersionKind.GroupKind() { + continue + } + + generated, err := tu.CRDsForXRD(r, xrdCRD) + if err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + for _, crd := range generated { + source := fmt.Sprintf("XRD %q in %s", r.GetName(), path) + + for _, key := range []string{crd.GetName(), crd.Spec.Names.Kind + "." + crd.Spec.Group} { + if existing, ok := sources[key]; ok { + return nil, fmt.Errorf("CRD %q (%s) is generated from %s but %s also declares it; "+ + "a generated CRD must not also be hand-written or generated twice", + crd.GetName(), key, source, existing) + } + + sources[key] = source + } + + crds = append(crds, crd) + } + } + } + + return crds, nil +} + +// createResources creates all resources in the cluster +// Assumes resources don't already exist - fails if they do. +func createResources(ctx context.Context, c client.Client, resources []*un.Unstructured) error { + for _, resource := range resources { + err := c.Create(ctx, resource.DeepCopy()) + if err != nil { + if apierrors.IsAlreadyExists(err) { + return fmt.Errorf("resource %s/%s of kind %s already exists - test setup error", + resource.GetNamespace(), resource.GetName(), resource.GetKind()) + } + + return fmt.Errorf("failed to create resource %s/%s: %w", + resource.GetNamespace(), resource.GetName(), err) + } + } + + return nil +} + +// applyResourceWithSSA applies a resource using Server-Side Apply with the specified field manager. +// This simulates how Crossplane applies composed resources, allowing us to test field ownership behavior. +func applyResourceWithSSA(ctx context.Context, c client.Client, resource *un.Unstructured, fieldManager string) error { + // Use Apply to perform Server-Side Apply + resourceCopy := resource.DeepCopy() + + // For SSA, we need to set the resource version to empty to indicate this is an apply + resourceCopy.SetResourceVersion("") + + applyConfig := client.ApplyConfigurationFromUnstructured(resourceCopy) + + err := c.Apply(ctx, applyConfig, client.FieldOwner(fieldManager), client.ForceOwnership) + if err != nil { + return fmt.Errorf("failed to apply resource %s/%s with field manager %s: %w", + resource.GetNamespace(), resource.GetName(), fieldManager, err) + } + + return nil +} + +// applyHierarchicalOwnership applies a hierarchical ownership structure. +func applyHierarchicalOwnership(ctx context.Context, _ logging.Logger, c client.Client, d *crossplaneDefaults, xrdAPIVersion XrdAPIVersion, hierarchies []HierarchicalOwnershipRelation) error { + // Map to store created resources by file path + createdResources := make(map[string]*un.Unstructured) + // Map to track parent-child relationships for establishing resourceRefs + parentChildRelationships := make(map[string]string) // child file -> parent file + + // First pass: Create all resources and collect parent-child relationships + err := createAllResourcesInHierarchy(ctx, c, d, hierarchies, createdResources, parentChildRelationships) + if err != nil { + return err + } + + // Second pass: Apply all owner references and resource refs between parents and children + err = applyAllRelationships(ctx, c, createdResources, parentChildRelationships, xrdAPIVersion) + if err != nil { + return err + } + + // Debug logging disabled by default. Uncomment to debug hierarchical setup issues. + // if err := LogResourcesAsYAML(ctx, log, c, createdResources); err != nil { + // log.Info(fmt.Sprintf("Warning: Failed to log resources as YAML: %v\n", err)) + // } + + return nil +} + +// Unused but useful for debugging; leave it here. +// LogResourcesAsYAML fetches the latest version of each resource and logs it as YAML. +func LogResourcesAsYAML(ctx context.Context, log logging.Logger, c client.Client, createdResources map[string]*un.Unstructured) error { + log.Info("\n===== FINAL STATE OF CREATED RESOURCES =====\n\n") + + // Sort the file paths for consistent output order + filePaths := make([]string, 0, len(createdResources)) + for filePath := range createdResources { + filePaths = append(filePaths, filePath) + } + + sort.Strings(filePaths) + + for _, filePath := range filePaths { + resource := createdResources[filePath] + + // Fetch the latest version of the resource + latest := &un.Unstructured{} + latest.SetGroupVersionKind(resource.GroupVersionKind()) + + if err := c.Get(ctx, client.ObjectKey{ + Name: resource.GetName(), + Namespace: resource.GetNamespace(), + }, latest); err != nil { + return fmt.Errorf("failed to get latest version of resource %s/%s: %w", + resource.GetNamespace(), resource.GetName(), err) + } + + // Convert to YAML + yamlData, err := gyaml.Marshal(latest.Object) + if err != nil { + return fmt.Errorf("failed to marshal resource to YAML: %w", err) + } + + // Print the resource file path and its YAML representation + log.Info(fmt.Sprintf("--- Source: %s\nResourceName: %s/%s\n%s\n\n", + filePath, latest.GetKind(), latest.GetName(), string(yamlData))) + } + + log.Info("===== END OF RESOURCES =====\n\n") + + return nil +} + +// createAllResourcesInHierarchy creates all resources in a hierarchy and tracks relationships. +func createAllResourcesInHierarchy(ctx context.Context, c client.Client, d *crossplaneDefaults, + hierarchies []HierarchicalOwnershipRelation, + createdResources map[string]*un.Unstructured, + parentChildRelationships map[string]string, +) error { + for _, hierarchy := range hierarchies { + // Create the owner resource first. Only a root is still uncreated here, since a nested owner was + // applied as its parent's composed resource before the recursion reached it. A root XR is + // created the way a user's manifest would be, never under Crossplane's composed field manager: + // its spec belongs to whoever applied it. + owner, err := createResourceFromFile(ctx, c, d, hierarchy.OwnerFile, nil, createdResources, "") + if err != nil { + return err + } + + // Create all owned resources without setting references yet + for ownedFile, childHierarchy := range hierarchy.OwnedFiles { + // Track the parent-child relationship + parentChildRelationships[ownedFile] = hierarchy.OwnerFile + + // Create the owned resource without setting references + _, err := createResourceFromFile(ctx, c, d, ownedFile, owner, createdResources, CrossplaneFieldManager) + if err != nil { + return err + } + + // Process nested hierarchies recursively + if childHierarchy != nil && len(childHierarchy.OwnedFiles) > 0 { + childHierarchies := []HierarchicalOwnershipRelation{ + { + OwnerFile: ownedFile, + OwnedFiles: childHierarchy.OwnedFiles, + }, + } + + err := createAllResourcesInHierarchy(ctx, c, d, childHierarchies, + createdResources, parentChildRelationships) + if err != nil { + return err + } + } + } + } + + return nil +} + +// createResourceFromFile creates the one resource a file holds, filled in by d as parent's composed +// resource (parent is nil for a root). With a fieldManager it is applied with Server-Side Apply (SSA) +// under that manager: pass CrossplaneFieldManager for a composed resource, as Crossplane applies composed +// resources in production, so tests see SSA-related behaviors like field removal. With none it is +// created plainly, as a setup file is. +func createResourceFromFile(ctx context.Context, c client.Client, d *crossplaneDefaults, path string, + parent *un.Unstructured, createdResources map[string]*un.Unstructured, fieldManager string, +) (*un.Unstructured, error) { + // Check if we've already processed this resource + if resource, exists := createdResources[path]; exists { + return resource, nil + } + + // Read the resource from file + resources, err := readResourcesFromFile(path) + if err != nil { + return nil, err + } + + // The hierarchy is keyed by file, so a second document would silently go unowned. + if len(resources) != 1 { + return nil, fmt.Errorf("file %s holds %d resources; an ownership hierarchy needs exactly one per file", path, len(resources)) + } + + resource := resources[0] + + if err := d.fill(ctx, resource, parent); err != nil { + return nil, fmt.Errorf("%s: %w", path, err) + } + + // A nested XR's composition refs are the nested XR's own reconciler's, never its parent's: the + // parent's composed apply doesn't carry them, so it must not own them, or a diff of the parent's + // render would report them removed. Apply them separately, as that reconciler writes them. + applied := resource + + var reconcilerFields map[int]any + if fieldManager == CrossplaneFieldManager { + applied, reconcilerFields = withoutCompositeReconcilerFields(resource) + } + + if fieldManager != "" { + if err := applyResourceWithSSA(ctx, c, applied, fieldManager); err != nil { + return nil, fmt.Errorf("failed to apply resource with SSA: %w", err) + } + } else if err := createResources(ctx, c, resources); err != nil { + return nil, err + } + + // Get the resource back from the server + serverResource := &un.Unstructured{} + serverResource.SetGroupVersionKind(resource.GroupVersionKind()) + + if err := c.Get(ctx, client.ObjectKey{ + Name: resource.GetName(), + Namespace: resource.GetNamespace(), + }, serverResource); err != nil { + return nil, fmt.Errorf("failed to get created resource: %w", err) + } + + if len(reconcilerFields) > 0 { + for i, v := range reconcilerFields { + if err := un.SetNestedField(serverResource.Object, v, compositeReconcilerFields[i]...); err != nil { + return nil, fmt.Errorf("cannot set %v on %s: %w", compositeReconcilerFields[i], path, err) + } + } + + if err := c.Update(ctx, serverResource, client.FieldOwner(CompositeReconcilerFieldManager)); err != nil { + return nil, fmt.Errorf("failed to write composite reconciler fields of %s: %w", path, err) + } + } + + // Store and return the created resource + createdResources[path] = serverResource + + return serverResource, nil +} + +// withoutCompositeReconcilerFields returns a copy of resource without the compositeReconcilerFields it +// sets, and those fields' values keyed by their index in compositeReconcilerFields. resource is not +// mutated. +func withoutCompositeReconcilerFields(resource *un.Unstructured) (*un.Unstructured, map[int]any) { + out := resource.DeepCopy() + removed := map[int]any{} + + for i, path := range compositeReconcilerFields { + if v, found, _ := un.NestedFieldCopy(out.Object, path...); found { + removed[i] = v + un.RemoveNestedField(out.Object, path...) + } + } + + return out, removed +} + +// applyAllRelationships applies all owner references and resource refs. +func applyAllRelationships(ctx context.Context, c client.Client, + createdResources map[string]*un.Unstructured, + parentChildRelationships map[string]string, + xrdAPIVersion XrdAPIVersion, +) error { + // Process all parent-child relationships + for childFile, parentFile := range parentChildRelationships { + childResource := createdResources[childFile] + parentResource := createdResources[parentFile] + + if childResource == nil || parentResource == nil { + return fmt.Errorf("missing resource in relationship: parent=%s, child=%s", + parentFile, childFile) + } + + // 1. Set the owner reference in the child's metadata + err := setOwnerReferenceAndUpdate(ctx, c, parentResource, childResource) + if err != nil { + return err + } + + // 2. Add the child resource reference to the parent + err = addResourceRefAndUpdate(ctx, c, parentResource, childResource, xrdAPIVersion) + if err != nil { + return err + } + } + + return nil +} + +// setOwnerReferenceAndUpdate sets the owner reference in the child and updates it. +// Uses a separate field manager for owner references to avoid conflicts with SSA-created resources. +func setOwnerReferenceAndUpdate(ctx context.Context, c client.Client, + owner *un.Unstructured, child *un.Unstructured, +) error { + // Get the latest version of the child + latestChild := &un.Unstructured{} + latestChild.SetGroupVersionKind(child.GroupVersionKind()) + + err := c.Get(ctx, client.ObjectKey{ + Name: child.GetName(), + Namespace: child.GetNamespace(), + }, latestChild) + if err != nil { + return fmt.Errorf("failed to get child resource: %w", err) + } + + // Set the owner reference + setOwnerReference(latestChild, owner) + + // Use a separate field manager for owner references to avoid conflicts with the + // Crossplane field manager that created the resource via SSA. + err = c.Update(ctx, latestChild, client.FieldOwner("diff.test")) + if err != nil { + return fmt.Errorf("failed to update child with owner reference: %w", err) + } + + return nil +} + +// addResourceRefAndUpdate adds a resource reference to the owner and updates it. +func addResourceRefAndUpdate(ctx context.Context, c client.Client, + owner *un.Unstructured, owned *un.Unstructured, xrdAPIVersion XrdAPIVersion, +) error { + // Get the latest version of the owner + latestOwner := &un.Unstructured{} + latestOwner.SetGroupVersionKind(owner.GroupVersionKind()) + + err := c.Get(ctx, client.ObjectKey{ + Name: owner.GetName(), + Namespace: owner.GetNamespace(), + }, latestOwner) + if err != nil { + return fmt.Errorf("failed to get owner for updating references: %w", err) + } + + // Add the resource reference + err = addResourceRef(latestOwner, owned, xrdAPIVersion) + if err != nil { + return fmt.Errorf("unable to add resource ref: %w", err) + } + + // Use a separate field manager for resource refs to avoid conflicts with the + // Crossplane field manager that created the resource via SSA. + want, _, _ := un.NestedSlice(latestOwner.Object, resourceRefsPath(xrdAPIVersion)...) + + err = c.Update(ctx, latestOwner, client.FieldOwner("diff.test")) + if err != nil { + return fmt.Errorf("failed to update owner with resource reference: %w", err) + } + + // The apiserver prunes a field its schema lacks, so resourceRefs written at the wrong API version's + // path would vanish silently and leave the XR composing nothing. + if got, _, _ := un.NestedSlice(latestOwner.Object, resourceRefsPath(xrdAPIVersion)...); len(got) != len(want) { + return fmt.Errorf("%s %s kept %d of %d resourceRefs at %v: is the test case's xrdAPIVersion that of the XRD?", + owner.GetKind(), owner.GetName(), len(got), len(want), resourceRefsPath(xrdAPIVersion)) + } + + return nil +} + +// resourceRefsPath is where an XR of the given XRD API version lists its composed resources. +func resourceRefsPath(xrdAPIVersion XrdAPIVersion) []string { + if xrdAPIVersion == V1 { + return []string{"spec", "resourceRefs"} + } + + return []string{"spec", "crossplane", "resourceRefs"} +} + +// localCrossplaneBinary returns the absolute path to a locally-built +// `crossplane` binary at _output/bin/crossplane (relative to cmd/diff/) when +// it exists, or "" when it doesn't. The binary, when present, contains the +// `crossplane internal render` subcommand introduced by upstream PR #7339 +// and is exec'd directly for fast in-process rendering. When absent, callers +// pass the empty string through to the diff command, which falls through to +// the docker render engine (slower but works wherever Docker does — including +// CI's WITH DOCKER block in the go-test target). +// +// Local developers wanting the fast path can build the binary with: +// +// go build -o _output/bin/crossplane github.com/crossplane/crossplane/v2/cmd/crossplane +// +// (run from the repo root). The path is overridable via the +// CROSSPLANE_DIFF_RENDER_BINARY env var when a different layout is needed. +// +// Callers thread the returned path into the diff command via +// --crossplane-render-binary= rather than via a process-global env var, +// so concurrent t.Parallel() tests don't race on shared state. +func localCrossplaneBinary(t *testing.T) string { + t.Helper() + + if override := os.Getenv("CROSSPLANE_DIFF_RENDER_BINARY"); override != "" { + return override + } + + absPath, err := filepath.Abs("../../_output/bin/crossplane") + if err != nil { + t.Fatalf("cannot resolve crossplane binary path: %v", err) + } + + if _, err := os.Stat(absPath); err != nil { + t.Logf("local crossplane binary not present at %s; falling back to docker render engine. Build it for faster iteration: go build -o _output/bin/crossplane github.com/crossplane/crossplane/v2/cmd/crossplane", absPath) + return "" + } + + return absPath +} + +// reconcileCompositionRevisions does what Crossplane's composition revision controller would, since +// envtest runs none: it gives each revision a controller reference to its Composition, and creates the +// revision of a Composition's current hash if no fixture declares it. +func reconcileCompositionRevisions(ctx context.Context, c client.Client) error { + comps := &xpextv1.CompositionList{} + revs := &xpextv1.CompositionRevisionList{} + + if err := c.List(ctx, comps); err != nil { + return err + } + + if err := c.List(ctx, revs); err != nil { + return err + } + + for i := range comps.Items { + comp := &comps.Items[i] + _, current := revisionIdentity(comp.GetName(), comp.Hash()) + owner := meta.AsController(meta.TypedReferenceTo(comp, xpextv1.CompositionGroupVersionKind)) + latest, found := int64(0), false + + for j := range revs.Items { + rev := &revs.Items[j] + if rev.GetLabels()[xpextv1.LabelCompositionName] != comp.GetName() { + continue + } + + latest = max(latest, rev.Spec.Revision) + found = found || rev.GetLabels()[xpextv1.LabelCompositionHash] == current + + meta.AddOwnerReference(rev, owner) + + if err := c.Update(ctx, rev); err != nil { + return err + } + } + + if !found { + if err := c.Create(ctx, newCompositionRevision(comp, latest+1, owner)); err != nil { + return err + } + } + } + + return nil +} + +// newCompositionRevision is crossplane's composition.NewCompositionRevision, which is internal. +func newCompositionRevision(comp *xpextv1.Composition, revision int64, owner metav1.OwnerReference) *xpextv1.CompositionRevision { + name, hash := revisionIdentity(comp.GetName(), comp.Hash()) + + rev := &xpextv1.CompositionRevision{Spec: (&xpextv1.GeneratedRevisionSpecConverter{}).ToRevisionSpec(comp.Spec)} + rev.Spec.Revision = revision + rev.SetName(name) + rev.SetLabels(maps.Clone(comp.GetLabels())) + meta.AddLabels(rev, map[string]string{xpextv1.LabelCompositionName: comp.GetName(), xpextv1.LabelCompositionHash: hash}) + rev.SetAnnotations(maps.Clone(comp.GetAnnotations())) + meta.AddOwnerReference(rev, owner) + + return rev +} + +// revisionIdentity is the name and hash label NewCompositionRevision gives the revision of a Composition +// with the given name and Composition.Hash(). +func revisionIdentity(compName, hash string) (string, string) { + hash = hash[:min(63, len(hash))] + return compName + "-" + hash[:min(7, len(hash))], hash +} + +// The labels Crossplane gives an XR and everything composed beneath it. +const ( + labelComposite = "crossplane.io/composite" + labelClaimName = "crossplane.io/claim-name" + labelClaimNamespace = "crossplane.io/claim-namespace" +) + +// crossplaneDefaults fills in, on each fixture a case sets up, what Crossplane would have written to it +// wherever the fixture leaves it out, just as envtestCRDs derives XR and claim CRDs from their XRDs. A +// value the fixture sets always wins: that is how a case sets up a state Crossplane wouldn't leave, such as +// a stale or pinned revision ref, or another XR's labels. +type crossplaneDefaults struct { + c client.Client + xrKinds map[schema.GroupKind]bool // the XR kinds the case's XRDs define +} + +func newCrossplaneDefaults(c client.Client, crds []*extv1.CustomResourceDefinition) *crossplaneDefaults { + d := &crossplaneDefaults{c: c, xrKinds: map[schema.GroupKind]bool{}} + + for _, crd := range crds { + if slices.Contains(crd.Spec.Names.Categories, "composite") { + d.xrKinds[schema.GroupKind{Group: crd.Spec.Group, Kind: crd.Spec.Names.Kind}] = true + } + } + + return d +} + +// fill fills in what Crossplane would have written to o, where o doesn't set it: +// - a CompositionRevision's name and hash label, which NewCompositionRevision derives from the +// Composition it was cut from: the one its crossplane.io/composition-name label names, with the +// revision's own spec, labels and annotations; +// - an XR's crossplane.io/composite label, its own name, and for a claim's backing XR the claim labels +// the claim syncer sets from the claim its claimRef names; or, on a resource an XR composes (a nested +// XR included), the XR's composite and claim labels, as RenderComposedResourceMetadata copies them; +// - the compositionRevisionRef of an XR that names a Composition but neither a revision nor a +// revision selector: the Composition's current revision, which reconcileCompositionRevisions creates. +func (d *crossplaneDefaults) fill(ctx context.Context, o, parent *un.Unstructured) error { + gk := o.GroupVersionKind().GroupKind() + if gk == xpextv1.CompositionRevisionGroupVersionKind.GroupKind() && o.GetName() == "" { + return d.nameRevision(ctx, o) + } + + var composite, claimName, claimNamespace string + + switch { + case parent != nil: + composite = parent.GetLabels()[labelComposite] + claimName, claimNamespace = parent.GetLabels()[labelClaimName], parent.GetLabels()[labelClaimNamespace] + case d.xrKinds[gk]: + composite = o.GetName() + // The claim syncer labels a claim's backing XR with the claim, which its claimRef names. + claimName, _, _ = un.NestedString(o.Object, "spec", "claimRef", "name") + claimNamespace, _, _ = un.NestedString(o.Object, "spec", "claimRef", "namespace") + } + + labels := map[string]string{} + if composite != "" { + labels[labelComposite] = composite + } + + // Like RenderComposedResourceMetadata, set the claim labels only as a pair. + if claimName != "" && claimNamespace != "" { + labels[labelClaimName], labels[labelClaimNamespace] = claimName, claimNamespace + } + + maps.Copy(labels, o.GetLabels()) + + if len(labels) > 0 { + o.SetLabels(labels) + } + + // The refs are at spec.crossplane in a v2 XR, and at spec in a v1 one. + for _, base := range [][]string{{"spec", "crossplane"}, {"spec"}} { + field := func(name ...string) []string { return slices.Concat(base, name) } + + comp, named, _ := un.NestedString(o.Object, field("compositionRef", "name")...) + _, hasRef, _ := un.NestedFieldNoCopy(o.Object, field("compositionRevisionRef")...) + _, hasSelector, _ := un.NestedFieldNoCopy(o.Object, field("compositionRevisionSelector")...) + + if !d.xrKinds[gk] || !named || hasRef || hasSelector { + continue + } + + current := &xpextv1.Composition{} + if err := d.c.Get(ctx, client.ObjectKey{Name: comp}, current); err != nil { + return fmt.Errorf("cannot derive the compositionRevisionRef of %s %s: %w", o.GetKind(), o.GetName(), err) + } + + name, _ := revisionIdentity(comp, current.Hash()) + + return un.SetNestedField(o.Object, name, field("compositionRevisionRef", "name")...) + } + + return nil +} + +// nameRevision names and hash-labels a CompositionRevision as NewCompositionRevision would have, from the +// Composition it was cut from: its spec, as the apiserver defaults it, and its own labels and annotations. +func (d *crossplaneDefaults) nameRevision(ctx context.Context, o *un.Unstructured) error { + compName := o.GetLabels()[xpextv1.LabelCompositionName] + if compName == "" { + return errors.Errorf("a CompositionRevision without a name needs a %s label to be named from", xpextv1.LabelCompositionName) + } + + probe := o.DeepCopy() + probe.SetGenerateName("revision-") + + if err := d.c.Create(ctx, probe, client.DryRunAll); err != nil { + return err + } + + rev := &xpextv1.CompositionRevision{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(probe.Object, rev); err != nil { + return err + } + + src := &xpextv1.Composition{Spec: (&xpextv1.GeneratedRevisionSpecConverter{}).FromRevisionSpec(rev.Spec)} + labels := maps.Clone(rev.GetLabels()) + delete(labels, xpextv1.LabelCompositionName) + delete(labels, xpextv1.LabelCompositionHash) + + if len(labels) > 0 { + src.SetLabels(labels) + } + + src.SetAnnotations(rev.GetAnnotations()) + + name, hash := revisionIdentity(compName, src.Hash()) + o.SetName(name) + meta.AddLabels(o, map[string]string{xpextv1.LabelCompositionHash: hash}) + + return nil +} diff --git a/internal/diff/integration/main_test.go b/internal/diff/integration/main_test.go new file mode 100644 index 00000000..733c642d --- /dev/null +++ b/internal/diff/integration/main_test.go @@ -0,0 +1,21 @@ +package integration + +import ( + "fmt" + "os" + "testing" + + "github.com/crossplane/cli/v2/internal/diff/testutils/envtestreaper" +) + +// TestMain runs before all tests. +func TestMain(m *testing.M) { + // Kill envtest servers that an earlier run left behind when it died abnormally. + envtestreaper.Reap(func(format string, args ...any) { fmt.Fprintf(os.Stderr, format+"\n", args...) }) + + // Run all tests + exitCode := m.Run() + + // Exit with the test suite's exit code + os.Exit(exitCode) +} diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go new file mode 100644 index 00000000..315246bf --- /dev/null +++ b/internal/diff/integration/simulate_harness_test.go @@ -0,0 +1,398 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// This file is the simulate harness: the one function every integration +// case funnels through. It ports crossplane-diff's command layer +// (origin/main 6a140b6: cmd/diff/main.go's CommonCmdFields and +// engineLimitVars, cmd_utils.go's defaultProcessorOptions, +// comp.go/xr.go's processor wiring and Run bodies) onto this repository's +// ported engine, driving it with CLI-style arguments the way the real +// commands' kong wiring does. +// +// It exists because `crossplane project simulate` does not yet cover the +// whole surface these cases need. When it does, replace runSimulate's +// body with a call to the real command and no case table changes: the +// cases are CLI args plus an expected exit code and expected output, and +// runSimulate is the only thing standing between them and execution. +// +// # Gap and environment skips +// +// Cases the current simulate surface cannot express are ported in full +// and skipped, never dropped, so each names what it waits for: +// +// - gap: — the simulate command lacks the feature this case +// needs. Grep the skip reasons to find what to enable as features +// land. +// - gap:flag-parity-not-merged () — the flag exists on branch +// jco-simulate-flag-parity but is not merged into this branch yet. +// The harness already wires these flags, so the case runs as soon as +// that branch merges. +// - env: — the environment lacks something the case needs. +// The render engine runs functions as Docker containers, so without +// a Docker daemon no rendering case can run; cases that would not +// render cannot be told apart upfront, so they skip too. +package integration + +import ( + "context" + "io" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/alecthomas/kong" + "k8s.io/client-go/rest" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + ld "github.com/crossplane/cli/v2/cmd/crossplane/common/load" + "github.com/crossplane/cli/v2/internal/diff/client" + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + "github.com/crossplane/cli/v2/internal/diff/ref" + "github.com/crossplane/cli/v2/internal/diff/renderer" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/docker" +) + +// simArgs mirrors the crossplane-diff flags the integration cases pass: +// the CommonCmdFields subset the runner threads, plus each command's +// positional files. Defaults match crossplane-diff's, including the +// engineLimitVars defaults main.go feeds kong. +type simArgs struct { + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + Namespace string ` help:"Namespace of the composite resources."` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` + OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2)." name:"output-version"` + NoColor bool ` help:"Disable colorized output." name:"no-color"` + MaxNestedDepth int `default:"${max_nested_depth}" help:"Maximum depth for nested XR recursion." name:"max-nested-depth"` + MaxIterations int `default:"${max_iterations}" help:"Maximum render iterations." name:"max-iterations"` + IgnorePaths []string ` help:"Paths to ignore in diffs." name:"ignore-paths"` + FunctionCredentials string ` help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` + EventualState bool ` help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` + Resource []string `sep:"," help:"Composite resources to scope to."` + IncludeManual bool ` help:"Include composites with a manual update policy." name:"include-manual"` + AnalyzeUnchanged bool ` help:"Analyze compositions identical to their in-cluster version (deprecated; --analyze-on=always)." name:"analyze-unchanged"` + AnalyzeOn string ` help:"Smallest composition change that triggers per-composite impact analysis." name:"analyze-on"` + Files []string `arg:"" optional:"" help:"Input files."` +} + +// parseSimulateArgs parses one case's CLI-style args. A parse failure is +// fatal, as it was when kong parsed crossplane-diff's own commands. +func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simArgs { + t.Helper() + + var a simArgs + parser, err := kong.New(&a, engineLimitVars(), kong.Name(string(testType))) + if err != nil { + t.Fatalf("failed to create kong parser: %v", err) + } + + if _, err := parser.Parse(args); err != nil { + t.Fatalf("failed to parse args %v: %v", args, err) + } + + return &a +} + +// engineLimitVars ports crossplane-diff main.go's kong.Vars of the same +// name: the engine's defaults for --max-nested-depth and +// --max-iterations, so the flags' defaults follow the engine rather than +// a number duplicated here. +func engineLimitVars() kong.Option { + return kong.Vars{ + "max_nested_depth": strconv.Itoa(dp.DefaultMaxNestedDepth), + "max_iterations": strconv.Itoa(dp.DefaultMaxRenderIterations), + } +} + +// simulateGap returns the reason the parsed args cannot run against the +// current simulate surface, or "" when they can. The runner calls it +// before starting envtest, so a skipped case costs nothing, and +// runSimulate never executes args it could not express. +// +// Keep this in sync with cmd/crossplane/project/simulate.go's flags: a +// flag that lands there moves out of here, and its cases start running. +func simulateGap(testType DiffTestType, a *simArgs) string { + // The project simulate command previews compositions from a project; + // XR files as input (crossplane-diff's xr command, issue #367's + // `project simulate -f xr.yaml`) is not implemented yet. + if testType == XRDiffTest { + return "gap: simulate has no xr mode (XR files as input) yet" + } + + // Flags that exist on the flag-parity branch but are not merged into + // this branch yet. The harness already wires them; the cases run as + // soon as the merge lands. + flagParity := []struct { + set bool + flag string + }{ + {a.NoColor, "--no-color"}, + {len(a.IgnorePaths) > 0, "--ignore-paths"}, + {a.FunctionCredentials != "", "--function-credentials"}, + {a.EventualState, "--eventual-state"}, + {a.AnalyzeUnchanged, "--analyze-unchanged (simulate's --analyze-on=always)"}, + {a.AnalyzeOn != "", "--analyze-on"}, + {a.DryRunOn != "" && a.DryRunOn != "all", "--dry-run-on"}, + } + for _, f := range flagParity { + if f.set { + return "gap:flag-parity-not-merged (" + f.flag + ")" + } + } + + // simulate scopes to one composite resource with --name (with + // --namespace for a namespaced one); it cannot express the repeated + // or comma-separated --resource scoping these cases need. + if len(a.Resource) > 1 { + return "gap: simulate has no repeated --resource scoping (single --name only)" + } + + return "" +} + +// runSimulate runs one case's args against the diff engine wired the way +// crossplane-diff's comp and xr commands wire it, and returns the exit +// code the command would exit with, the error it would fail with, and +// everything it wrote. Fresh clients per call, the way a second CLI +// invocation would see cold caches; the determinism cases rely on it. +func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []string, stdout, stderr io.Writer) (int, error) { + t.Helper() + + a := parseSimulateArgs(t, testType, args) + + // Bind an advisory collector the way main() does, so advisories land + // on stderr AND in structured output. + logger := tu.TestLogger(t, true) + advisories := dp.NewAdvisoryCollector(stderr) + + exitCode := dp.ExitCodeSuccess + + run := func() (int, error) { + clients, err := client.New(cfg, logger) + if err != nil { + return dp.ExitCodeToolError, errors.Wrap(err, "cannot create clients") + } + + opts := processorOptions(a, stdout, stderr, logger, advisories) + xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) + + loader, err := ld.NewCompositeLoader(a.Files) + if err != nil { + return dp.ExitCodeToolError, errors.Wrap(err, "cannot create resource loader") + } + + ctx, cancel, err := initializeClients(context.Background(), a.Timeout, clients, logger) + if err != nil { + return dp.ExitCodeToolError, err + } + defer cancel() + + // An interrupted run reports the interruption, not the cancelled + // calls it caused. Registered after cancel, so it runs before it: + // the run's own cancel must not read as an interrupt. + err = func() (err error) { + defer func() { + if dp.Interrupted(ctx) { + exitCode, err = dp.ExitCodeInterrupted, dp.ErrInterrupted + } + }() + + // Covers paths that return before rendering; Cleanup is + // idempotent. + defer dp.CleanupDetached(ctx, procFor(testType, xrProc, clients, opts), logger) + + switch testType { + case XRDiffTest: + resources, err := loader.Load() + if err != nil { + return errors.Wrap(err, "cannot load resources") + } + + if err := xrProc.Initialize(ctx); err != nil { + return errors.Wrap(err, "cannot initialize diff processor") + } + + hasDiffs, err := xrProc.PerformDiff(ctx, resources, clients.XpClients.Composition.FindMatchingComposition) + exitCode = dp.DetermineExitCode(err, hasDiffs) + if err != nil { + return errors.Wrap(err, "unable to process one or more resources") + } + + return nil + + case CompositionDiffTest: + proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) + + if err := proc.Initialize(ctx); err != nil { + return errors.Wrap(err, "cannot initialize composition diff processor") + } + + compositions, err := loader.Load() + if err != nil { + return errors.Wrap(err, "cannot load compositions") + } + + // A single --resource scopes to one composite resource, as + // simulate's --name does; the repeated and comma-separated + // forms never reach here (simulateGap skips them first). + refs, err := ref.ParseAll(a.Resource) + if err != nil { + return err + } + + hasDiffs, err := proc.DiffComposition(ctx, compositions, a.Namespace, refs) + exitCode = dp.DetermineExitCode(err, hasDiffs) + if err != nil { + return errors.Wrap(err, "unable to process composition diff") + } + + return nil + } + + t.Fatalf("unknown test type %q", testType) + return nil + }() + + return exitCode, err + } + + return run() +} + +// procFor builds the composition processor for a comp run, for +// CleanupDetached: the comp command cleans up the composition processor, +// which wraps the XR processor the render engine runs on. +func procFor(testType DiffTestType, xrProc dp.DiffProcessor, clients *client.Clients, opts []dp.ProcessorOption) dp.Cleaner { + if testType != CompositionDiffTest { + return xrProc + } + return dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) +} + +// initializeClients ports crossplane-diff's initializeAppContext over the +// ported client bundle: the run context bounded by the timeout, with the +// clients initialized on it. +func initializeClients(sigCtx context.Context, timeout time.Duration, clients *client.Clients, log logging.Logger) (context.Context, context.CancelFunc, error) { + ctx, cancel := context.WithTimeout(sigCtx, timeout) + if err := clients.Initialize(ctx, log); err != nil { + cancel() + return nil, nil, errors.Wrap(err, "cannot initialize client") + } + + return ctx, cancel, nil +} + +// processorOptions returns the options both processors get, replicating +// crossplane-diff's defaultProcessorOptions (cmd/diff/cmd_utils.go) — the +// single source of truth for behavior defaults in the command layer — +// plus the wiring its comp and xr commands add around the logger, the +// advisory channel, stdout and stderr. +func processorOptions(a *simArgs, stdout, stderr io.Writer, logger logging.Logger, advisories *dp.AdvisoryCollector) []dp.ProcessorOption { + // IgnorePaths carries only what the user asked to mask. The paths that + // are always filtered (kubectl's last-applied-configuration) are + // stripped unconditionally by the renderer instead — see + // renderer.alwaysIgnoredPaths. Prepending them here would make "the + // user passed --ignore-paths" indistinguishable from "we always ignore + // something", which comp-diff relies on to tell an edited composition + // from a merely re-applied one. + opts := []dp.ProcessorOption{ + dp.WithLogger(logger), + dp.WithAdvisories(advisories), + dp.WithColorize(!a.NoColor), + dp.WithMaxNestedDepth(a.maxNestedDepth()), + dp.WithMaxRenderIterations(a.maxIterations()), + dp.WithEventualState(a.EventualState), + dp.WithIgnorePaths(a.IgnorePaths), + dp.WithDryRunOn(dp.DryRunOn(a.DryRunOn)), + dp.WithIncludeManual(a.IncludeManual), + dp.WithMinimizeComposition(false), + dp.WithAnalyzeOn(a.analyzeOn()), + dp.WithStdout(stdout), + dp.WithStderr(stderr), + } + + var outputFormat renderer.OutputFormat + + switch renderer.OutputFormat(a.Output) { + case renderer.OutputFormatJSON: + outputFormat = renderer.OutputFormatJSON + case renderer.OutputFormatYAML: + outputFormat = renderer.OutputFormatYAML + case renderer.OutputFormatDiff: + outputFormat = renderer.OutputFormatDiff + default: + // Empty string or unrecognized values fall back to the human-readable diff. + outputFormat = renderer.OutputFormatDiff + } + + opts = append(opts, dp.WithOutputFormat(outputFormat), dp.WithOutputVersion(renderer.OutputVersion(a.OutputVersion))) + + // Function credentials load at parse time in crossplane-diff (a kong + // MapperValue); the cases that pass them are gap-skipped before this + // point, so nothing reaches the processor without them. + + return opts +} + +// analyzeOn ports crossplane-diff comp.go's analyzeOn(): the +// --analyze-on value, or --analyze-unchanged's equivalent "always", or +// the engine default with neither. +func (a *simArgs) analyzeOn() dp.AnalyzeOn { + if a.AnalyzeOn != "" { + return dp.AnalyzeOn(a.AnalyzeOn) + } + if a.AnalyzeUnchanged { + return dp.AnalyzeOnAlways + } + return dp.AnalyzeOnAnyChange +} + +// maxNestedDepth and maxIterations resolve the kong-var defaults, which +// kong leaves as "${max_nested_depth}" text when the vars are not fed to +// the parser — engineLimitVars feeds them, so these only guard that. +func (a *simArgs) maxNestedDepth() int { + if strings.HasPrefix(strconv.Itoa(a.MaxNestedDepth), "$") || a.MaxNestedDepth == 0 { + return dp.DefaultMaxNestedDepth + } + return a.MaxNestedDepth +} + +func (a *simArgs) maxIterations() int { + if a.MaxIterations == 0 { + return dp.DefaultMaxRenderIterations + } + return a.MaxIterations +} + +// dockerAvailable reports whether the local Docker daemon answers, which +// the render engine needs to run functions as containers. Probed once per +// test binary. +var dockerAvailable = sync.OnceValue(func() bool { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return docker.Check(ctx) == nil +}) + +// ExitCode carries the exit code a run determined, matching +// crossplane-diff's cmd/diff type the case tables were written against. +type ExitCode struct { + Code int +} diff --git a/internal/diff/integration/testdata/comp/api-version-updated-composition.yaml b/internal/diff/integration/testdata/comp/api-version-updated-composition.yaml new file mode 100644 index 00000000..02748913 --- /dev/null +++ b/internal/diff/integration/testdata/comp/api-version-updated-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapimigrateresources.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: comp.example.org/v1beta2 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/claim-revision-templating-updated-composition.yaml b/internal/diff/integration/testdata/comp/claim-revision-templating-updated-composition.yaml new file mode 100644 index 00000000..60bfc213 --- /dev/null +++ b/internal/diff/integration/testdata/comp/claim-revision-templating-updated-composition.yaml @@ -0,0 +1,37 @@ +# A metadata-only edit of resources/claim-revision-templating-composition.yaml: one added label, +# identical spec. Applying it mints a new CompositionRevision, which an Automatic claim's backing XR +# re-points to — and the template reads that revision's name, so the composed resource changes. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefclaims.diff.example.org + labels: + channel: preview +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/composition-no-changes.yaml b/internal/diff/integration/testdata/comp/composition-no-changes.yaml new file mode 100644 index 00000000..556cf7bf --- /dev/null +++ b/internal/diff/integration/testdata/comp/composition-no-changes.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/crds/xapimigrateresource-crd.yaml b/internal/diff/integration/testdata/comp/crds/xapimigrateresource-crd.yaml new file mode 100644 index 00000000..2f2ce494 --- /dev/null +++ b/internal/diff/integration/testdata/comp/crds/xapimigrateresource-crd.yaml @@ -0,0 +1,84 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xapimigrateresources.comp.example.org +spec: + group: comp.example.org + names: + kind: XApiMigrateResource + listKind: XApiMigrateResourceList + plural: xapimigrateresources + singular: xapimigrateresource + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: false + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + description: "Configuration data for the resource" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + lastTransitionTime: + type: string + reason: + type: string + message: + type: string + - name: v1beta2 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + description: "Configuration data for the resource" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + lastTransitionTime: + type: string + reason: + type: string + message: + type: string diff --git a/internal/diff/integration/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml b/internal/diff/integration/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml new file mode 100644 index 00000000..d26f917d --- /dev/null +++ b/internal/diff/integration/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml b/internal/diff/integration/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml new file mode 100644 index 00000000..49c1db14 --- /dev/null +++ b/internal/diff/integration/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.legacycluster.nop.example.org +spec: + group: legacycluster.nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/comp/crds/xdownstreamresource-ns-crd.yaml b/internal/diff/integration/testdata/comp/crds/xdownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..eb4fd390 --- /dev/null +++ b/internal/diff/integration/testdata/comp/crds/xdownstreamresource-ns-crd.yaml @@ -0,0 +1,68 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + resourceType: + type: string + description: "Type of the resource (e.g., database, storage, network)" + tier: + type: string + description: "Service tier (e.g., standard, premium)" + backupEnabled: + type: boolean + description: "Whether backup is enabled for this resource" + removableField: + type: string + description: "A field that can be removed for testing field removal detection" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/comp/field-removal-updated-composition.yaml b/internal/diff/integration/testdata/comp/field-removal-updated-composition.yaml new file mode 100644 index 00000000..7cff974f --- /dev/null +++ b/internal/diff/integration/testdata/comp/field-removal-updated-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xfieldremoval.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/mixed-status-updated-composition.yaml b/internal/diff/integration/testdata/comp/mixed-status-updated-composition.yaml new file mode 100644 index 00000000..3e01b1e8 --- /dev/null +++ b/internal/diff/integration/testdata/comp/mixed-status-updated-composition.yaml @@ -0,0 +1,59 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xmixed.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-database + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + resourceType: database + tier: premium + backupEnabled: true + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-storage + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: storage + spec: + forProvider: + resourceType: storage + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-network + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: network + spec: + forProvider: + resourceType: network + tier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/net-new-composition.yaml b/internal/diff/integration/testdata/comp/net-new-composition.yaml new file mode 100644 index 00000000..4bfa487f --- /dev/null +++ b/internal/diff/integration/testdata/comp/net-new-composition.yaml @@ -0,0 +1,37 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnewresources.diff.example.org + labels: + environment: staging + provider: aws +spec: + compositeTypeRef: + apiVersion: staging.diff.example.org/v1alpha1 + kind: XNewResource + mode: Pipeline + pipeline: + - step: generate-new-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: staging.nop.example.org/v1alpha1 + kind: XStagingResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: staging-resource + spec: + forProvider: + environment: staging + configData: new-{{ .observed.composite.resource.spec.newField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/api-version-original-composition.yaml b/internal/diff/integration/testdata/comp/resources/api-version-original-composition.yaml new file mode 100644 index 00000000..00221d82 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/api-version-original-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapimigrateresources.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: comp.example.org/v1beta1 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/claim-composition.yaml b/internal/diff/integration/testdata/comp/resources/claim-composition.yaml new file mode 100644 index 00000000..58219d58 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/claim-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: nopclaims.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml b/internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml new file mode 100644 index 00000000..f7a6b7c3 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml @@ -0,0 +1,41 @@ +# claim-revision-templating-composition.yaml as the cluster holds it after a client-side +# `kubectl apply` of an older file: identical content, plus a last-applied-configuration annotation +# that no longer describes the file. Diffing claim-revision-templating-composition.yaml against this is +# a change whose only delta is that annotation, so the name of the revision it would mint cannot be +# predicted, nothing is seeded, and every composite renders against the revision it tracks now. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefclaims.diff.example.org + annotations: + # Deliberately stale: it describes some earlier file, not the one diffed against this. + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition","metadata":{"name":"xrevisionrefclaims.diff.example.org"},"spec":{"mode":"Pipeline"}} +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition.yaml b/internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition.yaml new file mode 100644 index 00000000..0f5cd92c --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/claim-revision-templating-composition.yaml @@ -0,0 +1,39 @@ +# The claim-side counterpart of revision-templating-composition.yaml: the in-cluster copy of a +# composition whose template reads the CompositionRevision's own name off the composite and propagates +# it into a composed resource. XNop is a legacy (claim-capable) XR, so the ref is at the v1 path, +# spec.compositionRevisionRef. +# +# Paired with ../claim-revision-templating-updated-composition.yaml (a label-only edit) and with +# claim-revision-templating-composition-kubectl-applied.yaml (this, plus a stale kubectl annotation). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefclaims.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/claim-xrd.yaml b/internal/diff/integration/testdata/comp/resources/claim-xrd.yaml new file mode 100644 index 00000000..b2b8dfa7 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/claim-xrd.yaml @@ -0,0 +1,27 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnops.diff.example.org +spec: + group: diff.example.org + names: + kind: XNop + plural: xnops + claimNames: + kind: NopClaim + plural: nopclaims + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + writeConnectionSecretsToNamespace: + type: string diff --git a/internal/diff/integration/testdata/comp/resources/custom-namespace.yaml b/internal/diff/integration/testdata/comp/resources/custom-namespace.yaml new file mode 100644 index 00000000..bb71c9a0 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/custom-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: custom-namespace \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/cycle/existing-cycle-xr.yaml b/internal/diff/integration/testdata/comp/resources/cycle/existing-cycle-xr.yaml new file mode 100644 index 00000000..38d622c3 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/cycle/existing-cycle-xr.yaml @@ -0,0 +1,12 @@ +# An existing XR on the XCycleA composition, which comp discovers and re-renders against the +# updated composition. Its composition starts the XCycleA -> XCycleB -> XCycleA cycle. +apiVersion: ns.cycle.example.org/v1alpha1 +kind: XCycleA +metadata: + name: test-cycle + namespace: default +spec: + cycleField: "cycle-value" + crossplane: + compositionRef: + name: xcycleas.cycle.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml b/internal/diff/integration/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml new file mode 100644 index 00000000..8b63f58b --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml @@ -0,0 +1,10 @@ +apiVersion: comp.example.org/v1beta1 +kind: XApiMigrateResource +metadata: + name: test-api-version-api-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: api-migrate-resource +spec: + forProvider: + configData: test-value diff --git a/internal/diff/integration/testdata/comp/resources/existing-api-version-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-api-version-xr.yaml new file mode 100644 index 00000000..05f4d7b2 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-api-version-xr.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionUpdatePolicy: Automatic + coolField: test-value diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-1-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-1-xr.yaml new file mode 100644 index 00000000..24de708a --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-1-xr.yaml @@ -0,0 +1,21 @@ +# The backing XR Crossplane's claim syncer created for existing-claim-1.yaml. Its composed resource is +# existing-claim-downstream-1.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: test-claim-1-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: test-claim-1 + namespace: test-namespace + coolField: claim-value-1 + compositeDeletePolicy: Background + compositionUpdatePolicy: Automatic + compositionRef: + name: nopclaims.diff.example.org + resourceRefs: + - apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + name: test-claim-1-xr diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-1.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-1.yaml new file mode 100644 index 00000000..dac0a2b9 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-1.yaml @@ -0,0 +1,18 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim-1 + namespace: test-namespace +spec: + coolField: claim-value-1 + compositeDeletePolicy: Background + compositionRef: + name: nopclaims.diff.example.org + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: nopclaims.diff.example.org-e164b8b + # Bound to its backing XR, existing-claim-1-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: test-claim-1-xr diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-2-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-2-xr.yaml new file mode 100644 index 00000000..de2703cb --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-2-xr.yaml @@ -0,0 +1,21 @@ +# The backing XR Crossplane's claim syncer created for existing-claim-2.yaml. Its composed resource is +# existing-claim-downstream-2.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: test-claim-2-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: test-claim-2 + namespace: test-namespace + coolField: claim-value-2 + compositeDeletePolicy: Background + compositionUpdatePolicy: Automatic + compositionRef: + name: nopclaims.diff.example.org + resourceRefs: + - apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + name: test-claim-2-xr diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-2.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-2.yaml new file mode 100644 index 00000000..7f7b89e1 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-2.yaml @@ -0,0 +1,18 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim-2 + namespace: test-namespace +spec: + coolField: claim-value-2 + compositeDeletePolicy: Background + compositionRef: + name: nopclaims.diff.example.org + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: nopclaims.diff.example.org-e164b8b + # Bound to its backing XR, existing-claim-2-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: test-claim-2-xr diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-downstream-1.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-downstream-1.yaml new file mode 100644 index 00000000..23fd1f94 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-downstream-1.yaml @@ -0,0 +1,10 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-claim-1-xr +spec: + forProvider: + configData: claim-value-1 + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-downstream-2.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-downstream-2.yaml new file mode 100644 index 00000000..f0848c7b --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-downstream-2.yaml @@ -0,0 +1,10 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-claim-2-xr +spec: + forProvider: + configData: claim-value-2 + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml new file mode 100644 index 00000000..4803e305 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml @@ -0,0 +1,14 @@ +# The composed resource of existing-claim-revision-ref-xr.yaml, rendered from the revision that XR +# tracks, so configData holds that revision's name. Carries crossplane.io/composition-resource-name (and, +# from the harness, the composite and claim labels) because the render adds all of them: a fixture missing +# any is reported as modified on every run, which would hide whether the revision name propagated. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: revision-ref-claim-xr + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefclaims.diff.example.org-98b484d + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml new file mode 100644 index 00000000..865fbace --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml @@ -0,0 +1,12 @@ +# existing-claim-revision-ref-downstream.yaml as rendered from the revision +# existing-claim-revision-ref-kubectl-applied-xr.yaml tracks, so configData holds that revision's name. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: revision-ref-claim-xr + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefclaims.diff.example.org-329a7cd + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml new file mode 100644 index 00000000..9aa0011f --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml @@ -0,0 +1,15 @@ +# existing-claim-revision-ref-xr.yaml where the composition was applied with a client-side `kubectl +# apply`. Composition.Hash() covers annotations, so the revision it tracks has a different name. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: revision-ref-claim-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: revision-ref-claim + namespace: test-namespace + coolField: revision-ref-claim-value + compositionRef: + name: xrevisionrefclaims.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml new file mode 100644 index 00000000..76a758b2 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml @@ -0,0 +1,20 @@ +# existing-claim-revision-ref.yaml where the composition was applied with a client-side `kubectl apply` +# (claim-revision-templating-composition-kubectl-applied.yaml). Composition.Hash() covers annotations, +# so that composition's revision has another name, which the claim carries like its backing XR, +# existing-claim-revision-ref-kubectl-applied-xr.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: revision-ref-claim + namespace: test-namespace +spec: + coolField: revision-ref-claim-value + compositeDeletePolicy: Background + compositionRef: + name: xrevisionrefclaims.diff.example.org + compositionRevisionRef: + name: xrevisionrefclaims.diff.example.org-329a7cd + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: revision-ref-claim-xr diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-xr.yaml new file mode 100644 index 00000000..09cb9326 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref-xr.yaml @@ -0,0 +1,15 @@ +# The backing XR of existing-claim-revision-ref.yaml, tracking the current revision of +# claim-revision-templating-composition.yaml as Crossplane's composite reconciler leaves it. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: revision-ref-claim-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: revision-ref-claim + namespace: test-namespace + coolField: revision-ref-claim-value + compositionRef: + name: xrevisionrefclaims.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref.yaml b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref.yaml new file mode 100644 index 00000000..6bb6c655 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-claim-revision-ref.yaml @@ -0,0 +1,20 @@ +# A Claim at the default (Automatic) update policy, bound to existing-claim-revision-ref-xr.yaml. It +# carries that XR's compositionRevisionRef, because under Automatic the claim syncer copies the XR's ref +# back onto the claim. Going the other way the claim's ref is not propagated: under Automatic the XR +# controller owns the backing XR's ref, so that is the one render must see (issue #498). +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: revision-ref-claim + namespace: test-namespace +spec: + coolField: revision-ref-claim-value + compositeDeletePolicy: Background + compositionRef: + name: xrevisionrefclaims.diff.example.org + compositionRevisionRef: + name: xrevisionrefclaims.diff.example.org-98b484d + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: revision-ref-claim-xr diff --git a/internal/diff/integration/testdata/comp/resources/existing-conditional-gating-stage0.yaml b/internal/diff/integration/testdata/comp/resources/existing-conditional-gating-stage0.yaml new file mode 100644 index 00000000..2e0c9b2c --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-conditional-gating-stage0.yaml @@ -0,0 +1,12 @@ +# The stage0 resource conditional-gating-composition.yaml composed for existing-conditional-gating-xr.yaml. +# It has no Ready condition yet, so stage1 stays gated until --eventual-state synthesizes one. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/comp/resources/existing-conditional-gating-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-conditional-gating-xr.yaml new file mode 100644 index 00000000..0a9b0167 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-conditional-gating-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: conditional-gating-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: conditional-gating-composition diff --git a/internal/diff/integration/testdata/comp/resources/existing-custom-ns-downstream.yaml b/internal/diff/integration/testdata/comp/resources/existing-custom-ns-downstream.yaml new file mode 100644 index 00000000..f22ee1bf --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-custom-ns-downstream.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: custom-namespace-resource + namespace: custom-namespace + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: custom-ns-existing-value + resourceTier: basic \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/existing-custom-ns-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-custom-ns-xr.yaml new file mode 100644 index 00000000..c8c5d766 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-custom-ns-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: custom-namespace-resource + namespace: custom-namespace +spec: + coolField: custom-ns-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-1.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-1.yaml new file mode 100644 index 00000000..b623c945 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: existing-value + resourceTier: basic \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-2.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-2.yaml new file mode 100644 index 00000000..76645339 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: another-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: another-existing-value + resourceTier: basic \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-deleting.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-deleting.yaml new file mode 100644 index 00000000..3dd7c99d --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-deleting.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: deleting-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: existing-value + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-drifted.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-drifted.yaml new file mode 100644 index 00000000..347c4221 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-drifted.yaml @@ -0,0 +1,14 @@ +# existing-downstream-1.yaml after an out-of-band edit: someone changed configData in the cluster, so +# it no longer matches what the composition renders for existing-xr-1.yaml. Re-rendering an unchanged +# composition therefore surfaces a delta the composition did not cause. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: drifted-value + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-manual.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-manual.yaml new file mode 100644 index 00000000..34dda8a3 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-manual.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: manual-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: manual-existing-value + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml new file mode 100644 index 00000000..eea3c1cc --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml @@ -0,0 +1,15 @@ +# The composed resource of existing-xr-pinned-revision-ref.yaml as its pinned revision rendered it, so +# configData holds that revision's name. It carries crossplane.io/composition-resource-name as well as the +# gotemplating annotation, as a rendered resource does; see existing-downstream-revision-ref.yaml for why a +# fixture without it would show a diff on every run. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: pinned-revision-ref-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefs.diff.example.org-0d65896 + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml new file mode 100644 index 00000000..617bac3b --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml @@ -0,0 +1,19 @@ +# The composed resource as it exists in the cluster, i.e. rendered from the revision the composite +# currently tracks. configData therefore holds that revision's name, which is exactly what makes the +# revision-propagating change observable: re-render against the new revision and this value must move. +# +# It carries crossplane.io/composition-resource-name, which the render adds, so that it is a real +# discriminator: a fixture omitting it shows an annotation diff on EVERY run, which would report the +# resource as modified whether or not the revision name propagated. Its value is a real revision name, +# which matches the predicted-name pattern too, so the tests pin the exact old and new value. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: revision-ref-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefs.diff.example.org-e36719b + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-v2.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-v2.yaml new file mode 100644 index 00000000..709ba671 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-v2.yaml @@ -0,0 +1,13 @@ +# What original-composition-2.yaml currently renders for existing-xr-v2, so the diff against the +# changed composition shows only the fields that composition actually changed. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: v2-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource-v2 +spec: + forProvider: + configData: v2-updated-v2-existing-value + resourceTier: enterprise diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-with-argocd.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-with-argocd.yaml new file mode 100644 index 00000000..73dcfbfc --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-with-argocd.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + argocd.argoproj.io/tracking-id: "test-app:/XDownstreamResource:default/test-resource" + labels: + argocd.argoproj.io/instance: test-app + name: test-resource + namespace: default +spec: + forProvider: + configData: existing-value + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-stage0.yaml b/internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-stage0.yaml new file mode 100644 index 00000000..9f520639 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-stage0.yaml @@ -0,0 +1,12 @@ +# The stage0 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml. +# It has no Ready condition yet, so stage1 stays gated until --eventual-state synthesizes one. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-xr.yaml new file mode 100644 index 00000000..bf00981f --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-sequencer-gating-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-gating-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: sequencer-gating-composition diff --git a/internal/diff/integration/testdata/comp/resources/existing-sha256-downstream.yaml b/internal/diff/integration/testdata/comp/resources/existing-sha256-downstream.yaml new file mode 100644 index 00000000..c5c48f16 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-sha256-downstream.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: sha256-test-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: sha256-value + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/existing-sha256-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-sha256-xr.yaml new file mode 100644 index 00000000..3409f380 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-sha256-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sha256-test-resource + namespace: default +spec: + coolField: sha256-value + crossplane: + compositionRef: + name: xnopresources-sha256.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-unverified-cluster-xr.yaml b/internal/diff/integration/testdata/comp/resources/existing-unverified-cluster-xr.yaml new file mode 100644 index 00000000..4b75574e --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-unverified-cluster-xr.yaml @@ -0,0 +1,11 @@ +# A cluster-scoped XR using unverified-cluster-composition.yaml. It has composed nothing: the +# composition renders ConfigMaps into a namespace that does not exist, so Crossplane cannot create them. +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: unverified-xr +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources-unverified.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml new file mode 100644 index 00000000..b7ff7ef3 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml @@ -0,0 +1,13 @@ +# existing-xr-1.yaml where the composition was applied with a client-side `kubectl apply` of the +# file being diffed (original-composition-kubectl-applied-current.yaml). Composition.Hash() covers +# annotations, so that composition's revision has a different name, and this is the one the XR tracks. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml new file mode 100644 index 00000000..ea3e88fe --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml @@ -0,0 +1,13 @@ +# existing-xr-1.yaml where the composition was applied with a client-side `kubectl apply` +# (original-composition-kubectl-applied.yaml). Composition.Hash() covers annotations, so that +# composition's revision has a different name, and this is the one the XR tracks. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-1.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-1.yaml new file mode 100644 index 00000000..46dca046 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-2.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-2.yaml new file mode 100644 index 00000000..77d70d9c --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: another-resource + namespace: default +spec: + coolField: another-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-deleting.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-deleting.yaml new file mode 100644 index 00000000..c4d717a5 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-deleting.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: deleting-resource + namespace: default + # The finalizer keeps the object around after a delete is issued, so envtest (which runs no + # controllers) leaves it with metadata.deletionTimestamp set — a resource mid-deletion. + finalizers: + - composite.apiextensions.crossplane.io +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml new file mode 100644 index 00000000..c03ffe8e --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml @@ -0,0 +1,22 @@ +# ../../diff/resources/existing-xr-listing-uncontrolled-resources.yaml, tracking the in-cluster +# removal-test-original-composition.yaml instead. Its resourceRefs still name two composed resources it +# no longer controls: one ../../diff/resources/existing-other-xr.yaml now controls, and one whose +# controller reference was removed. The harness appends the resources this XR does control. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic + resourceRefs: + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: other-resource + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: resource-without-controller diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-manual.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-manual.yaml new file mode 100644 index 00000000..677aeba1 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-manual.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: manual-resource + namespace: default +spec: + coolField: manual-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Manual diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml new file mode 100644 index 00000000..1856b731 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml @@ -0,0 +1,18 @@ +# A Manual composite of the revision-templating composition, pinned to an older CompositionRevision than +# the one existing-xr-revision-ref.yaml tracks: revision 1, +# revision-templating-composition-older-revision.yaml. A Manual composite never re-points, so applying +# an edit of its composition leaves it on this revision, and a template reading the revision name must +# keep reading this one. Kept for analysis only under --include-manual. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: pinned-revision-ref-resource + namespace: default +spec: + coolField: pinned-revision-ref-existing-value + crossplane: + compositionRef: + name: xrevisionrefs.diff.example.org + compositionUpdatePolicy: Manual + compositionRevisionRef: + name: xrevisionrefs.diff.example.org-0d65896 diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-revision-ref.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-revision-ref.yaml new file mode 100644 index 00000000..7750dd99 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-revision-ref.yaml @@ -0,0 +1,15 @@ +# A composite with an Automatic update policy, tracking the latest revision of +# revision-templating-composition.yaml (revision 2, revision-templating-composition-revision.yaml), as +# Crossplane's composite reconciler leaves it. Automatic, so it would re-point to the revision a composition edit +# produces. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: revision-ref-resource + namespace: default +spec: + coolField: revision-ref-existing-value + crossplane: + compositionRef: + name: xrevisionrefs.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-selector-match.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-selector-match.yaml new file mode 100644 index 00000000..ef9c030d --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-selector-match.yaml @@ -0,0 +1,18 @@ +# Deliberately no compositionRevisionRef. The selector matches no revision in the cluster: +# original-composition.yaml has no version label, so neither has its one revision. Crossplane cannot +# select a revision for this XR, so it never writes the ref, and the XR waits for a composition +# labelled version=0.0.2, like updated-composition-labeled.yaml. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-match-resource + namespace: default +spec: + coolField: selector-match-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + version: 0.0.2 diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-selector-mismatch.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-selector-mismatch.yaml new file mode 100644 index 00000000..a7f18cdc --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-selector-mismatch.yaml @@ -0,0 +1,18 @@ +# Deliberately no compositionRevisionRef. The selector matches no revision in the cluster: +# original-composition.yaml has no version label, so neither has its one revision. Crossplane cannot +# select a revision for this XR, so it never writes the ref, and the XR waits for a composition +# labelled version=0.0.1 (updated-composition-labeled.yaml is 0.0.2, so it is not that one). +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-mismatch-resource + namespace: default +spec: + coolField: selector-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + version: 0.0.1 diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-v2.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-v2.yaml new file mode 100644 index 00000000..10b2dad7 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-v2.yaml @@ -0,0 +1,12 @@ +# An XR that uses the SECOND composition (xnopresources-v2), so a multi-composition run can have +# affected XRs under both compositions rather than only the first. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: v2-resource + namespace: default +spec: + coolField: v2-existing-value + crossplane: + compositionRef: + name: xnopresources-v2.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/existing-xr-with-argocd.yaml b/internal/diff/integration/testdata/comp/resources/existing-xr-with-argocd.yaml new file mode 100644 index 00000000..2b4fce63 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-xr-with-argocd.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app:/XNopResource:default/test-resource" + labels: + argocd.argoproj.io/instance: test-app +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/field-removal/composition-with-field.yaml b/internal/diff/integration/testdata/comp/resources/field-removal/composition-with-field.yaml new file mode 100644 index 00000000..78a14146 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/field-removal/composition-with-field.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xfieldremoval.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + removableField: will-be-removed + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/field-removal/existing-downstream.yaml b/internal/diff/integration/testdata/comp/resources/field-removal/existing-downstream.yaml new file mode 100644 index 00000000..0c1491f2 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/field-removal/existing-downstream.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: field-removal-test + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: test-value + removableField: will-be-removed diff --git a/internal/diff/integration/testdata/comp/resources/field-removal/existing-xr.yaml b/internal/diff/integration/testdata/comp/resources/field-removal/existing-xr.yaml new file mode 100644 index 00000000..831e9b86 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/field-removal/existing-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: field-removal-test + namespace: default +spec: + coolField: test-value + crossplane: + compositionRef: + name: xfieldremoval.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/functions-sha256.yaml b/internal/diff/integration/testdata/comp/resources/functions-sha256.yaml new file mode 100644 index 00000000..0fda179d --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/functions-sha256.yaml @@ -0,0 +1,17 @@ +--- +# Function referenced by SHA256 digest instead of tag. +# This tests that a function whose package is a digest reference renders. +# The digest is for function-go-templating:v0.11.0 - same image, different reference format. +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating-sha256 +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating@sha256:34f1ca0ff2e1702ae0a5088a96f31e992eb9b15356af3b0f1a2068603e767753 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.4.2 diff --git a/internal/diff/integration/testdata/comp/resources/functions.yaml b/internal/diff/integration/testdata/comp/resources/functions.yaml new file mode 100644 index 00000000..f86e81bf --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/functions.yaml @@ -0,0 +1,32 @@ +--- +# TODO: when updating these, ensure you also update the list of pulled images in Earthfile/go-test +# +# No Docker runtime annotations: the render engine labels each function's container with its run, drops any container +# name, and removes the containers when the run ends. +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.4.2 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-environment-configs +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-environment-configs:v0.4.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-extra-resources +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-extra-resources:v0.2.0 diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-1.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-1.yaml new file mode 100644 index 00000000..02c75e6e --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-1-database + namespace: default + annotations: + crossplane.io/composition-resource-name: database +spec: + forProvider: + resourceType: database + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-2.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-2.yaml new file mode 100644 index 00000000..55cbbba5 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-2-database + namespace: default + annotations: + crossplane.io/composition-resource-name: database +spec: + forProvider: + resourceType: database + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-3.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-3.yaml new file mode 100644 index 00000000..25ddf6f4 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-db-3.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-3-database + namespace: default + annotations: + crossplane.io/composition-resource-name: database +spec: + forProvider: + resourceType: database + tier: premium + backupEnabled: true diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-1.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-1.yaml new file mode 100644 index 00000000..f9672d09 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-1-network + namespace: default + annotations: + crossplane.io/composition-resource-name: network +spec: + forProvider: + resourceType: network + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-2.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-2.yaml new file mode 100644 index 00000000..e368615b --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-2-network + namespace: default + annotations: + crossplane.io/composition-resource-name: network +spec: + forProvider: + resourceType: network + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-3.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-3.yaml new file mode 100644 index 00000000..7186308e --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-network-3.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-3-network + namespace: default + annotations: + crossplane.io/composition-resource-name: network +spec: + forProvider: + resourceType: network + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-1.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-1.yaml new file mode 100644 index 00000000..1ef2aeff --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-1-storage + namespace: default + annotations: + crossplane.io/composition-resource-name: storage +spec: + forProvider: + resourceType: storage + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-2.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-2.yaml new file mode 100644 index 00000000..d6bcfd9d --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-2-storage + namespace: default + annotations: + crossplane.io/composition-resource-name: storage +spec: + forProvider: + resourceType: storage + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-3.yaml b/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-3.yaml new file mode 100644 index 00000000..af025860 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-downstream-storage-3.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-3-storage + namespace: default + annotations: + crossplane.io/composition-resource-name: storage +spec: + forProvider: + resourceType: storage + tier: standard diff --git a/internal/diff/integration/testdata/comp/resources/mixed-status-composition.yaml b/internal/diff/integration/testdata/comp/resources/mixed-status-composition.yaml new file mode 100644 index 00000000..f3c367ee --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-status-composition.yaml @@ -0,0 +1,58 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xmixed.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-database + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + resourceType: database + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-storage + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: storage + spec: + forProvider: + resourceType: storage + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-network + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: network + spec: + forProvider: + resourceType: network + tier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/mixed-xr-1.yaml b/internal/diff/integration/testdata/comp/resources/mixed-xr-1.yaml new file mode 100644 index 00000000..8a44ded7 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-xr-1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: mixed-test-xr-1 + namespace: default +spec: + coolField: mixed-value-1 + crossplane: + compositionRef: + name: xmixed.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/mixed-xr-2.yaml b/internal/diff/integration/testdata/comp/resources/mixed-xr-2.yaml new file mode 100644 index 00000000..413db4d2 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-xr-2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: mixed-test-xr-2 + namespace: default +spec: + coolField: mixed-value-2 + crossplane: + compositionRef: + name: xmixed.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/mixed-xr-3.yaml b/internal/diff/integration/testdata/comp/resources/mixed-xr-3.yaml new file mode 100644 index 00000000..a6fb9cd6 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/mixed-xr-3.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: mixed-test-xr-3 + namespace: default +spec: + coolField: mixed-value-3 + crossplane: + compositionRef: + name: xmixed.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml new file mode 100644 index 00000000..12a756b2 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml @@ -0,0 +1,56 @@ +# The CompositionRevision Crossplane creates for collision-composition.yaml, +# named and labelled from its hash as Crossplane names it. envtest runs no Crossplane controllers, +# so it must be declared. The XRs in existing-xr-ns-{a,b}.yaml track it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xnopresources.collision.example.org-163b210 + labels: + crossplane.io/composition-name: xnopresources.collision.example.org + crossplane.io/composition-hash: 163b2101da346b4554697172116dfe489afe2605a36a3af8122f643c3f13e81 +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Fetch ConfigMap by name - uses XR's namespace since no namespace specified + - apiVersion: v1 + kind: ConfigMap + into: nsConfig + type: Reference + ref: + name: collision-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $config := index .context "apiextensions.crossplane.io/extra-resources" "nsConfig" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: collision-resource + spec: + forProvider: + # This should contain the ConfigMap data from the XR's namespace + configData: {{ index $config "data" "value" }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition.yaml new file mode 100644 index 00000000..eff808b4 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/collision-composition.yaml @@ -0,0 +1,52 @@ +# Composition that fetches a ConfigMap by name from the XR's namespace. +# This is used to test the namespace collision bug where resources with +# the same name in different namespaces collide in the cache. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.collision.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Fetch ConfigMap by name - uses XR's namespace since no namespace specified + - apiVersion: v1 + kind: ConfigMap + into: nsConfig + type: Reference + ref: + name: collision-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $config := index .context "apiextensions.crossplane.io/extra-resources" "nsConfig" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: collision-resource + spec: + forProvider: + # This should contain the ConfigMap data from the XR's namespace + configData: {{ index $config "data" "value" }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml new file mode 100644 index 00000000..bc1a9387 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml @@ -0,0 +1,8 @@ +# ConfigMap in namespace ns-a - will be fetched by XR in ns-a +apiVersion: v1 +kind: ConfigMap +metadata: + name: collision-config + namespace: ns-a +data: + value: from-ns-a diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml new file mode 100644 index 00000000..3f5ba205 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml @@ -0,0 +1,8 @@ +# ConfigMap in namespace ns-b - will be fetched by XR in ns-b +apiVersion: v1 +kind: ConfigMap +metadata: + name: collision-config + namespace: ns-b +data: + value: from-ns-b diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml new file mode 100644 index 00000000..eccd4905 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml @@ -0,0 +1,14 @@ +# Downstream resource for XR in ns-a +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: xr-in-ns-a + namespace: ns-a + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-a +spec: + forProvider: + # Current value from the ConfigMap in ns-a + configData: from-ns-a diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml new file mode 100644 index 00000000..75a16cdf --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml @@ -0,0 +1,14 @@ +# Downstream resource for XR in ns-b +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: xr-in-ns-b + namespace: ns-b + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-b +spec: + forProvider: + # Current value from the ConfigMap in ns-b + configData: from-ns-b diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml new file mode 100644 index 00000000..6e1aad43 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml @@ -0,0 +1,15 @@ +# XR in namespace ns-a +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: xr-in-ns-a + labels: + crossplane.io/composite: xr-in-ns-a + namespace: ns-a +spec: + coolField: test-value-a + crossplane: + compositionRef: + name: xnopresources.collision.example.org + compositionRevisionRef: + name: xnopresources.collision.example.org-163b210 diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml new file mode 100644 index 00000000..a7e9c5a0 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml @@ -0,0 +1,15 @@ +# XR in namespace ns-b +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: xr-in-ns-b + labels: + crossplane.io/composite: xr-in-ns-b + namespace: ns-b +spec: + coolField: test-value-b + crossplane: + compositionRef: + name: xnopresources.collision.example.org + compositionRevisionRef: + name: xnopresources.collision.example.org-163b210 diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml new file mode 100644 index 00000000..cbf261f5 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ns-a diff --git a/internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml b/internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml new file mode 100644 index 00000000..0dc47b53 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ns-b diff --git a/internal/diff/integration/testdata/comp/resources/nested-xr/existing-child-downstream.yaml b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-child-downstream.yaml new file mode 100644 index 00000000..3094b7df --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-child-downstream.yaml @@ -0,0 +1,16 @@ +# Downstream resource created by the CHILD'S composition (xnopresources.diff.example.org). +# This resource should NOT be affected when we diff the parent composition, +# because the nested XR should use its own composition. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-nested + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + # This value comes from the CHILD's composition (xnopresources.diff.example.org) + # which uses: configData: {{ .observed.composite.resource.spec.coolField }} + configData: child-config-value + resourceTier: basic diff --git a/internal/diff/integration/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml new file mode 100644 index 00000000..e9ff65cb --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml @@ -0,0 +1,14 @@ +# Nested XNopResource created by the parent composition. +# This XR uses its OWN composition (xnopresources.diff.example.org) to create downstream resources. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-parent-nested + namespace: default + annotations: + crossplane.io/composition-resource-name: nested-xr +spec: + coolField: child-config-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml new file mode 100644 index 00000000..7d6b8b19 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml @@ -0,0 +1,12 @@ +# Direct downstream resource created by the parent composition +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-direct + namespace: default + annotations: + crossplane.io/composition-resource-name: direct-resource +spec: + forProvider: + configData: parent-parent-value + resourceTier: parent-tier diff --git a/internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-xr.yaml b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-xr.yaml new file mode 100644 index 00000000..6b88e567 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/nested-xr/existing-parent-xr.yaml @@ -0,0 +1,12 @@ +# Existing parent XR that uses the parent composition +apiVersion: parent.diff.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: parent-value + childConfig: child-config-value + crossplane: + compositionRef: + name: xparentresources.parent.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/nested-xr/parent-composition.yaml b/internal/diff/integration/testdata/comp/resources/nested-xr/parent-composition.yaml new file mode 100644 index 00000000..066be354 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/nested-xr/parent-composition.yaml @@ -0,0 +1,49 @@ +# Parent composition that creates a nested XNopResource. +# The nested XNopResource uses its OWN composition (xnopresources.diff.example.org) +# to create its downstream resources. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xparentresources.parent.diff.example.org +spec: + compositeTypeRef: + apiVersion: parent.diff.example.org/v1alpha1 + kind: XParentResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + # Direct downstream resource owned by the parent XR + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-direct + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: direct-resource + spec: + forProvider: + configData: parent-{{ .observed.composite.resource.spec.parentField }} + resourceTier: parent-tier + --- + # Nested XR of type XNopResource - this should use its OWN composition + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-nested + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nested-xr + spec: + coolField: {{ .observed.composite.resource.spec.childConfig }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/nested-xr/parent-xrd.yaml b/internal/diff/integration/testdata/comp/resources/nested-xr/parent-xrd.yaml new file mode 100644 index 00000000..ffe6e36d --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/nested-xr/parent-xrd.yaml @@ -0,0 +1,50 @@ +# Parent XRD that creates nested XRs of type XNopResource +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xparentresources.parent.diff.example.org +spec: + group: parent.diff.example.org + names: + kind: XParentResource + plural: xparentresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parentField: + type: string + childConfig: + type: string + status: + type: object + properties: + conditions: + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/comp/resources/original-composition-2.yaml b/internal/diff/integration/testdata/comp/resources/original-composition-2.yaml new file mode 100644 index 00000000..a6605cec --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/original-composition-2.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-v2.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource-v2 + spec: + forProvider: + configData: v2-updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: enterprise + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied-current.yaml b/internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied-current.yaml new file mode 100644 index 00000000..0b2fe1dd --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied-current.yaml @@ -0,0 +1,41 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org + annotations: + # Content-identical to original-composition.yaml apart from this annotation, whose value is what a + # client-side `kubectl apply` of composition-no-changes.yaml writes: that file minus this annotation, + # JSON-encoded (kubectl keeps the emptied annotations map, hence "annotations":{}). Unlike + # original-composition-kubectl-applied.yaml, whose value is stale, re-applying that file over this + # one is a no-op: kubectl recomputes the same annotation, the patch is empty, and nothing is written. + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition","metadata":{"name":"xnopresources.diff.example.org","annotations":{}},"spec":{"compositeTypeRef":{"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource"},"mode":"Pipeline","pipeline":[{"step":"generate-resources","functionRef":{"name":"function-go-templating"},"input":{"apiVersion":"template.fn.crossplane.io/v1beta1","kind":"GoTemplate","source":"Inline","inline":{"template":"apiVersion: ns.nop.example.org/v1alpha1\nkind: XDownstreamResource\nmetadata:\n name: {{ .observed.composite.resource.metadata.name }}\n namespace: {{ .observed.composite.resource.metadata.namespace }}\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: nop-resource\nspec:\n forProvider:\n configData: {{ .observed.composite.resource.spec.coolField }}\n resourceTier: basic\n"}}},{"step":"automatically-detect-ready-composed-resources","functionRef":{"name":"function-auto-ready"}}]}} +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied.yaml b/internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied.yaml new file mode 100644 index 00000000..f13c7064 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/original-composition-kubectl-applied.yaml @@ -0,0 +1,40 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org + annotations: + # Content-identical to original-composition.yaml apart from this annotation, which is what a + # client-side `kubectl apply` stamps on the cluster copy. The file a user diffs never carries it, + # so this is the asymmetry the tool must not mistake for an edit. The value below is abridged — + # only its presence matters, never its content. + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition","metadata":{"name":"xnopresources.diff.example.org"},"spec":{"compositeTypeRef":{"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource"},"mode":"Pipeline"}} +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/original-composition.yaml b/internal/diff/integration/testdata/comp/resources/original-composition.yaml new file mode 100644 index 00000000..0a583699 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/original-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/resources/removal-test-original-composition.yaml b/internal/diff/integration/testdata/comp/resources/removal-test-original-composition.yaml new file mode 100644 index 00000000..1c07815c --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/removal-test-original-composition.yaml @@ -0,0 +1,45 @@ +# The in-cluster version of removal-test-composition, which still renders resource2. Diffing +# ../../diff/resources/removal-test-composition.yaml, which no longer does, against it removes resource2. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-removed + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource2 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/revision-templating-composition-older-revision.yaml b/internal/diff/integration/testdata/comp/resources/revision-templating-composition-older-revision.yaml new file mode 100644 index 00000000..a6a9dff3 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/revision-templating-composition-older-revision.yaml @@ -0,0 +1,40 @@ +# An earlier revision of revision-templating-composition.yaml, from when the composition carried a +# channel=stable label it has since dropped: same spec, so only its name differs from revision 2's. +# The harness names and labels it from the hash of the composition it was cut from, as Crossplane does. +# The Manual XR in existing-xr-pinned-revision-ref.yaml is pinned to it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + channel: "stable" + crossplane.io/composition-name: xrevisionrefs.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/revision-templating-composition-revision.yaml b/internal/diff/integration/testdata/comp/resources/revision-templating-composition-revision.yaml new file mode 100644 index 00000000..b3dd5157 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/revision-templating-composition-revision.yaml @@ -0,0 +1,39 @@ +# The CompositionRevision Crossplane creates for revision-templating-composition.yaml, declared rather +# than left to the harness because it is revision 2, the latest: +# revision-templating-composition-older-revision.yaml is revision 1. The Automatic XR in +# existing-xr-revision-ref.yaml tracks it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xrevisionrefs.diff.example.org +spec: + revision: 2 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/revision-templating-composition.yaml b/internal/diff/integration/testdata/comp/resources/revision-templating-composition.yaml new file mode 100644 index 00000000..58423fcd --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/revision-templating-composition.yaml @@ -0,0 +1,39 @@ +# The in-cluster copy of a composition whose template reads the CompositionRevision's own name off the +# composite and propagates it into a composed resource. Functions receive the whole XR (AsState -> +# xfn.AsStruct, no field stripping), so this is legitimate, if unusual — and it is the case that makes +# "a revision is created, but it renders identically" a claim the tool has to verify rather than assume. +# +# Paired with ../revision-templating-updated-composition.yaml, which differs from this by a label only. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefs.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/sha256-composition.yaml b/internal/diff/integration/testdata/comp/resources/sha256-composition.yaml new file mode 100644 index 00000000..e08a7433 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/sha256-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-sha256.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating-sha256 + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/status-downstream-1.yaml b/internal/diff/integration/testdata/comp/resources/status-downstream-1.yaml new file mode 100644 index 00000000..a84d5a67 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/status-downstream-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: status-test-xr-1 + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stable-value + resourceTier: standard diff --git a/internal/diff/integration/testdata/comp/resources/status-downstream-2.yaml b/internal/diff/integration/testdata/comp/resources/status-downstream-2.yaml new file mode 100644 index 00000000..1fee2a23 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/status-downstream-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: status-test-xr-2 + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stable-value + resourceTier: standard diff --git a/internal/diff/integration/testdata/comp/resources/status-indicator-composition.yaml b/internal/diff/integration/testdata/comp/resources/status-indicator-composition.yaml new file mode 100644 index 00000000..08fea7b5 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/status-indicator-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xstatus.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-value + resourceTier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/status-xr-1.yaml b/internal/diff/integration/testdata/comp/resources/status-xr-1.yaml new file mode 100644 index 00000000..d22c27e2 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/status-xr-1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: status-test-xr-1 + namespace: default +spec: + coolField: test-value-1 + crossplane: + compositionRef: + name: xstatus.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/status-xr-2.yaml b/internal/diff/integration/testdata/comp/resources/status-xr-2.yaml new file mode 100644 index 00000000..2ef34578 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/status-xr-2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: status-test-xr-2 + namespace: default +spec: + coolField: test-value-2 + crossplane: + compositionRef: + name: xstatus.diff.example.org diff --git a/internal/diff/integration/testdata/comp/resources/test-namespace.yaml b/internal/diff/integration/testdata/comp/resources/test-namespace.yaml new file mode 100644 index 00000000..f8db044d --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/test-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace diff --git a/internal/diff/integration/testdata/comp/resources/unverified-cluster-composition.yaml b/internal/diff/integration/testdata/comp/resources/unverified-cluster-composition.yaml new file mode 100644 index 00000000..5c757c43 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/unverified-cluster-composition.yaml @@ -0,0 +1,15 @@ +# The in-cluster version of updated-unverified-cluster-composition.yaml: a composition for the +# cluster-scoped XNopResource that composes nothing yet. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-unverified.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/resources/xrd.yaml b/internal/diff/integration/testdata/comp/resources/xrd.yaml new file mode 100644 index 00000000..b4b57069 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/xrd.yaml @@ -0,0 +1,51 @@ +# This is a v2 XRD so instances of the XR will be namespace scoped. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/comp/revision-templating-updated-composition.yaml b/internal/diff/integration/testdata/comp/revision-templating-updated-composition.yaml new file mode 100644 index 00000000..263ad44c --- /dev/null +++ b/internal/diff/integration/testdata/comp/revision-templating-updated-composition.yaml @@ -0,0 +1,43 @@ +# A metadata-only edit of resources/revision-templating-composition.yaml: one added label, identical +# spec. Crossplane's Composition.Hash() covers labels, so applying this mints a new CompositionRevision +# with a byte-identical spec — and because the template below reads the revision's own name, the +# composed resource genuinely changes. +# +# Before issue #474 the tool rendered this composite with its *existing* compositionRevisionRef, saw no +# downstream delta, and exited 0. The label is the only difference; everything else must stay identical +# to the cluster copy or the test stops exercising the metadata-only path. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefs.diff.example.org + labels: + channel: preview +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/status-indicator-updated-composition.yaml b/internal/diff/integration/testdata/comp/status-indicator-updated-composition.yaml new file mode 100644 index 00000000..a6a6a6a7 --- /dev/null +++ b/internal/diff/integration/testdata/comp/status-indicator-updated-composition.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xstatus.diff.example.org + labels: + environment: production +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-value + resourceTier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-claim-composition.yaml b/internal/diff/integration/testdata/comp/updated-claim-composition.yaml new file mode 100644 index 00000000..f619a369 --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-claim-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: nopclaims.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-collision-composition.yaml b/internal/diff/integration/testdata/comp/updated-collision-composition.yaml new file mode 100644 index 00000000..88a356ef --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-collision-composition.yaml @@ -0,0 +1,50 @@ +# Updated composition - adds a prefix to trigger a diff +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.collision.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Fetch ConfigMap by name - uses XR's namespace since no namespace specified + - apiVersion: v1 + kind: ConfigMap + into: nsConfig + type: Reference + ref: + name: collision-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $config := index .context "apiextensions.crossplane.io/extra-resources" "nsConfig" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: collision-resource + spec: + forProvider: + # CHANGED: Added prefix to configData + configData: prefix-{{ index $config "data" "value" }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-composition-2-changed.yaml b/internal/diff/integration/testdata/comp/updated-composition-2-changed.yaml new file mode 100644 index 00000000..83dfeec3 --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-composition-2-changed.yaml @@ -0,0 +1,36 @@ +# A genuinely-changed version of original-composition-2.yaml (configData prefix and resourceTier +# differ). Distinct from updated-composition-2.yaml, which is byte-identical to the original and is +# used for the mixed changed/unchanged case. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-v2.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource-v2 + spec: + forProvider: + configData: v2-changed-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium-plus + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-composition-2.yaml b/internal/diff/integration/testdata/comp/updated-composition-2.yaml new file mode 100644 index 00000000..a6605cec --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-composition-2.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-v2.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource-v2 + spec: + forProvider: + configData: v2-updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: enterprise + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/updated-composition-labeled.yaml b/internal/diff/integration/testdata/comp/updated-composition-labeled.yaml new file mode 100644 index 00000000..6768f115 --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-composition-labeled.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org + labels: + version: 0.0.2 +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-composition.yaml b/internal/diff/integration/testdata/comp/updated-composition.yaml new file mode 100644 index 00000000..e3e72edb --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/comp/updated-conditional-gating-composition.yaml b/internal/diff/integration/testdata/comp/updated-conditional-gating-composition.yaml new file mode 100644 index 00000000..6d5a3ed9 --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-conditional-gating-composition.yaml @@ -0,0 +1,57 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: conditional-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-cycle-composition.yaml b/internal/diff/integration/testdata/comp/updated-cycle-composition.yaml new file mode 100644 index 00000000..a1807a2d --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-cycle-composition.yaml @@ -0,0 +1,35 @@ +# testdata/diff/resources/cycle/compositions.yaml's XCycleA composition with a spec change (a label +# on the composed XCycleB), so comp evaluates its composites at every --analyze-on setting. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xcycleas.cycle.example.org +spec: + compositeTypeRef: + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleA + mode: Pipeline + pipeline: + - step: generate-cycle-b + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleB + metadata: + name: {{ .observed.composite.resource.metadata.name }}-c + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cycle-b + labels: + cycle.example.org/edited: "true" + spec: + cycleField: {{ .observed.composite.resource.spec.cycleField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-parent-composition.yaml b/internal/diff/integration/testdata/comp/updated-parent-composition.yaml new file mode 100644 index 00000000..b4e7093b --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-parent-composition.yaml @@ -0,0 +1,50 @@ +# Updated parent composition - changes the direct downstream resource tier from parent-tier to premium-tier. +# The nested XNopResource and its downstream should NOT be affected because: +# 1. The nested XR uses its own composition (xnopresources.diff.example.org) +# 2. That composition is NOT being diffed here +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xparentresources.parent.diff.example.org +spec: + compositeTypeRef: + apiVersion: parent.diff.example.org/v1alpha1 + kind: XParentResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + # Direct downstream resource owned by the parent XR - CHANGED tier + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-direct + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: direct-resource + spec: + forProvider: + configData: parent-{{ .observed.composite.resource.spec.parentField }} + resourceTier: premium-tier + --- + # Nested XR of type XNopResource - unchanged + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-nested + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nested-xr + spec: + coolField: {{ .observed.composite.resource.spec.childConfig }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-sequencer-gating-composition.yaml b/internal/diff/integration/testdata/comp/updated-sequencer-gating-composition.yaml new file mode 100644 index 00000000..603c3ec0 --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-sequencer-gating-composition.yaml @@ -0,0 +1,53 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- + - ^stage1- diff --git a/internal/diff/integration/testdata/comp/updated-sha256-composition.yaml b/internal/diff/integration/testdata/comp/updated-sha256-composition.yaml new file mode 100644 index 00000000..de89c21e --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-sha256-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-sha256.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating-sha256 + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/comp/updated-unverified-cluster-composition.yaml b/internal/diff/integration/testdata/comp/updated-unverified-cluster-composition.yaml new file mode 100644 index 00000000..0f80e57d --- /dev/null +++ b/internal/diff/integration/testdata/comp/updated-unverified-cluster-composition.yaml @@ -0,0 +1,47 @@ +# Adds two ConfigMaps in a namespace that does not exist in the cluster. +# +# A cluster-scoped XR may compose resources in any namespace (a namespaced one is confined to its +# own), which is what lets `comp` reach a missing namespace from an XR that already exists. Both +# additions' dry-run creates are refused for want of the namespace, so both are diffed unverified, +# and the warning saying so counts them. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-unverified.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ .observed.composite.resource.metadata.name }}-first + namespace: nonexistent-namespace + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: first-config + data: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ .observed.composite.resource.metadata.name }}-second + namespace: nonexistent-namespace + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: second-config + data: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-1.yaml b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-1.yaml new file mode 100644 index 00000000..c5efc39d --- /dev/null +++ b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-1.yaml @@ -0,0 +1,8 @@ +apiVersion: concurrent.diff.example.org/v1alpha1 +kind: XConcurrentTest +metadata: + name: concurrent-test-1 + namespace: default +spec: + config: "test-config-1" + dataField: "data-1" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-2.yaml b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-2.yaml new file mode 100644 index 00000000..c7cabbfa --- /dev/null +++ b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-2.yaml @@ -0,0 +1,8 @@ +apiVersion: concurrent.diff.example.org/v1alpha1 +kind: XConcurrentTest +metadata: + name: concurrent-test-2 + namespace: default +spec: + config: "test-config-2" + dataField: "data-2" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-3.yaml b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-3.yaml new file mode 100644 index 00000000..a09ea742 --- /dev/null +++ b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-3.yaml @@ -0,0 +1,8 @@ +apiVersion: concurrent.diff.example.org/v1alpha1 +kind: XConcurrentTest +metadata: + name: concurrent-test-3 + namespace: default +spec: + config: "test-config-3" + dataField: "data-3" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-4.yaml b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-4.yaml new file mode 100644 index 00000000..6be5aaaf --- /dev/null +++ b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-4.yaml @@ -0,0 +1,8 @@ +apiVersion: concurrent.diff.example.org/v1alpha1 +kind: XConcurrentTest +metadata: + name: concurrent-test-4 + namespace: default +spec: + config: "test-config-4" + dataField: "data-4" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-5.yaml b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-5.yaml new file mode 100644 index 00000000..1a2669c7 --- /dev/null +++ b/internal/diff/integration/testdata/diff/concurrent-xrs/concurrent-xr-5.yaml @@ -0,0 +1,8 @@ +apiVersion: concurrent.diff.example.org/v1alpha1 +kind: XConcurrentTest +metadata: + name: concurrent-test-5 + namespace: default +spec: + config: "test-config-5" + dataField: "data-5" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml b/internal/diff/integration/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml new file mode 100644 index 00000000..1d9370bb --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml @@ -0,0 +1,61 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusternopresources.nop.crossplane.io +spec: + group: nop.crossplane.io + names: + kind: ClusterNopResource + listKind: ClusterNopResourceList + plural: clusternopresources + singular: clusternopresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + conditionAfter: + type: array + items: + type: object + properties: + conditionType: + type: string + conditionStatus: + type: string + time: + type: string + status: + type: object + properties: + atProvider: + type: object + conditions: + type: array + items: + type: object + properties: + lastTransitionTime: + type: string + format: date-time + observedGeneration: + type: integer + format: int64 + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/crds/concurrent-additional-resource.yaml b/internal/diff/integration/testdata/diff/crds/concurrent-additional-resource.yaml new file mode 100644 index 00000000..82fcf071 --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/concurrent-additional-resource.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xadditionalresources.concurrent.nop.example.org +spec: + group: concurrent.nop.example.org + names: + kind: XAdditionalResource + plural: xadditionalresources + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + forProvider: + properties: + index: + type: integer + config: + type: string + type: object + type: object + type: object + served: true + storage: true \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/crds/concurrent-base-resource.yaml b/internal/diff/integration/testdata/diff/crds/concurrent-base-resource.yaml new file mode 100644 index 00000000..9a0d7a89 --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/concurrent-base-resource.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xbaseresources.concurrent.nop.example.org +spec: + group: concurrent.nop.example.org + names: + kind: XBaseResource + plural: xbaseresources + scope: Namespaced + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + properties: + forProvider: + properties: + index: + type: integer + config: + type: string + type: object + type: object + type: object + served: true + storage: true \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/crds/xapimigrateresource-crd.yaml b/internal/diff/integration/testdata/diff/crds/xapimigrateresource-crd.yaml new file mode 100644 index 00000000..3005cf3c --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xapimigrateresource-crd.yaml @@ -0,0 +1,84 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xapimigrateresources.diff.example.org +spec: + group: diff.example.org + names: + kind: XApiMigrateResource + listKind: XApiMigrateResourceList + plural: xapimigrateresources + singular: xapimigrateresource + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: false + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + description: "Configuration data for the resource" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + lastTransitionTime: + type: string + reason: + type: string + message: + type: string + - name: v1beta2 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + description: "Configuration data for the resource" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + lastTransitionTime: + type: string + reason: + type: string + message: + type: string diff --git a/internal/diff/integration/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml b/internal/diff/integration/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..78cbb0ed --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdefaulteddownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XDefaultedDownstreamResource + listKind: XDefaultedDownstreamResourceList + plural: xdefaulteddownstreamresources + singular: xdefaulteddownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + size: + type: string + default: "small" + description: "Defaulted by the CRD; the composition never sets it" diff --git a/internal/diff/integration/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml b/internal/diff/integration/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml new file mode 100644 index 00000000..d26f917d --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml b/internal/diff/integration/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml new file mode 100644 index 00000000..49c1db14 --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.legacycluster.nop.example.org +spec: + group: legacycluster.nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/diff/crds/xdownstreamresource-ns-crd.yaml b/internal/diff/integration/testdata/diff/crds/xdownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..1d5e07ff --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xdownstreamresource-ns-crd.yaml @@ -0,0 +1,59 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + removableField: + type: string + description: "A field that can be removed for testing field removal detection" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/diff/crds/xstatusresource-ns-crd.yaml b/internal/diff/integration/testdata/diff/crds/xstatusresource-ns-crd.yaml new file mode 100644 index 00000000..b8ddfe4f --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xstatusresource-ns-crd.yaml @@ -0,0 +1,66 @@ +# A composed-resource kind that declares the status SUBRESOURCE. +# +# That declaration is the entire reason this CRD exists, and it is load-bearing rather than +# incidental: with `subresources: {status: {}}` the apiserver strips `status` from a CREATE request +# and hands back an object with no status at all. Every other CRD under testdata/diff/crds omits the +# subresource, so status is simply persisted on create there — which would make a status-retention +# assertion pass whether or not the diff calculator re-attached the rendered status, i.e. prove +# nothing. +# +# Used by resources/composition-with-status.yaml to check that a status authored by a composition +# pipeline survives the apiserver round-trip for an addition (crossplane-diff#334). +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xstatusresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XStatusResource + listKind: XStatusResourceList + plural: xstatusresources + singular: xstatusresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + status: + type: object + properties: + phase: + type: string + description: "A status value written by the composition pipeline, not by the apiserver" + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/internal/diff/integration/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml b/internal/diff/integration/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..16873804 --- /dev/null +++ b/internal/diff/integration/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml @@ -0,0 +1,52 @@ +# A CRD that serves two versions and stores the newer one, which defaults a field differently. A +# resource requested at v1alpha1 and predicted locally is defaulted against v1alpha1 only, so its +# unverified-addition warning must name the storage version (#528). +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xversioneddownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XVersionedDownstreamResource + listKind: XVersionedDownstreamResourceList + plural: xversioneddownstreamresources + singular: xversioneddownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: false + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + size: + type: string + default: "small" + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + size: + type: string + default: "medium" diff --git a/internal/diff/integration/testdata/diff/existing-xr-revision-ref-omitted.yaml b/internal/diff/integration/testdata/diff/existing-xr-revision-ref-omitted.yaml new file mode 100644 index 00000000..fa56bf15 --- /dev/null +++ b/internal/diff/integration/testdata/diff/existing-xr-revision-ref-omitted.yaml @@ -0,0 +1,16 @@ +# testdata/comp/resources/existing-xr-revision-ref.yaml exactly as a user would write it: unchanged, +# but without the compositionRef and compositionRevisionRef that Crossplane's composite reconciler +# wrote into the cluster copy. Applying this leaves those refs where they are, so nothing would change — +# and the composition this composite uses (testdata/comp/resources/revision-templating-composition.yaml) +# reads the revision name into a composed resource. Only the inherited compositionRef resolves the +# revision whose name render must see, so a render that drops the cluster's refs shows a spurious +# change. See issue #499. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: revision-ref-resource + namespace: default +spec: + coolField: revision-ref-existing-value + crossplane: + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/first-xr.yaml b/internal/diff/integration/testdata/diff/first-xr.yaml new file mode 100644 index 00000000..cc6c2fe4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/first-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: first-resource + namespace: default +spec: + coolField: "first-value" diff --git a/internal/diff/integration/testdata/diff/generated-name-xr.yaml b/internal/diff/integration/testdata/diff/generated-name-xr.yaml new file mode 100644 index 00000000..0be857b8 --- /dev/null +++ b/internal/diff/integration/testdata/diff/generated-name-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + generateName: generated-xr- + namespace: default +spec: + coolField: "new-value" diff --git a/internal/diff/integration/testdata/diff/invalid-schema-xr.yaml b/internal/diff/integration/testdata/diff/invalid-schema-xr.yaml new file mode 100644 index 00000000..785a6207 --- /dev/null +++ b/internal/diff/integration/testdata/diff/invalid-schema-xr.yaml @@ -0,0 +1,8 @@ +# XR with invalid schema - coolField should be a string, not an integer +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: invalid-schema-xr + namespace: default +spec: + coolField: 12345 diff --git a/internal/diff/integration/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml b/internal/diff/integration/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml new file mode 100644 index 00000000..49be07d7 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version-xr + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionRevisionRef: + name: xapimigrateresources.example.org-311ee4f + compositionUpdatePolicy: Manual + coolField: changed-value diff --git a/internal/diff/integration/testdata/diff/modified-api-version-xr-rev2.yaml b/internal/diff/integration/testdata/diff/modified-api-version-xr-rev2.yaml new file mode 100644 index 00000000..0baf92f0 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-api-version-xr-rev2.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version-xr + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionRevisionRef: + name: xapimigrateresources.example.org-311ee4f + compositionUpdatePolicy: Manual + coolField: test-value diff --git a/internal/diff/integration/testdata/diff/modified-claim-nested.yaml b/internal/diff/integration/testdata/diff/modified-claim-nested.yaml new file mode 100644 index 00000000..67844ef1 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-claim-nested.yaml @@ -0,0 +1,12 @@ +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: ParentNopClaim +metadata: + name: existing-parent-claim + namespace: default + labels: + new-label: added-value +spec: + parentField: modified-parent-value + compositeDeletePolicy: Background + compositionRef: + name: parent-nop-claim-composition diff --git a/internal/diff/integration/testdata/diff/modified-claim-omitting-defaulted-fields.yaml b/internal/diff/integration/testdata/diff/modified-claim-omitting-defaulted-fields.yaml new file mode 100644 index 00000000..3c6645d8 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-claim-omitting-defaulted-fields.yaml @@ -0,0 +1,11 @@ +# modified-claim.yaml without spec.compositeDeletePolicy, which the claim's CRD defaults (as it does +# spec.compositionUpdatePolicy, also omitted). +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + coolField: modified-value + compositionRef: + name: claim-composition diff --git a/internal/diff/integration/testdata/diff/modified-claim.yaml b/internal/diff/integration/testdata/diff/modified-claim.yaml new file mode 100644 index 00000000..3116b173 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-claim.yaml @@ -0,0 +1,10 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Background + coolField: modified-value + compositionRef: + name: claim-composition diff --git a/internal/diff/integration/testdata/diff/modified-cluster-xr.yaml b/internal/diff/integration/testdata/diff/modified-cluster-xr.yaml new file mode 100644 index 00000000..dcbaf051 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-cluster-xr.yaml @@ -0,0 +1,6 @@ +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + coolField: "modified-value" diff --git a/internal/diff/integration/testdata/diff/modified-cross-ns-xr.yaml b/internal/diff/integration/testdata/diff/modified-cross-ns-xr.yaml new file mode 100644 index 00000000..78068e4d --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-cross-ns-xr.yaml @@ -0,0 +1,8 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-cross-ns-resource + namespace: default +spec: + coolField: modified-cross-ns-value + environment: production \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/modified-env-xr.yaml b/internal/diff/integration/testdata/diff/modified-env-xr.yaml new file mode 100644 index 00000000..3664be56 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-env-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XEnvResource +metadata: + name: test-env-resource +spec: + compositionUpdatePolicy: Automatic + configKey: "modified-config-value" diff --git a/internal/diff/integration/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml b/internal/diff/integration/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 00000000..92f64376 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml @@ -0,0 +1,11 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-legacy-manual-v1 +spec: + compositionRef: + name: xlegacynopresources.diff.example.org + compositionRevisionRef: + name: xlegacynopresources.diff.example.org-97a39c0 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml b/internal/diff/integration/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml new file mode 100644 index 00000000..12f18008 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml @@ -0,0 +1,12 @@ +# The v1-path (spec.compositionRevisionRef) twin of modified-xr-manual-v1-omits-revision-ref.yaml: +# resources/existing-legacy-xr-manual-v1.yaml with coolField modified and compositionRevisionRef left +# out, so it must stay pinned to the -2f488b6 revision (the v1 template). See issue #499. +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-legacy-manual-v1 +spec: + compositionRef: + name: xlegacynopresources.diff.example.org + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-legacy-xr.yaml b/internal/diff/integration/testdata/diff/modified-legacy-xr.yaml new file mode 100644 index 00000000..47df1975 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-legacy-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + compositionUpdatePolicy: Automatic + coolField: "modified-value" diff --git a/internal/diff/integration/testdata/diff/modified-manual-pinned-claim.yaml b/internal/diff/integration/testdata/diff/modified-manual-pinned-claim.yaml new file mode 100644 index 00000000..be846bd7 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-manual-pinned-claim.yaml @@ -0,0 +1,12 @@ +# resources/pinned-claim/existing-manual.yaml's claim as its author keeps it, with coolField changed. +# Like the cluster copy it names no revision: the pin is the backing XR's. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: manual-claim + namespace: existing-namespace +spec: + coolField: modified-value + compositionUpdatePolicy: Manual + compositionRef: + name: xpinneds.pinned.diff.example.org diff --git a/internal/diff/integration/testdata/diff/modified-nested-xr.yaml b/internal/diff/integration/testdata/diff/modified-nested-xr.yaml new file mode 100644 index 00000000..842e568b --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-nested-xr.yaml @@ -0,0 +1,8 @@ +# A modified parent XR - value changed from "existing-value" to "modified-value" +apiVersion: ns.nested.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: "modified-value" diff --git a/internal/diff/integration/testdata/diff/modified-xr-automatic.yaml b/internal/diff/integration/testdata/diff/modified-xr-automatic.yaml new file mode 100644 index 00000000..b0e59196 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-automatic.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-automatic + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml b/internal/diff/integration/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 00000000..bb285ab2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-430f3f3 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml b/internal/diff/integration/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml new file mode 100644 index 00000000..7216d4f3 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml @@ -0,0 +1,16 @@ +# resources/existing-xr-manual-v1.yaml with coolField modified, and with compositionRevisionRef left +# out — as a hand-written manifest would, since Crossplane is what writes that field. An apply that +# omits a field does not remove it, so the composite stays pinned to the revision the cluster copy +# names (-911318d, the v1 template). Rendering against the latest revision instead would show the v2 +# template's output for a change that cannot cause it. See issue #499. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-manual-v1.yaml b/internal/diff/integration/testdata/diff/modified-xr-manual-v1.yaml new file mode 100644 index 00000000..9ed3f2cd --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-manual-v1.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-911318d + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-selector-bound.yaml b/internal/diff/integration/testdata/diff/modified-xr-selector-bound.yaml new file mode 100644 index 00000000..e2e6c3a2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-selector-bound.yaml @@ -0,0 +1,13 @@ +# resources/selector-bound/setup.yaml's composite as its author keeps it, with coolField changed. It +# selects its composition by label and leaves compositionRef to Crossplane, which already set it. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-bound + namespace: default +spec: + coolField: modified-value + crossplane: + compositionSelector: + matchLabels: + binding: shared diff --git a/internal/diff/integration/testdata/diff/modified-xr-selector-nomatch.yaml b/internal/diff/integration/testdata/diff/modified-xr-selector-nomatch.yaml new file mode 100644 index 00000000..0f45934e --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-selector-nomatch.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-selector-nomatch + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + channel: nonexistent + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-selector-stable.yaml b/internal/diff/integration/testdata/diff/modified-xr-selector-stable.yaml new file mode 100644 index 00000000..b4dfec12 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-selector-stable.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-selector + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + channel: stable + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-switch-to-automatic.yaml b/internal/diff/integration/testdata/diff/modified-xr-switch-to-automatic.yaml new file mode 100644 index 00000000..8d721546 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-switch-to-automatic.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/modified-xr-with-external-dep.yaml b/internal/diff/integration/testdata/diff/modified-xr-with-external-dep.yaml new file mode 100644 index 00000000..eba2a6e3 --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr-with-external-dep.yaml @@ -0,0 +1,8 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: modified-with-external-dep + environment: testing diff --git a/internal/diff/integration/testdata/diff/modified-xr.yaml b/internal/diff/integration/testdata/diff/modified-xr.yaml new file mode 100644 index 00000000..8cef439a --- /dev/null +++ b/internal/diff/integration/testdata/diff/modified-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "modified-value" diff --git a/internal/diff/integration/testdata/diff/new-claim-with-claimref-composition.yaml b/internal/diff/integration/testdata/diff/new-claim-with-claimref-composition.yaml new file mode 100644 index 00000000..bd63e462 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-claim-with-claimref-composition.yaml @@ -0,0 +1,10 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Background + compositionRef: + name: claim-composition-with-claimref + coolField: new-value diff --git a/internal/diff/integration/testdata/diff/new-claim.yaml b/internal/diff/integration/testdata/diff/new-claim.yaml new file mode 100644 index 00000000..6d153fee --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-claim.yaml @@ -0,0 +1,11 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Background + compositionRef: + name: claim-composition + coolField: new-value + diff --git a/internal/diff/integration/testdata/diff/new-cycle-xr.yaml b/internal/diff/integration/testdata/diff/new-cycle-xr.yaml new file mode 100644 index 00000000..45f9b375 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-cycle-xr.yaml @@ -0,0 +1,8 @@ +# A new XR whose composition starts the XCycleA -> XCycleB -> XCycleA cycle. +apiVersion: ns.cycle.example.org/v1alpha1 +kind: XCycleA +metadata: + name: test-cycle + namespace: default +spec: + cycleField: "cycle-value" diff --git a/internal/diff/integration/testdata/diff/new-manual-default-xr.yaml b/internal/diff/integration/testdata/diff/new-manual-default-xr.yaml new file mode 100644 index 00000000..89fbe026 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-manual-default-xr.yaml @@ -0,0 +1,15 @@ +# A new composite pinned to revision 1, the older of its composition's two revisions, that leaves its +# update policy to its XRD's default (Manual). Under Manual, Crossplane renders a composite against the +# revision it names, so this one renders revision 1; under Automatic it would render the latest. +apiVersion: manualdefault.diff.example.org/v1alpha1 +kind: XManualDefault +metadata: + name: new-manual-default + namespace: default +spec: + coolField: new-value + crossplane: + compositionRef: + name: xmanualdefaults.manualdefault.diff.example.org + compositionRevisionRef: + name: xmanualdefaults.manualdefault.diff.example.org-373e832 diff --git a/internal/diff/integration/testdata/diff/new-nested-child-xr.yaml b/internal/diff/integration/testdata/diff/new-nested-child-xr.yaml new file mode 100644 index 00000000..37a82217 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-nested-child-xr.yaml @@ -0,0 +1,9 @@ +# The child XR that new-nested-xr.yaml's composition renders (named -child). Passed alongside +# that parent it is an input error: the parent's composition writes it, so this would be a second writer. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + namespace: default +spec: + childField: "parent-value" diff --git a/internal/diff/integration/testdata/diff/new-nested-xr.yaml b/internal/diff/integration/testdata/diff/new-nested-xr.yaml new file mode 100644 index 00000000..07ea96d7 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-nested-xr.yaml @@ -0,0 +1,8 @@ +# A new parent XR that will create a child XR, which will then create managed resources +apiVersion: ns.nested.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: "parent-value" diff --git a/internal/diff/integration/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml b/internal/diff/integration/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml new file mode 100644 index 00000000..143783ac --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml @@ -0,0 +1,11 @@ +# A new claim that omits every field a CRD defaults: spec.tier (defaulted by both the claim's and the +# XR's CRD), spec.compositeDeletePolicy (claim CRD only) and spec.compositionUpdatePolicy (XR CRD only). +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: ParentNopClaim +metadata: + name: new-parent-claim + namespace: default +spec: + parentField: new-value + compositionRef: + name: parent-nop-claim-defaults-composition diff --git a/internal/diff/integration/testdata/diff/new-rendered-defaults-xrs.yaml b/internal/diff/integration/testdata/diff/new-rendered-defaults-xrs.yaml new file mode 100644 index 00000000..b11d2e92 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-rendered-defaults-xrs.yaml @@ -0,0 +1,16 @@ +# New composites that set only size, leaving region and compositionUpdatePolicy to their CRDs' +# defaults; see resources/rendered-defaults/definitions.yaml. +apiVersion: rendered-defaults.diff.example.org/v1alpha1 +kind: XRegionalDatabase +metadata: + name: modern-database + namespace: default +spec: + size: large +--- +apiVersion: rendered-defaults.diff.example.org/v1alpha1 +kind: XLegacyRegionalDatabase +metadata: + name: legacy-database +spec: + size: small diff --git a/internal/diff/integration/testdata/diff/new-xr-manual-no-ref.yaml b/internal/diff/integration/testdata/diff/new-xr-manual-no-ref.yaml new file mode 100644 index 00000000..21557827 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr-manual-no-ref.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-no-ref + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Manual + coolField: new-value diff --git a/internal/diff/integration/testdata/diff/new-xr-missing-namespace-second.yaml b/internal/diff/integration/testdata/diff/new-xr-missing-namespace-second.yaml new file mode 100644 index 00000000..e5abd110 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr-missing-namespace-second.yaml @@ -0,0 +1,10 @@ +# A second XR targeting the same namespace as new-xr-missing-namespace.yaml, which does not exist in +# the cluster. Diffed together, the two give the unverified-addition warning more than one resource +# to count. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: second-resource + namespace: nonexistent-namespace +spec: + coolField: "second-value" diff --git a/internal/diff/integration/testdata/diff/new-xr-missing-namespace.yaml b/internal/diff/integration/testdata/diff/new-xr-missing-namespace.yaml new file mode 100644 index 00000000..03399959 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr-missing-namespace.yaml @@ -0,0 +1,13 @@ +# An XR targeting a namespace that does not exist in the cluster. +# +# This is a supported workflow, not a mistake: a Namespace and the resources inside it are routinely +# applied together (one `kubectl apply -f ./manifests/`), so a user previewing that apply legitimately +# has resources whose namespace is not there yet. The dry-run create cannot be performed, so the +# resources degrade to rendered output and say so — they must NOT fail the run. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: nonexistent-namespace +spec: + coolField: "new-value" diff --git a/internal/diff/integration/testdata/diff/new-xr-second.yaml b/internal/diff/integration/testdata/diff/new-xr-second.yaml new file mode 100644 index 00000000..b31482df --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr-second.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: second-resource + namespace: default +spec: + coolField: "second-value" diff --git a/internal/diff/integration/testdata/diff/new-xr-with-creds.yaml b/internal/diff/integration/testdata/diff/new-xr-with-creds.yaml new file mode 100644 index 00000000..07c0fa53 --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr-with-creds.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource-with-creds + namespace: default +spec: + coolField: test-value-creds + crossplane: + compositionRef: + name: xnopresources-with-creds.diff.example.org diff --git a/internal/diff/integration/testdata/diff/new-xr-with-cross-ns-external-dep.yaml b/internal/diff/integration/testdata/diff/new-xr-with-cross-ns-external-dep.yaml new file mode 100644 index 00000000..d1997d5c --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr-with-cross-ns-external-dep.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "new-value" + # Matches the ConfigMap in "other-namespace" (labelled environment: crossns). + # The go-templating matchLabels ExtraResources selector omits the namespace, + # so resolving it requires an all-namespaces lookup (issue #376). + environment: crossns diff --git a/internal/diff/integration/testdata/diff/new-xr.yaml b/internal/diff/integration/testdata/diff/new-xr.yaml new file mode 100644 index 00000000..57dd706b --- /dev/null +++ b/internal/diff/integration/testdata/diff/new-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "new-value" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/admission-policy-canary.yaml b/internal/diff/integration/testdata/diff/resources/admission-policy-canary.yaml new file mode 100644 index 00000000..2130c04e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/admission-policy-canary.yaml @@ -0,0 +1,15 @@ +# A resource that rejecting-admission-policy.yaml must refuse. +# +# The harness dry-run creates this (never persisting it) until the apiserver rejects it, which is how +# a test learns that the policy is actually enforcing. Admission policies reach the apiserver's +# admission plugin through an informer, so for a short window after creation the cluster still +# accepts what the policy will later refuse; a diff issued inside that window would observe no +# rejection at all. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: admission-policy-canary + namespace: default +spec: + forProvider: + configData: new-value diff --git a/internal/diff/integration/testdata/diff/resources/api-version-composition-revision-v1.yaml b/internal/diff/integration/testdata/diff/resources/api-version-composition-revision-v1.yaml new file mode 100644 index 00000000..887eb013 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/api-version-composition-revision-v1.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xapimigrateresources.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: diff.example.org/v1beta1 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/api-version-composition.yaml b/internal/diff/integration/testdata/diff/resources/api-version-composition.yaml new file mode 100644 index 00000000..defbc52a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/api-version-composition.yaml @@ -0,0 +1,34 @@ +# The in-cluster xapimigrateresources.example.org, which renders the resource at v1beta2. The harness +# creates its latest revision from it; api-version-composition-revision-v1.yaml is the earlier one. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapimigrateresources.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: diff.example.org/v1beta2 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/claim-composition-with-claimref.yaml b/internal/diff/integration/testdata/diff/resources/claim-composition-with-claimref.yaml new file mode 100644 index 00000000..c468c446 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-composition-with-claimref.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: claim-composition-with-claimref +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + {{- if .observed.composite.resource.spec.claimRef }} + configData: {{ .observed.composite.resource.spec.claimRef.namespace }}/{{ .observed.composite.resource.spec.claimRef.name }} + {{- else }} + configData: {{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/claim-composition.yaml b/internal/diff/integration/testdata/diff/resources/claim-composition.yaml new file mode 100644 index 00000000..f07fb97e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: claim-composition +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/child-composition.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/child-composition.yaml new file mode 100644 index 00000000..824162c4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/child-composition.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: child-nop-claim-composition +spec: + compositeTypeRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopChild + mode: Pipeline + pipeline: + - step: create-nop-resource + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.crossplane.io/v1alpha1 + kind: ClusterNopResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + child-field: {{ .observed.composite.resource.spec.childField }} + spec: + forProvider: + conditionAfter: + - conditionType: Ready + conditionStatus: "True" + time: 0s + - step: auto-ready + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/child-definition.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/child-definition.yaml new file mode 100644 index 00000000..cc63922d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/child-definition.yaml @@ -0,0 +1,29 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopchildren.claimnested.diff.example.org +spec: + group: claimnested.diff.example.org + names: + kind: XNopChild + plural: xnopchildren + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + childField: + type: string + required: + - childField + status: + type: object + properties: + message: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/existing-child-xr.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-child-xr.yaml new file mode 100644 index 00000000..4b39acb4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-child-xr.yaml @@ -0,0 +1,11 @@ +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: XNopChild +metadata: + name: existing-parent-claim-82crv-child + generateName: existing-parent-claim-82crv- + annotations: + crossplane.io/composition-resource-name: child-xr +spec: + childField: existing-parent-value + compositionRef: + name: child-nop-claim-composition diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/existing-claim.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-claim.yaml new file mode 100644 index 00000000..ba4e28ca --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-claim.yaml @@ -0,0 +1,18 @@ +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: ParentNopClaim +metadata: + name: existing-parent-claim + namespace: default +spec: + parentField: existing-parent-value + compositeDeletePolicy: Background + compositionRef: + name: parent-nop-claim-composition + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: parent-nop-claim-composition-cfac93e + # Bound to its backing XR, existing-parent-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopParent + name: existing-parent-claim-82crv diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/existing-managed-resource.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-managed-resource.yaml new file mode 100644 index 00000000..21a97b6d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-managed-resource.yaml @@ -0,0 +1,14 @@ +apiVersion: nop.crossplane.io/v1alpha1 +kind: ClusterNopResource +metadata: + name: existing-parent-claim-82crv-nop + generateName: existing-parent-claim-82crv- + annotations: + child-field: existing-parent-value + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + conditionAfter: + - conditionType: Ready + conditionStatus: "True" + time: 0s diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/existing-parent-xr.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-parent-xr.yaml new file mode 100644 index 00000000..20399443 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/existing-parent-xr.yaml @@ -0,0 +1,24 @@ +# The backing XR Crossplane's claim syncer created for existing-claim.yaml, tracking its composition's +# current revision. A case also diffs this file as an input, so it spells out what the harness would +# otherwise derive. +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: XNopParent +metadata: + name: existing-parent-claim-82crv + generateName: existing-parent-claim- + labels: + crossplane.io/claim-name: existing-parent-claim + crossplane.io/claim-namespace: default + crossplane.io/composite: existing-parent-claim-82crv +spec: + claimRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: ParentNopClaim + name: existing-parent-claim + namespace: default + parentField: existing-parent-value + compositionUpdatePolicy: Automatic + compositionRef: + name: parent-nop-claim-composition + compositionRevisionRef: + name: parent-nop-claim-composition-cfac93e diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/parent-composition.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/parent-composition.yaml new file mode 100644 index 00000000..f2475f2d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/parent-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: parent-nop-claim-composition +spec: + compositeTypeRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopParent + mode: Pipeline + pipeline: + - step: create-child-xr + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopChild + metadata: + generateName: {{ .observed.composite.resource.metadata.name }}- + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: child-xr + spec: + childField: {{ .observed.composite.resource.spec.parentField }} + - step: auto-ready + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml new file mode 100644 index 00000000..d3e331ca --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml @@ -0,0 +1,35 @@ +# Renders, into a composed resource, the two fields of its XR that only the XR's CRD defaults: +# spec.tier (a user default, which the claim's CRD shares) and spec.compositionUpdatePolicy (machinery, +# which the claim's CRD does not default). A claim whose manifest omits both therefore shows, in this +# resource, whether the XR that render consumed was defaulted with the XR's CRD. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: parent-nop-claim-defaults-composition +spec: + compositeTypeRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopParent + mode: Pipeline + pipeline: + - step: render-defaults + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-defaults + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: defaults + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.tier }}/{{ .observed.composite.resource.spec.compositionUpdatePolicy }} + - step: auto-ready + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/claim-nested/parent-definition.yaml b/internal/diff/integration/testdata/diff/resources/claim-nested/parent-definition.yaml new file mode 100644 index 00000000..d3ef4162 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-nested/parent-definition.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopparents.claimnested.diff.example.org +spec: + group: claimnested.diff.example.org + names: + kind: XNopParent + plural: xnopparents + claimNames: + kind: ParentNopClaim + plural: parentnopclaims + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parentField: + type: string + tier: + type: string + default: standard + required: + - parentField + status: + type: object + properties: + message: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/claim-xrd.yaml b/internal/diff/integration/testdata/diff/resources/claim-xrd.yaml new file mode 100644 index 00000000..af86a8af --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/claim-xrd.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.diff.example.org +spec: + group: diff.example.org + names: + kind: XNopResource + plural: xnopresources + claimNames: + kind: NopClaim + plural: nopclaims + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + tier: + type: string + default: standard + writeConnectionSecretsToNamespace: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/cluster-xrd.yaml b/internal/diff/integration/testdata/diff/resources/cluster-xrd.yaml new file mode 100644 index 00000000..f7e518e4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/cluster-xrd.yaml @@ -0,0 +1,51 @@ +# This is a v2 XRD so instances of the XR will be namespace scoped. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.diff.example.org +spec: + group: diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Cluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/composition-multi-functions.yaml b/internal/diff/integration/testdata/diff/resources/composition-multi-functions.yaml new file mode 100644 index 00000000..ddda8d62 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-multi-functions.yaml @@ -0,0 +1,80 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xconcurrenttests.diff.example.org +spec: + compositeTypeRef: + apiVersion: concurrent.diff.example.org/v1alpha1 + kind: XConcurrentTest + mode: Pipeline + pipeline: + # Step 1: Generate base resources + - step: generate-base-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- range $i := until 3 }} + --- + apiVersion: concurrent.nop.example.org/v1alpha1 + kind: XBaseResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }}-base-{{ $i }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: base-resource-{{ $i }} + spec: + forProvider: + index: {{ $i }} + config: {{ $.observed.composite.resource.spec.config }} + {{- end }} + + # Step 2: Apply environment configs + - step: apply-environment-configs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: EnvironmentConfigs + + # Step 3: Generate additional resources + - step: generate-additional-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- range $i := until 2 }} + --- + apiVersion: concurrent.nop.example.org/v1alpha1 + kind: XAdditionalResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }}-additional-{{ $i }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: additional-resource-{{ $i }} + spec: + forProvider: + index: {{ $i }} + config: {{ $.observed.composite.resource.spec.config }} + {{- end }} + + # Step 4: Fetch extra resources if needed + - step: fetch-extra-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extraresources.fn.crossplane.io/v1beta1 + kind: ExtraResources + + # Step 5: Auto-ready + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/composition-no-namespace-patch.yaml b/internal/diff/integration/testdata/diff/resources/composition-no-namespace-patch.yaml new file mode 100644 index 00000000..a4cb8739 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-no-namespace-patch.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # NOTE: No namespace template - testing automatic namespace propagation + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/composition-revision-preview.yaml b/internal/diff/integration/testdata/diff/resources/composition-revision-preview.yaml new file mode 100644 index 00000000..18d67a21 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-revision-preview.yaml @@ -0,0 +1,38 @@ +# A later revision of xnopresources.diff.example.org, from when the composition carried the +# channel=preview label. The harness names and labels it from the hash of the composition it was made +# from, as Crossplane does. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + channel: preview +spec: + revision: 2 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: preview-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-revision-stable.yaml b/internal/diff/integration/testdata/diff/resources/composition-revision-stable.yaml new file mode 100644 index 00000000..5dff855b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-revision-stable.yaml @@ -0,0 +1,38 @@ +# An earlier revision of xnopresources.diff.example.org, from when the composition carried the +# channel=stable label. The harness names and labels it from the hash of the composition it was made +# from, as Crossplane does. The XR in existing-xr-selector-stable.yaml tracks it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + channel: stable +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-revision-v1.yaml b/internal/diff/integration/testdata/diff/resources/composition-revision-v1.yaml new file mode 100644 index 00000000..109940aa --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-revision-v1.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xnopresources.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-v2.yaml b/internal/diff/integration/testdata/diff/resources/composition-v2.yaml new file mode 100644 index 00000000..17c012c6 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-v2.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-builtin-defaults.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-builtin-defaults.yaml new file mode 100644 index 00000000..c032a4cd --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-builtin-defaults.yaml @@ -0,0 +1,61 @@ +# Renders a built-in Kubernetes resource whose apiserver defaults are NOT applied locally. +# +# This fixture exists to exercise the apiserver round-trip for additions (issue #334), and the +# choice of a built-in type is load-bearing rather than incidental. +# +# The lenient Defaulter predicts CRD-derived `default:` values locally for an addition that gets no +# apiserver result, so a CRD-backed composed resource can show its defaults in a `+++` diff with no +# apiserver involvement. Deployment has no CRD, so IsCRDRequired is false and the Defaulter passes it +# through unchanged — which makes it the smallest resource whose defaulted fields can only come from +# the apiserver. +# +# The Deployment deliberately sets only the fields a caller must set (selector, template, one +# container). Everything asserted in the corresponding test — spec.strategy.type, +# spec.template.spec.restartPolicy, spec.template.spec.dnsPolicy — is left for the apiserver to +# populate. Deployment is preferred over Service here because Service creation allocates a +# clusterIP even under dry-run, which would put a non-deterministic value in the diff; Deployment's +# defaults are pure schema defaulting with no allocator involved. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: {{ .observed.composite.resource.metadata.name }}-deploy + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: deployment + spec: + selector: + matchLabels: + app: {{ .observed.composite.resource.metadata.name }} + template: + metadata: + labels: + app: {{ .observed.composite.resource.metadata.name }} + spec: + containers: + - name: main + image: nginx:1.27 + env: + - name: CONFIG_DATA + value: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-configmap.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-configmap.yaml new file mode 100644 index 00000000..d0ca422f --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-configmap.yaml @@ -0,0 +1,48 @@ +# Renders a built-in Kubernetes resource (ConfigMap) alongside a composed XR. +# +# ConfigMap has no CRD, so every code path that determines a resource's scope +# or validates it against a schema has to cope with its absence. Scope comes +# from the discovery API; schema validation comes from the embedded Kubernetes +# scheme in crossplane/cli's validate package. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ .observed.composite.resource.metadata.name }}-config + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: config-map + data: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml new file mode 100644 index 00000000..48f1a0af --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-defaulted-downstream.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # spec.forProvider.size is deliberately not rendered: its CRD defaults it. + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDefaultedDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-defaulted + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: defaulted-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-defaults.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-defaults.yaml new file mode 100644 index 00000000..0eedc809 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-defaults.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xdefaultresources.ns.diff.example.org + labels: + provider: nop + service: nop +spec: + mode: Pipeline + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XTestDefaultResource + pipeline: + - step: auto-ready + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml new file mode 100644 index 00000000..c0edae36 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-non-storage-version-downstream.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # Rendered at v1alpha1, which its CRD serves but does not store. + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XVersionedDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-versioned + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: versioned-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-status.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-status.yaml new file mode 100644 index 00000000..be9332b7 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-status.yaml @@ -0,0 +1,41 @@ +# A composition whose pipeline authors a `status` on the composed resource it renders. +# +# Composition pipelines are explicitly allowed to write their own status, so a rendered status is +# user-authored output rather than a stale read of the cluster. The composed kind here +# (XStatusResource) declares the status subresource, so the apiserver strips status from the dry-run +# create and returns an object with none — which means the status below can only reach the `+++` +# diff if the calculator re-attaches it (crossplane-diff#334). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XStatusResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: status-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + status: + phase: authored-by-composition + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-unserved-k8s-group.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-unserved-k8s-group.yaml new file mode 100644 index 00000000..17ecbf25 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-unserved-k8s-group.yaml @@ -0,0 +1,42 @@ +# Renders a composed resource in a *.k8s.io group that the cluster does not serve. +# +# This shape slips past every earlier guard. IsCRDRequired (schema_client.go) assumes any group +# ending in .k8s.io other than apiextensions.k8s.io is built-in and returns false WITHOUT consulting +# discovery, so no CRD is demanded. FetchCurrentObject then propagates only non-NotFound errors, and +# a discovery 404 for an unserved group/version is NotFound-shaped, so it is swallowed as "this +# resource is new". The resource therefore reaches the addition path with a GVK that cannot be +# resolved at all. +# +# It stands in for a typo in a group name, an apiVersion skew, a stripped-down cluster, or an +# aggregated API that was never deployed. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: totallynotserved.k8s.io/v1 + kind: Widget + metadata: + name: {{ .observed.composite.resource.metadata.name }}-widget + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: widget + spec: + size: large + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/composition.yaml b/internal/diff/integration/testdata/diff/resources/composition.yaml new file mode 100644 index 00000000..24a2b59b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # add namespace to serve v2 + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/conditional-gating-composition.yaml b/internal/diff/integration/testdata/diff/resources/conditional-gating-composition.yaml new file mode 100644 index 00000000..57c9411f --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/conditional-gating-composition.yaml @@ -0,0 +1,80 @@ +# Composition-driven gating (no function-sequencer). stage1 is only rendered by +# go-templating when observed stage0 has Ready=True. This isolates eventual-state +# simulation from sequencer behavior: the gating is encoded in the template itself. +# +# Template data model: observed composed resources are at .observed.resources +# (map keyed by composition-resource-name), matching the RunFunctionRequest proto +# field State.resources. Each entry has .resource (the composed resource object). +# +# Flow under eventual-state: +# iter 1: observed.resources is empty -> stage1 guard skipped -> renders stage0 only +# synthesizeReady flips rendered stage0 Ready=True and merges into observed +# iter 2: observed.resources[stage0-resource].resource.status.conditions has +# Ready=True -> renders stage0+stage1 +# iter 3: renders stage0+stage1 again; both are observed Ready -> stable +# For an existing XR whose stage0 is observed but not Ready, iter 1 synthesizes +# its Ready on the observed copy, and the flow continues the same way (#564). +# +# Flow without eventual-state: iter 1 only. Renders stage0. Stable immediately. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: conditional-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- /* Render stage1 iff observed stage0 has a Ready=True condition. + Access pattern: .observed.resources is the protojson of State.resources + (map keyed by composition-resource-name). + Compute $ready once, then emit the manifest once to avoid + duplicates if multiple Ready=True conditions ever appear. */ -}} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/conditional-gating-xr.yaml b/internal/diff/integration/testdata/diff/resources/conditional-gating-xr.yaml new file mode 100644 index 00000000..b871f6bd --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/conditional-gating-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: conditional-gating-test + namespace: default +spec: + coolField: "test-value" diff --git a/internal/diff/integration/testdata/diff/resources/credentials/cli-credentials.yaml b/internal/diff/integration/testdata/diff/resources/credentials/cli-credentials.yaml new file mode 100644 index 00000000..73d57dbd --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/credentials/cli-credentials.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: function-credentials + namespace: crossplane-system +type: Opaque +data: + # base64 encoded "cli-override-key" + credentials: Y2xpLW92ZXJyaWRlLWtleQ== diff --git a/internal/diff/integration/testdata/diff/resources/credentials/composition-with-creds.yaml b/internal/diff/integration/testdata/diff/resources/credentials/composition-with-creds.yaml new file mode 100644 index 00000000..2820c988 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/credentials/composition-with-creds.yaml @@ -0,0 +1,39 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-with-creds.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + # Credentials reference - this tests auto-fetch from cluster + credentials: + - name: test-creds + source: Secret + secretRef: + namespace: crossplane-system + name: function-credentials + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/credentials/function-credentials-secret.yaml b/internal/diff/integration/testdata/diff/resources/credentials/function-credentials-secret.yaml new file mode 100644 index 00000000..7c070b9f --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/credentials/function-credentials-secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: crossplane-system +--- +apiVersion: v1 +kind: Secret +metadata: + name: function-credentials + namespace: crossplane-system +type: Opaque +data: + # base64 encoded "test-api-key-12345" + credentials: dGVzdC1hcGkta2V5LTEyMzQ1 diff --git a/internal/diff/integration/testdata/diff/resources/cross-namespace-configmap.yaml b/internal/diff/integration/testdata/diff/resources/cross-namespace-configmap.yaml new file mode 100644 index 00000000..4fa3771b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/cross-namespace-configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cross-namespace-config + namespace: kube-system + labels: + app: cross-namespace-app + environment: production +data: + config-data: "cross-namespace-data" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: another-cross-namespace-config + namespace: kube-public + labels: + app: another-cross-namespace-app + environment: staging +data: + config-data: "another-cross-namespace-data" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/cross-namespace-fn-composition.yaml b/internal/diff/integration/testdata/diff/resources/cross-namespace-fn-composition.yaml new file mode 100644 index 00000000..a1bcb0e0 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/cross-namespace-fn-composition.yaml @@ -0,0 +1,72 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.cross-namespace.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-cross-namespace-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Cross-namespace ConfigMap by label selector + - apiVersion: v1 + kind: ConfigMap + into: crossNamespaceConfig + type: Selector + namespace: kube-system + selector: + matchLabels: + - key: app + type: Value + value: cross-namespace-app + - key: environment + type: Value + value: production + # Another cross-namespace ConfigMap by name reference + - apiVersion: v1 + kind: ConfigMap + into: namedCrossNamespaceConfig + type: Reference + namespace: kube-public + ref: + name: another-cross-namespace-config + # Cluster-scoped resource for comparison + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + into: namedClusterRole + type: Reference + ref: + name: external-named-clusterrole + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $crossNsConfig := index .context "apiextensions.crossplane.io/extra-resources" "crossNamespaceConfig" 0 -}} + {{- $namedCrossNsConfig := index .context "apiextensions.crossplane.io/extra-resources" "namedCrossNamespaceConfig" 0 -}} + {{- $clusterRole := index .context "apiextensions.crossplane.io/extra-resources" "namedClusterRole" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cross-ns-resource + spec: + forProvider: + configData: {{ index $crossNsConfig "data" "config-data" }}-{{ index $namedCrossNsConfig "data" "config-data" }}-{{ $clusterRole.metadata.name }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/cycle/compositions.yaml b/internal/diff/integration/testdata/diff/resources/cycle/compositions.yaml new file mode 100644 index 00000000..37830144 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/cycle/compositions.yaml @@ -0,0 +1,67 @@ +# Each composition composes the other kind, naming the child after its parent the way the nested +# fixtures do, so every level of the cycle appends another suffix to the name. The suffix is a short +# "-c" because Crossplane's generated XR CRDs cap metadata.name at 63 characters: with "-child" a real +# cluster (and the generated test CRDs) would refuse the ninth level before --max-nested-depth fires. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xcycleas.cycle.example.org +spec: + compositeTypeRef: + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleA + mode: Pipeline + pipeline: + - step: generate-cycle-b + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleB + metadata: + name: {{ .observed.composite.resource.metadata.name }}-c + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cycle-b + spec: + cycleField: {{ .observed.composite.resource.spec.cycleField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xcyclebs.cycle.example.org +spec: + compositeTypeRef: + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleB + mode: Pipeline + pipeline: + - step: generate-cycle-a + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleA + metadata: + name: {{ .observed.composite.resource.metadata.name }}-c + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cycle-a + spec: + cycleField: {{ .observed.composite.resource.spec.cycleField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/cycle/xrds.yaml b/internal/diff/integration/testdata/diff/resources/cycle/xrds.yaml new file mode 100644 index 00000000..6754ced2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/cycle/xrds.yaml @@ -0,0 +1,100 @@ +# Two XR kinds whose compositions compose each other: XCycleA -> XCycleB -> XCycleA -> ... +# A misconfiguration Crossplane itself would recurse on forever; crossplane-diff must stop at +# --max-nested-depth with a clear error rather than recursing until the stack overflows (#485). +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xcycleas.ns.cycle.example.org +spec: + group: ns.cycle.example.org + names: + kind: XCycleA + plural: xcycleas + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + cycleField: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string +--- +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xcyclebs.ns.cycle.example.org +spec: + group: ns.cycle.example.org + names: + kind: XCycleB + plural: xcyclebs + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + cycleField: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/default-composition.yaml b/internal/diff/integration/testdata/diff/resources/default-composition.yaml new file mode 100644 index 00000000..b7ccde46 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/default-composition.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: default-composition + labels: + provider: default + environment: any +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-default-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: default-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: "default" + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/env-composition.yaml b/internal/diff/integration/testdata/diff/resources/env-composition.yaml new file mode 100644 index 00000000..6583c4de --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/env-composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xenvresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XEnvResource + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: test-env-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ $envConfig := index .context "apiextensions.crossplane.io/environment" }} + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamEnvResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: env-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.configKey }} + region: {{ $envConfig.region }} + environment: {{ $envConfig.environment }} + serviceLevel: {{ $envConfig.serviceLevel }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/env-xrd.yaml b/internal/diff/integration/testdata/diff/resources/env-xrd.yaml new file mode 100644 index 00000000..9d137cac --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/env-xrd.yaml @@ -0,0 +1,22 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xenvresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XEnvResource + plural: xenvresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + configKey: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/environment-config-v1beta1.yaml b/internal/diff/integration/testdata/diff/resources/environment-config-v1beta1.yaml new file mode 100644 index 00000000..ddb284c0 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/environment-config-v1beta1.yaml @@ -0,0 +1,8 @@ +apiVersion: apiextensions.crossplane.io/v1beta1 +kind: EnvironmentConfig +metadata: + name: test-env-config +data: + region: us-west-2 + environment: staging + serviceLevel: premium diff --git a/internal/diff/integration/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml b/internal/diff/integration/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml new file mode 100644 index 00000000..7caf33a2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml @@ -0,0 +1,10 @@ +apiVersion: diff.example.org/v1beta1 +kind: XApiMigrateResource +metadata: + name: test-api-version-xr-api-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: api-migrate-resource +spec: + forProvider: + configData: test-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-api-version-xr-rev1.yaml b/internal/diff/integration/testdata/diff/resources/existing-api-version-xr-rev1.yaml new file mode 100644 index 00000000..558b7168 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-api-version-xr-rev1.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version-xr + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionRevisionRef: + name: xapimigrateresources.example.org-2067855 + compositionUpdatePolicy: Manual + coolField: test-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml b/internal/diff/integration/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml new file mode 100644 index 00000000..ab5d01ab --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml @@ -0,0 +1,10 @@ +# A different field manager sets two fields the claim's CRD defaults to non-default values on the +# claim, whose manifest (modified-claim-omitting-defaulted-fields.yaml) omits both. +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Foreground + compositionUpdatePolicy: Manual diff --git a/internal/diff/integration/testdata/diff/resources/existing-claim-downstream-resource.yaml b/internal/diff/integration/testdata/diff/resources/existing-claim-downstream-resource.yaml new file mode 100644 index 00000000..bdea322d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-claim-downstream-resource.yaml @@ -0,0 +1,9 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-claim-82crv +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-claim-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-claim-xr.yaml new file mode 100644 index 00000000..e5ed847a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-claim-xr.yaml @@ -0,0 +1,21 @@ +# The backing XR Crossplane's claim syncer created for existing-claim.yaml, and whose name it wrote to +# that claim's spec.resourceRef. Its composed resource is existing-claim-downstream-resource.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-claim-82crv + generateName: test-claim- +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: claim-composition + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: test-claim + namespace: existing-namespace + resourceRefs: + - apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + name: test-claim-82crv diff --git a/internal/diff/integration/testdata/diff/resources/existing-claim.yaml b/internal/diff/integration/testdata/diff/resources/existing-claim.yaml new file mode 100644 index 00000000..0de70f11 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-claim.yaml @@ -0,0 +1,18 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositeDeletePolicy: Background + compositionRef: + name: claim-composition + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: claim-composition-caff3a7 + # Bound to its backing XR, existing-claim-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + name: test-claim-82crv diff --git a/internal/diff/integration/testdata/diff/resources/existing-cluster-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-cluster-xr.yaml new file mode 100644 index 00000000..03e34f05 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-cluster-xr.yaml @@ -0,0 +1,10 @@ +# TODO: use opposite pattern of prefixing namespaced api group instead of prefixing cluster api group +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition diff --git a/internal/diff/integration/testdata/diff/resources/existing-cross-ns-downstream.yaml b/internal/diff/integration/testdata/diff/resources/existing-cross-ns-downstream.yaml new file mode 100644 index 00000000..45fdbc88 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-cross-ns-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-cross-ns-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: cross-ns-resource +spec: + forProvider: + configData: existing-cross-ns-data-existing-named-data-old-cross-ns-role \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/existing-cross-ns-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-cross-ns-xr.yaml new file mode 100644 index 00000000..5830cd0e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-cross-ns-xr.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-cross-ns-resource + namespace: default +spec: + coolField: existing-cross-ns-value + environment: staging + crossplane: + compositionRef: + name: xnopresources.cross-namespace.example.org diff --git a/internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-composed.yaml b/internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-composed.yaml new file mode 100644 index 00000000..cb92c60d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-composed.yaml @@ -0,0 +1,14 @@ +# The fields Crossplane's composed-resource field manager applies. spec.forProvider.size is absent, +# exactly as the composition renders it. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDefaultedDownstreamResource +metadata: + name: test-resource-defaulted + namespace: default + annotations: + crossplane.io/composition-resource-name: defaulted-resource + labels: + crossplane.io/composite: test-resource +spec: + forProvider: + configData: new-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml b/internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml new file mode 100644 index 00000000..04a6509a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml @@ -0,0 +1,10 @@ +# A different field manager (a user or another controller) sets the CRD-defaulted field to a +# non-default value on the composed resource Crossplane already manages. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDefaultedDownstreamResource +metadata: + name: test-resource-defaulted + namespace: default +spec: + forProvider: + size: large diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-automatic.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-automatic.yaml new file mode 100644 index 00000000..f8abdd1d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-automatic.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-automatic + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1-existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-manual-v1.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-manual-v1.yaml new file mode 100644 index 00000000..8909fb23 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-manual-v1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-manual-v1 + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1-existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml new file mode 100644 index 00000000..650c6e68 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml @@ -0,0 +1,15 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + argocd.argoproj.io/tracking-id: "test-app:/XDownstreamResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.nop.example.org/v1alpha1","kind":"XDownstreamResource"} + labels: + argocd.argoproj.io/instance: test-app + name: test-resource + namespace: default +spec: + forProvider: + configData: new-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-resource.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-resource.yaml new file mode 100644 index 00000000..3dc89919 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-resource.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-selector-stable.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-selector-stable.yaml new file mode 100644 index 00000000..5f53076d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-selector-stable.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-selector + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stable-existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-with-external-dep.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-with-external-dep.yaml new file mode 100644 index 00000000..f25c13dc --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-with-external-dep.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-resource + namespace: default +spec: + forProvider: + configData: existing-value + roleName: old-role-name diff --git a/internal/diff/integration/testdata/diff/resources/existing-downstream-with-generated-name.yaml b/internal/diff/integration/testdata/diff/resources/existing-downstream-with-generated-name.yaml new file mode 100644 index 00000000..e8cc8c56 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-downstream-with-generated-name.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + generateName: test-resource- + labels: + crossplane.io/composite: test-resource + name: test-resource-abc123 + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-env-downstream-resource.yaml b/internal/diff/integration/testdata/diff/resources/existing-env-downstream-resource.yaml new file mode 100644 index 00000000..19da0e39 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-env-downstream-resource.yaml @@ -0,0 +1,16 @@ +# Deliberately no compositionRef or compositionRevisionRef. XDownstreamEnvResource is an XR kind (its +# XRD is xdownstreamenvresource-xrd.yaml), but no Composition targets it, so Crossplane can select +# none and never writes either ref. The diff likewise skips rendering it as a nested XR. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamEnvResource +metadata: + annotations: + crossplane.io/composition-resource-name: env-resource + name: test-env-resource +spec: + compositionUpdatePolicy: Automatic + forProvider: + configData: existing-config-value + environment: staging + region: us-west-2 + serviceLevel: premium \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/existing-env-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-env-xr.yaml new file mode 100644 index 00000000..1768c99a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-env-xr.yaml @@ -0,0 +1,9 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XEnvResource +metadata: + name: test-env-resource +spec: + compositionRef: + name: xenvresources.diff.example.org + compositionUpdatePolicy: Automatic + configKey: "existing-config-value" diff --git a/internal/diff/integration/testdata/diff/resources/existing-generate-name-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-generate-name-xr.yaml new file mode 100644 index 00000000..72d7f866 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-generate-name-xr.yaml @@ -0,0 +1,13 @@ +# existing-xr.yaml as Crossplane leaves it once generated-name-composition.yaml has composed it, so it +# tracks that composition's revision rather than composition.yaml's. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml b/internal/diff/integration/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml new file mode 100644 index 00000000..0134d82c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml @@ -0,0 +1,9 @@ +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-legacy-manual-v1 + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1-existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml b/internal/diff/integration/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml new file mode 100644 index 00000000..4999cd65 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml @@ -0,0 +1,11 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-legacy-manual-v1 +spec: + compositionRef: + name: xlegacynopresources.diff.example.org + compositionRevisionRef: + name: xlegacynopresources.diff.example.org-2f488b6 + compositionUpdatePolicy: Manual + coolField: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-legacy-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-legacy-xr.yaml new file mode 100644 index 00000000..5c375919 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-legacy-xr.yaml @@ -0,0 +1,9 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic + coolField: "existing-value" diff --git a/internal/diff/integration/testdata/diff/resources/existing-namespace.yaml b/internal/diff/integration/testdata/diff/resources/existing-namespace.yaml new file mode 100644 index 00000000..3fc6cd48 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: existing-namespace \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/existing-other-xr-downstream.yaml b/internal/diff/integration/testdata/diff/resources/existing-other-xr-downstream.yaml new file mode 100644 index 00000000..23e74d32 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-other-xr-downstream.yaml @@ -0,0 +1,11 @@ +# The resource composition.yaml composes for existing-other-xr.yaml, which controls it. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: other-resource + namespace: default +spec: + forProvider: + configData: other-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-other-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-other-xr.yaml new file mode 100644 index 00000000..4653e2b9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-other-xr.yaml @@ -0,0 +1,13 @@ +# A second XR, reconciled by composition.yaml, which controls the composed resource in +# existing-other-xr-downstream.yaml. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: other-resource + namespace: default +spec: + coolField: "other-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/existing-removal-test-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-removal-test-xr.yaml new file mode 100644 index 00000000..bc841ee6 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-removal-test-xr.yaml @@ -0,0 +1,13 @@ +# existing-xr.yaml as Crossplane leaves it once removal-test-composition.yaml has composed it, so it +# tracks that composition and its revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml new file mode 100644 index 00000000..8ce45267 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml @@ -0,0 +1,19 @@ +# The stage0 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml, +# once it has become Ready. Crossplane then composed stage1 too +# (existing-sequencer-gating-stage1-ready.yaml). +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value +status: + conditions: + - type: Ready + status: "True" + reason: Available + lastTransitionTime: "2024-01-01T00:00:00Z" diff --git a/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0.yaml b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0.yaml new file mode 100644 index 00000000..e84cc250 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage0.yaml @@ -0,0 +1,19 @@ +# The stage0 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml, +# still provisioning: it is not Ready, so function-sequencer keeps stage1 gated until +# --eventual-state synthesizes its Ready. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value +status: + conditions: + - type: Ready + status: "False" + reason: Creating + lastTransitionTime: "2024-01-01T00:00:00Z" diff --git a/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml new file mode 100644 index 00000000..a209807c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml @@ -0,0 +1,18 @@ +# The stage1 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml +# once its stage0 (existing-sequencer-gating-stage0-ready.yaml) became Ready. It is Ready too. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage1-resource + name: 1-stage1-resource + namespace: default +spec: + forProvider: + configData: existing-value +status: + conditions: + - type: Ready + status: "True" + reason: Available + lastTransitionTime: "2024-01-01T00:00:00Z" diff --git a/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-xr.yaml new file mode 100644 index 00000000..b259f22a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-sequencer-gating-xr.yaml @@ -0,0 +1,13 @@ +# sequencer-xr.yaml as it stood in the cluster before its coolField was changed, once Crossplane had +# reconciled it against sequencer-gating-composition.yaml, recording the composition it selected at +# spec.crossplane.* as the v2 XRD has them. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: sequencer-gating-composition diff --git a/internal/diff/integration/testdata/diff/resources/existing-sequencer-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-sequencer-xr.yaml new file mode 100644 index 00000000..6c50f601 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-sequencer-xr.yaml @@ -0,0 +1,12 @@ +# sequencer-xr.yaml as it stands in the cluster once Crossplane has reconciled it against +# sequencer-composition.yaml. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-test + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionRef: + name: sequencer-composition diff --git a/internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml b/internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml new file mode 100644 index 00000000..e1c4dcb2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-v2xrd-v1paths + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml new file mode 100644 index 00000000..2f1b1b25 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml @@ -0,0 +1,14 @@ +# v2 XRD with v1-style composition paths test case (issue #206) +# This XR uses v1-style spec.compositionRef instead of spec.crossplane.compositionRef +apiVersion: v2withv1paths.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-v2xrd-v1paths + namespace: default +spec: + # v1-style composition reference (at spec.compositionRef instead of spec.crossplane.compositionRef), + # which is what this case is about. The harness derives the revision ref at the same v1-style path. + compositionRef: + name: xnopresources.v2withv1paths.diff.example.org + compositionUpdatePolicy: Automatic + coolField: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-automatic.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-automatic.yaml new file mode 100644 index 00000000..52060d5d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-automatic.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-automatic + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-911318d # Currently on v1, but should auto-upgrade + compositionUpdatePolicy: Automatic + coolField: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-deleting.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-deleting.yaml new file mode 100644 index 00000000..b84ed1af --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-deleting.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + # The finalizer keeps the object around after a delete is issued, so envtest (which runs no + # controllers) leaves it with metadata.deletionTimestamp set — a resource mid-deletion. + finalizers: + - composite.apiextensions.crossplane.io +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml new file mode 100644 index 00000000..a68ebbd7 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml @@ -0,0 +1,23 @@ +# existing-removal-test-xr.yaml whose resourceRefs still name two composed resources it no longer +# controls: one another XR (existing-other-xr.yaml) now controls, and one whose controller reference +# was removed. Crossplane's garbage collector deletes only a resource the composite controls, so +# neither is removed although the composition no longer renders them. The harness appends the +# resources this XR does control. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic + resourceRefs: + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: other-resource + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: resource-without-controller diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-manual-v1.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-manual-v1.yaml new file mode 100644 index 00000000..61c2c65c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-manual-v1.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-911318d + compositionUpdatePolicy: Manual + coolField: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-selector-stable.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-selector-stable.yaml new file mode 100644 index 00000000..bb1edeb2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-selector-stable.yaml @@ -0,0 +1,16 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-selector + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-1623e01 + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + channel: stable + coolField: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-argocd.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-argocd.yaml new file mode 100644 index 00000000..af4bc154 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-argocd.yaml @@ -0,0 +1,19 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app-OLD:/XNopResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource","namespace":"default"},"spec":{"coolField":"old-value"}} + labels: + argocd.argoproj.io/instance: test-app-OLD +spec: + coolField: "new-value" + # What any XR Crossplane has reconciled carries: the reconciler writes spec.crossplane, and the + # XR's generated CRD then defaults the policy. + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml new file mode 100644 index 00000000..678d6a0e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml @@ -0,0 +1,9 @@ +# A different field manager (a user or another controller) sets the XRD-defaulted spec.region to a +# non-default value on an XR whose own manifest (xr-with-missing-defaults.yaml) omits it. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default +spec: + region: eu-west-1 diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml new file mode 100644 index 00000000..6a855f8d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml @@ -0,0 +1,16 @@ +# What Crossplane's composite reconciler writes to xr-with-missing-defaults.yaml once it has reconciled +# it: its own composite label, and the composition and revision it selected (the revision the harness +# creates for composition-with-defaults.yaml, as the revision controller does). +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default + labels: + crossplane.io/composite: test-resource-with-defaults +spec: + crossplane: + compositionRef: + name: xdefaultresources.ns.diff.example.org + compositionRevisionRef: + name: xdefaultresources.ns.diff.example.org-d0e5887 diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml new file mode 100644 index 00000000..8a000a9b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml @@ -0,0 +1,14 @@ +# existing-xr-with-external-dep.yaml as Crossplane leaves it once +# multistep-fatal-after-extra-res-composition.yaml has composed it, so it tracks that composition and +# its revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: multistep-fatal-after-extra-res.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml new file mode 100644 index 00000000..7c16d606 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml @@ -0,0 +1,13 @@ +# existing-xr-with-external-dep.yaml as Crossplane leaves it once external-res-gotpl-composition.yaml +# has composed it, so it tracks that composition and its revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: templated-extra-resources.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml new file mode 100644 index 00000000..2f09685b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml @@ -0,0 +1,14 @@ +# existing-xr-with-external-dep.yaml as Crossplane leaves it once +# unguarded-external-res-gotpl-composition.yaml has composed it, so it tracks that composition and its +# revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: unguarded-templated-extra-resources.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep.yaml new file mode 100644 index 00000000..852cd656 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr-with-external-dep.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/existing-xr.yaml b/internal/diff/integration/testdata/diff/resources/existing-xr.yaml new file mode 100644 index 00000000..38e8c85c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/existing-xr.yaml @@ -0,0 +1,13 @@ +# spec.crossplane.compositionUpdatePolicy is what any XR Crossplane has reconciled carries: the +# reconciler writes spec.crossplane, and the XR's generated CRD then defaults the policy. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/external-named-clusterrole.yaml b/internal/diff/integration/testdata/diff/resources/external-named-clusterrole.yaml new file mode 100644 index 00000000..3f6b02ef --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/external-named-clusterrole.yaml @@ -0,0 +1,8 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-named-clusterrole +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] diff --git a/internal/diff/integration/testdata/diff/resources/external-res-fn-composition.yaml b/internal/diff/integration/testdata/diff/resources/external-res-fn-composition.yaml new file mode 100644 index 00000000..dacd3b43 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/external-res-fn-composition.yaml @@ -0,0 +1,64 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + - apiVersion: v1 + kind: ConfigMap + into: cfgMap + type: Selector + selector: + matchLabels: + - key: app + type: Value + value: test-app + - key: environment + type: FromCompositeFieldPath + valueFromFieldPath: spec.environment + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + # function-extra-resources only supports Cluster-scoped resources as name refs + into: namedClusterRole + type: Reference + ref: + name: external-named-clusterrole + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- /* we can't use the same call to 'index' on these because it goes map -> array -> map */ -}} + {{- $extras := index .context "apiextensions.crossplane.io/extra-resources" -}} + {{- $cfgMap := index $extras "cfgMap" 0 -}} + {{- $clusterRole := index $extras "namedClusterRole" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ index $cfgMap "data" "resource-data" }} + roleName: {{ $clusterRole.metadata.name }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/external-res-gotpl-composition.yaml b/internal/diff/integration/testdata/diff/resources/external-res-gotpl-composition.yaml new file mode 100644 index 00000000..eb3c88aa --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/external-res-gotpl-composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: templated-extra-resources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- with .extraResources }} + {{ $configMaps := index . "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} + {{- end }} diff --git a/internal/diff/integration/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml b/internal/diff/integration/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml new file mode 100644 index 00000000..a886976c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: templated-extra-resources-matchname.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # Regression scenario for crossplane-contrib/crossplane-diff#355: + # ExtraResources block uses matchName to reference a Secret that + # does not exist in the cluster. Pre-fix this aborted the diff with + # "cannot get referenced resource"; post-fix the unmet requirement + # is silently skipped (mirroring upstream xfn behavior) and the + # composition continues to render its downstream resource. + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + optional-secret: + apiVersion: v1 + kind: Secret + matchName: nonexistent-secret + namespace: default + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + roleName: static-role diff --git a/internal/diff/integration/testdata/diff/resources/external-resource-configmap-other-namespace.yaml b/internal/diff/integration/testdata/diff/resources/external-resource-configmap-other-namespace.yaml new file mode 100644 index 00000000..4a02772e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/external-resource-configmap-other-namespace.yaml @@ -0,0 +1,22 @@ +# Reproduction fixture for crossplane-contrib/crossplane-diff#376. +# +# A ConfigMap matched by a matchLabels ExtraResources selector (no namespace) +# that lives in a DIFFERENT namespace than the XR being diffed. The XR is in +# "default"; this resource is in "other-namespace". A correct all-namespaces +# lookup (matching upstream Crossplane) finds it; a lookup wrongly scoped to the +# XR namespace does not. +apiVersion: v1 +kind: Namespace +metadata: + name: other-namespace +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: external-resource-crossns + namespace: other-namespace + labels: + app: test-app + environment: crossns +data: + resource-data: "cross-namespace-external-resource-data" diff --git a/internal/diff/integration/testdata/diff/resources/external-resource-configmap.yaml b/internal/diff/integration/testdata/diff/resources/external-resource-configmap.yaml new file mode 100644 index 00000000..53200b9e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/external-resource-configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: external-resource-staging + namespace: default + labels: + app: test-app + environment: staging +data: + resource-data: "staging-external-resource-data" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: external-resource-testing + namespace: default + labels: + app: test-app + environment: testing +data: + resource-data: "testing-external-resource-data" \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/functions.yaml b/internal/diff/integration/testdata/diff/resources/functions.yaml new file mode 100644 index 00000000..9d26bbcc --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/functions.yaml @@ -0,0 +1,39 @@ +--- +# TODO: when updating these, ensure you also update the list of pulled images in Earthfile/go-test +# +# No Docker runtime annotations: the render engine labels each function's container with its run, drops any container +# name, and removes the containers when the run ends. +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.4.2 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-environment-configs +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-environment-configs:v0.4.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-extra-resources +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-extra-resources:v0.2.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-sequencer +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-sequencer:v0.5.0 diff --git a/internal/diff/integration/testdata/diff/resources/generated-name-composition.yaml b/internal/diff/integration/testdata/diff/resources/generated-name-composition.yaml new file mode 100644 index 00000000..f16e10c1 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/generated-name-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + generateName: test-resource- + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/legacy-composition-revision-v1.yaml b/internal/diff/integration/testdata/diff/resources/legacy-composition-revision-v1.yaml new file mode 100644 index 00000000..8f7eec5d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/legacy-composition-revision-v1.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xlegacynopresources.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/legacy-composition-v2.yaml b/internal/diff/integration/testdata/diff/resources/legacy-composition-v2.yaml new file mode 100644 index 00000000..fc492bb9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/legacy-composition-v2.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xlegacynopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/legacy-xrd.yaml b/internal/diff/integration/testdata/diff/resources/legacy-xrd.yaml new file mode 100644 index 00000000..c8648a5a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/legacy-xrd.yaml @@ -0,0 +1,49 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.legacycluster.diff.example.org +spec: + group: legacycluster.diff.example.org + names: + kind: XNopResource + plural: xnopresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/manual-default/definitions.yaml b/internal/diff/integration/testdata/diff/resources/manual-default/definitions.yaml new file mode 100644 index 00000000..11c29f88 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/manual-default/definitions.yaml @@ -0,0 +1,96 @@ +# An XRD whose composites default to the Manual update policy, and a composition with two revisions. +# Each revision's template writes its own version into the composed resource, so the value shows which +# revision rendered. The composition itself is the newest revision (v2), as it is in a cluster, and the +# harness creates that revision from it; revision 1 is declared here. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xmanualdefaults.manualdefault.diff.example.org +spec: + group: manualdefault.diff.example.org + names: + kind: XManualDefault + plural: xmanualdefaults + scope: Namespaced + defaultCompositionUpdatePolicy: Manual + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xmanualdefaults.manualdefault.diff.example.org +spec: + compositeTypeRef: + apiVersion: manualdefault.diff.example.org/v1alpha1 + kind: XManualDefault + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xmanualdefaults.manualdefault.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: manualdefault.diff.example.org/v1alpha1 + kind: XManualDefault + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml b/internal/diff/integration/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml new file mode 100644 index 00000000..a974f9f5 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml @@ -0,0 +1,13 @@ +# v2 XRD with v1-style composition paths test case (issue #206) +# This XR uses v1-style spec.compositionRef instead of spec.crossplane.compositionRef +apiVersion: v2withv1paths.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-v2xrd-v1paths + namespace: default +spec: + # v1-style composition reference (at spec.compositionRef instead of spec.crossplane.compositionRef) + compositionRef: + name: xnopresources.v2withv1paths.diff.example.org + compositionUpdatePolicy: Automatic + coolField: modified-value diff --git a/internal/diff/integration/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml b/internal/diff/integration/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml new file mode 100644 index 00000000..55419e07 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml @@ -0,0 +1,65 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: multistep-fatal-after-extra-res.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + # Step 1: guarded gotpl. Emits an ExtraResources requirement AND (once + # .extraResources is populated on the inner fetcher iteration) a composed + # resource. Stabilizes cleanly within the single render call. + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- with .extraResources }} + {{ $configMaps := index . "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} + {{- end }} + # Step 2: always fatals. `index .missing.path 0` — .missing is , + # indexing returns "index of untyped nil". This step does NOT emit + # requirements — so rsp.Requirements is nil for this step, but the render + # library has already accumulated step 1's requirements into its outputs. + # This produces the combination the PR's render-loop fix addresses: + # renderErr != nil AND len(output.Requirements) > 0. + - step: always-fatal + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ index .missing.path 0 }} diff --git a/internal/diff/integration/testdata/diff/resources/nested/child-composition.yaml b/internal/diff/integration/testdata/diff/resources/nested/child-composition.yaml new file mode 100644 index 00000000..17ae8f7d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/child-composition.yaml @@ -0,0 +1,33 @@ +# Child composition - creates managed resources downstream +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xchildresources.nested.example.org +spec: + compositeTypeRef: + apiVersion: ns.nested.example.org/v1alpha1 + kind: XChildResource + mode: Pipeline + pipeline: + - step: generate-managed-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-managed + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: managed-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.childField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/nested/child-revision-templating-composition.yaml b/internal/diff/integration/testdata/diff/resources/nested/child-revision-templating-composition.yaml new file mode 100644 index 00000000..54b1faa3 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/child-revision-templating-composition.yaml @@ -0,0 +1,38 @@ +# A child composition whose template reads the CompositionRevision's own name off the nested composite +# and propagates it into the composed resource — the nested counterpart of +# testdata/comp/resources/revision-templating-composition.yaml. A nested XR is rendered from its +# parent's output, which never carries compositionRevisionRef (Crossplane's composite reconciler writes +# that), so unless the render inherits the cluster copy's ref this template has nothing to read. +# Used in place of child-composition.yaml, never alongside it: both target XChildResource. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xchildresources.nested.example.org +spec: + compositeTypeRef: + apiVersion: ns.nested.example.org/v1alpha1 + kind: XChildResource + mode: Pipeline + pipeline: + - step: generate-managed-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-managed + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: managed-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/nested/child-xrd.yaml b/internal/diff/integration/testdata/diff/resources/nested/child-xrd.yaml new file mode 100644 index 00000000..c8da4876 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/child-xrd.yaml @@ -0,0 +1,55 @@ +# Child XRD - this will be created as a composed resource of the parent +# and then will itself create downstream managed resources +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xchildresources.ns.nested.example.org +spec: + group: ns.nested.example.org + names: + kind: XChildResource + plural: xchildresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + childField: + type: string + description: A field for the child resource + tier: + type: string + default: standard + description: Defaulted by the XRD; the parent composition never sets it + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml b/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml new file mode 100644 index 00000000..7004e4f1 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml @@ -0,0 +1,18 @@ +# existing-child-xr.yaml as Crossplane's composite reconciler leaves it once it has selected +# child-revision-templating-composition.yaml and its revision: carrying spec.crossplane.compositionRef +# and, from the harness, compositionRevisionRef. That composition reads the revision name into the +# managed resource, so a nested render that did not inherit the refs would report the managed resource +# as changing. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + annotations: + crossplane.io/composition-resource-name: child-xr + namespace: default +spec: + childField: "existing-value" + crossplane: + compositionRef: + name: xchildresources.nested.example.org + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml b/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml new file mode 100644 index 00000000..3cef855c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml @@ -0,0 +1,9 @@ +# A different field manager sets the XRD-defaulted spec.tier to a non-default value on the child XR, +# which the parent composition renders without it. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + namespace: default +spec: + tier: premium diff --git a/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr.yaml b/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr.yaml new file mode 100644 index 00000000..fa857d35 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/existing-child-xr.yaml @@ -0,0 +1,13 @@ +# Existing child XR in the cluster (created by parent) +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + annotations: + crossplane.io/composition-resource-name: child-xr + namespace: default +spec: + childField: "existing-value" + crossplane: + compositionRef: + name: xchildresources.nested.example.org diff --git a/internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml b/internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml new file mode 100644 index 00000000..de74ec9e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml @@ -0,0 +1,15 @@ +# The managed resource composed by existing-child-xr-revision-ref.yaml through +# child-revision-templating-composition.yaml, so configData holds the revision the child tracks. +# Carries crossplane.io/composition-resource-name (and, from the harness, the composite label) because +# the render adds both: a fixture missing them is reported as modified on every run, which would hide +# whether the revision name propagated. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-child-managed + namespace: default + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: xchildresources.nested.example.org-96afc06 diff --git a/internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource.yaml b/internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource.yaml new file mode 100644 index 00000000..1211a7e4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/existing-managed-resource.yaml @@ -0,0 +1,11 @@ +# Existing managed resource in the cluster (created by child XR) +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-child-managed + namespace: default + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: "existing-value" diff --git a/internal/diff/integration/testdata/diff/resources/nested/existing-parent-xr.yaml b/internal/diff/integration/testdata/diff/resources/nested/existing-parent-xr.yaml new file mode 100644 index 00000000..9ce1afe0 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/existing-parent-xr.yaml @@ -0,0 +1,11 @@ +# Existing parent XR in the cluster +apiVersion: ns.nested.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: "existing-value" + crossplane: + compositionRef: + name: xparentresources.nested.example.org diff --git a/internal/diff/integration/testdata/diff/resources/nested/parent-composition.yaml b/internal/diff/integration/testdata/diff/resources/nested/parent-composition.yaml new file mode 100644 index 00000000..5a4ce994 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/parent-composition.yaml @@ -0,0 +1,32 @@ +# Parent composition - creates a child XR as its composed resource +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xparentresources.nested.example.org +spec: + compositeTypeRef: + apiVersion: ns.nested.example.org/v1alpha1 + kind: XParentResource + mode: Pipeline + pipeline: + - step: generate-child-xr + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nested.example.org/v1alpha1 + kind: XChildResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-child + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: child-xr + spec: + childField: {{ .observed.composite.resource.spec.parentField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/nested/parent-xrd.yaml b/internal/diff/integration/testdata/diff/resources/nested/parent-xrd.yaml new file mode 100644 index 00000000..04de284e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/nested/parent-xrd.yaml @@ -0,0 +1,50 @@ +# Parent XRD - this will create nested XRs as its composed resources +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xparentresources.ns.nested.example.org +spec: + group: ns.nested.example.org + names: + kind: XParentResource + plural: xparentresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parentField: + type: string + description: A field that will be passed to child XR + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/definitions.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/definitions.yaml new file mode 100644 index 00000000..34bff81d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/definitions.yaml @@ -0,0 +1,96 @@ +# A claim-capable XRD and a composition with two revisions, for the revision tests. Each revision's +# template writes "::" +# into the composed resource, so one value shows both which revision rendered and which ref the render +# saw. The composition itself is the newest revision (v2), as it is in a cluster, and the harness creates +# that revision from it; revision 1 is declared here. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xpinneds.pinned.diff.example.org +spec: + group: pinned.diff.example.org + names: + kind: XPinned + plural: xpinneds + claimNames: + kind: Pinned + plural: pinneds + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xpinneds.pinned.diff.example.org +spec: + compositeTypeRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2:{{ .observed.composite.resource.spec.compositionRevisionRef.name }}:{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xpinneds.pinned.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1:{{ .observed.composite.resource.spec.compositionRevisionRef.name }}:{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml new file mode 100644 index 00000000..d0672a40 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml @@ -0,0 +1,10 @@ +# The composed resource of existing-automatic-xr.yaml, as revision 2 rendered it. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: automatic-claim-a1b2c + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v2:xpinneds.pinned.diff.example.org-806289b:existing-value diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml new file mode 100644 index 00000000..ecd3a8a9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml @@ -0,0 +1,15 @@ +# The backing XR of existing-automatic.yaml, tracking revision 2, the latest. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: automatic-claim-a1b2c +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org + claimRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: Pinned + name: automatic-claim + namespace: existing-namespace diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic.yaml new file mode 100644 index 00000000..7d3eda85 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-automatic.yaml @@ -0,0 +1,19 @@ +# An Automatic claim bound to a backing XR tracking revision 2, the latest: existing-automatic-xr.yaml, +# whose composed resource, as revision 2 rendered it, is existing-automatic-downstream.yaml. The claim +# carries the ref the claim syncer copies back from its XR under Automatic. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: automatic-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org + compositionRevisionRef: + name: xpinneds.pinned.diff.example.org-806289b + resourceRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + name: automatic-claim-a1b2c diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml new file mode 100644 index 00000000..8fca08a8 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml @@ -0,0 +1,10 @@ +# The composed resource of existing-lagging-automatic-xr.yaml, as revision 1 rendered it. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: lagging-xr + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1:xpinneds.pinned.diff.example.org-3d33597:existing-value diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml new file mode 100644 index 00000000..553bd228 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml @@ -0,0 +1,16 @@ +# An Automatic XR, bound to no claim, whose compositionRevisionRef still names revision 1 although revision +# 2 is the latest. Its composed resource, as revision 1 rendered it, is +# existing-lagging-automatic-downstream.yaml. Crossplane re-points an Automatic composite at the latest +# revision on its next reconcile, so the ref lags until then: say, when the composite is diffed straight +# after its composition changed. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: lagging-xr +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org + compositionRevisionRef: + name: xpinneds.pinned.diff.example.org-3d33597 diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml new file mode 100644 index 00000000..f6d3a87d --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml @@ -0,0 +1,10 @@ +# The composed resource of existing-manual-xr.yaml, as revision 1 rendered it. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: manual-claim-x1y2z + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1:xpinneds.pinned.diff.example.org-3d33597:existing-value diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml new file mode 100644 index 00000000..a5d186fa --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml @@ -0,0 +1,17 @@ +# The backing XR of existing-manual.yaml, pinned to revision 1. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: manual-claim-x1y2z +spec: + coolField: existing-value + compositionUpdatePolicy: Manual + compositionRef: + name: xpinneds.pinned.diff.example.org + compositionRevisionRef: + name: xpinneds.pinned.diff.example.org-3d33597 + claimRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: Pinned + name: manual-claim + namespace: existing-namespace diff --git a/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual.yaml b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual.yaml new file mode 100644 index 00000000..8ece1692 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/pinned-claim/existing-manual.yaml @@ -0,0 +1,19 @@ +# A Manual claim bound to a backing XR that is pinned to revision 1, the older of the composition's two +# revisions: existing-manual-xr.yaml, whose composed resource, as revision 1 rendered it, is +# existing-manual-downstream.yaml. The claim carries no compositionRevisionRef: the claim syncer copies +# the ref back to a claim only under Automatic, so a Manual claim has one only if its author set it. The +# pin therefore lives on the backing XR alone. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: manual-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositionUpdatePolicy: Manual + compositionRef: + name: xpinneds.pinned.diff.example.org + resourceRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + name: manual-claim-x1y2z diff --git a/internal/diff/integration/testdata/diff/resources/production-composition.yaml b/internal/diff/integration/testdata/diff/resources/production-composition.yaml new file mode 100644 index 00000000..1e0b06ba --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/production-composition.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: production-composition + labels: + provider: aws + environment: production +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-production-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: production-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: "production" + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/rejecting-admission-policy.yaml b/internal/diff/integration/testdata/diff/resources/rejecting-admission-policy.yaml new file mode 100644 index 00000000..ff10e3eb --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/rejecting-admission-policy.yaml @@ -0,0 +1,38 @@ +# A ValidatingAdmissionPolicy that refuses XDownstreamResources carrying the configData values the +# diff fixtures render, so a dry-run against the apiserver is rejected (crossplane-diff#334). +# +# Why a VAP and not a CEL x-kubernetes-validations rule on the CRD: crossplane-diff's own schema +# validator evaluates CEL rules locally, before the diff calculator runs, so a CEL rule would reject +# the resource without the apiserver ever being consulted. The test would pass on code that never +# dry-ran anything — the exact false positive this instrument has to avoid. A VAP is invisible to the +# local validator, needs no webhook server, and has been GA since Kubernetes 1.30. +# +# CREATE covers the addition path (dry-run create); UPDATE covers the existing-resource path, where +# a server-side-apply dry-run reaches admission as an UPDATE. The same policy therefore drives the +# rejection tests for both, including the deliberate reclassification of an existing resource's +# rejection from a tool error to a validation failure. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: reject-marked-downstream-resources +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["ns.nop.example.org"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE"] + resources: ["xdownstreamresources"] + validations: + - expression: >- + !has(object.spec) || !has(object.spec.forProvider) || !has(object.spec.forProvider.configData) + || !(object.spec.forProvider.configData in ['new-value', 'modified-value']) + message: "this cluster refuses that configData value" +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: reject-marked-downstream-resources +spec: + policyName: reject-marked-downstream-resources + validationActions: ["Deny"] diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-cluster-composition.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-cluster-composition.yaml new file mode 100644 index 00000000..766a06f8 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-cluster-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml new file mode 100644 index 00000000..4d6bc8d1 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml @@ -0,0 +1,9 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + name: resource-to-be-kept +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-composition.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-composition.yaml new file mode 100644 index 00000000..b386e452 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-composition.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-composition.yaml new file mode 100644 index 00000000..67995f9f --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xlegacychildresources.legacycluster.diff.example.org +spec: + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XLegacyChildResource + mode: Pipeline + pipeline: + - step: generate-managed-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-managed + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: managed-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.childField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-xrd.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-xrd.yaml new file mode 100644 index 00000000..ce48cad4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-child-xrd.yaml @@ -0,0 +1,24 @@ +# A legacy (v1) XR that a legacy XR composes, so a removal hierarchy at the v1 spec.resourceRefs path is +# two real XRs deep, as in a cluster: removal-test-legacy-nested-xr.yaml is one. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xlegacychildresources.legacycluster.diff.example.org +spec: + group: legacycluster.diff.example.org + names: + kind: XLegacyChildResource + plural: xlegacychildresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + childField: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-legacy-composition.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-composition.yaml new file mode 100644 index 00000000..e8fa8909 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml new file mode 100644 index 00000000..10d846bf --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml @@ -0,0 +1,11 @@ +# The managed resource removal-test-legacy-child-composition.yaml composed for +# removal-test-legacy-nested-xr.yaml. +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: resource-to-be-removed-managed + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: child-value diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr.yaml new file mode 100644 index 00000000..e421b6f2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-legacy-nested-xr.yaml @@ -0,0 +1,13 @@ +# removal-test-nested-xr.yaml at the legacy (v1) paths: the nested XR removal-test-legacy-composition +# once composed as resource2. It composes removal-test-legacy-nested-xr-managed.yaml in turn, through +# the revision the harness creates for removal-test-legacy-child-composition.yaml. +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XLegacyChildResource +metadata: + name: resource-to-be-removed + annotations: + crossplane.io/composition-resource-name: resource2 +spec: + childField: child-value + compositionRef: + name: xlegacychildresources.legacycluster.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml new file mode 100644 index 00000000..e03c4398 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml @@ -0,0 +1,9 @@ +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + name: resource-to-be-kept +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-nested-xr-managed.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-nested-xr-managed.yaml new file mode 100644 index 00000000..0bde272b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-nested-xr-managed.yaml @@ -0,0 +1,11 @@ +# The managed resource nested/child-composition.yaml composed for removal-test-nested-xr.yaml. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: resource-to-be-removed-managed + namespace: default + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: child-value diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-nested-xr.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-nested-xr.yaml new file mode 100644 index 00000000..6ff9ea7b --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-nested-xr.yaml @@ -0,0 +1,15 @@ +# The nested XR the removal-test compositions once composed as resource2, as Crossplane left it, with +# the composition of its own kind it was composed with. It composes removal-test-nested-xr-managed.yaml in +# turn, so removing it removes that too. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: resource-to-be-removed + namespace: default + annotations: + crossplane.io/composition-resource-name: resource2 +spec: + childField: child-value + crossplane: + compositionRef: + name: xchildresources.nested.example.org diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml new file mode 100644 index 00000000..4072c25a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + name: resource-to-be-kept + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml new file mode 100644 index 00000000..385389d9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource2 + name: resource-to-be-removed + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml b/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml new file mode 100644 index 00000000..fb14f03a --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml @@ -0,0 +1,15 @@ +# A resource test-resource once composed as resource3, whose controller reference has since been +# removed (say, by a user orphaning it). It keeps its labels and annotations, and the XR's resourceRefs +# still name it (existing-xr-listing-uncontrolled-resources.yaml). +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource3 + labels: + crossplane.io/composite: test-resource + name: resource-without-controller + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/internal/diff/integration/testdata/diff/resources/rendered-defaults/definitions.yaml b/internal/diff/integration/testdata/diff/resources/rendered-defaults/definitions.yaml new file mode 100644 index 00000000..5976be20 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/rendered-defaults/definitions.yaml @@ -0,0 +1,129 @@ +# XRDs whose defaults a composition reads, one modern (v2, machinery under spec.crossplane) and one +# legacy (v1, machinery directly under spec), each with a composition that writes the composite's +# defaulted fields into a composed resource. The CRDs are generated from these XRDs (upstream xcrd), so +# the XR CRDs default region from the XRD's schema and, for the legacy XR, spec.compositionUpdatePolicy +# from defaultCompositionUpdatePolicy, as in a real cluster. A render input that was not defaulted would +# write "" instead. +# +# The modern template does not read spec.crossplane.compositionUpdatePolicy: its CRD defaults the +# policy only inside an existing spec.crossplane, which a new XR's manifest does not have, so the tool +# renders a new modern XR without one. (In a cluster Crossplane writes spec.crossplane before it first +# composes, so the apiserver defaults the policy by then.) +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xregionaldatabases.rendered-defaults.diff.example.org +spec: + group: rendered-defaults.diff.example.org + names: + kind: XRegionalDatabase + plural: xregionaldatabases + scope: Namespaced + defaultCompositionUpdatePolicy: Automatic + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + size: + type: string + region: + type: string + default: us-east-1 +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xregionaldatabases.rendered-defaults.diff.example.org +spec: + compositeTypeRef: + apiVersion: rendered-defaults.diff.example.org/v1alpha1 + kind: XRegionalDatabase + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.region }}/{{ .observed.composite.resource.spec.size }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xlegacyregionaldatabases.rendered-defaults.diff.example.org +spec: + group: rendered-defaults.diff.example.org + names: + kind: XLegacyRegionalDatabase + plural: xlegacyregionaldatabases + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + size: + type: string + region: + type: string + default: eu-west-1 +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xlegacyregionaldatabases.rendered-defaults.diff.example.org +spec: + compositeTypeRef: + apiVersion: rendered-defaults.diff.example.org/v1alpha1 + kind: XLegacyRegionalDatabase + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.region }}/{{ .observed.composite.resource.spec.size }}/{{ .observed.composite.resource.spec.compositionUpdatePolicy }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/selector-bound/existing-downstream.yaml b/internal/diff/integration/testdata/diff/resources/selector-bound/existing-downstream.yaml new file mode 100644 index 00000000..8e54b1b0 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/selector-bound/existing-downstream.yaml @@ -0,0 +1,11 @@ +# The composed resource of existing-xr.yaml, as composition selector-bound-a rendered it. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: selector-bound + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: a-existing-value diff --git a/internal/diff/integration/testdata/diff/resources/selector-bound/existing-xr.yaml b/internal/diff/integration/testdata/diff/resources/selector-bound/existing-xr.yaml new file mode 100644 index 00000000..9711b44c --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/selector-bound/existing-xr.yaml @@ -0,0 +1,16 @@ +# A composite whose compositionSelector matches both compositions in setup.yaml, and which Crossplane +# bound to the first, selector-bound-a. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-bound + namespace: default +spec: + coolField: existing-value + crossplane: + compositionSelector: + matchLabels: + binding: shared + compositionRef: + name: selector-bound-a + compositionUpdatePolicy: Automatic diff --git a/internal/diff/integration/testdata/diff/resources/selector-bound/setup.yaml b/internal/diff/integration/testdata/diff/resources/selector-bound/setup.yaml new file mode 100644 index 00000000..e33e1e16 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/selector-bound/setup.yaml @@ -0,0 +1,74 @@ +# Two compositions that both match one compositionSelector. existing-xr.yaml is a composite Crossplane +# has already bound to the first, and existing-downstream.yaml its composed resource. Crossplane selects +# a composition once, when the composite has no compositionRef, and never re-selects +# (composite.APILabelSelectorResolver), so the composite stays on composition A however many +# compositions its selector matches. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: selector-bound-a + labels: + binding: shared +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: a-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: selector-bound-b + labels: + binding: shared +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: b-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-composition.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-composition.yaml new file mode 100644 index 00000000..3a56fdf9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-composition.yaml @@ -0,0 +1,53 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- # Stage 0 (composition-resource-name starts with stage0-) + - ^stage1- # Stage 1 (hidden until stage 0 is ready, unless it already exists) diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-gating-composition.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-gating-composition.yaml new file mode 100644 index 00000000..2c912698 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-gating-composition.yaml @@ -0,0 +1,68 @@ +# Composition used by tests that need function-sequencer to actually gate later stages. +# +# IMPORTANT notes: +# +# 1. function-sequencer matches on the resource's composition-resource-name +# (the SDK's resource.Name), NOT on metadata.name. The rules use regexes that +# match the composition-resource-names emitted by go-templating +# (stage0-resource / stage1-resource). +# +# 2. function-sequencer's gating checks DesiredComposed.Ready == True for earlier +# stages. DesiredComposed.Ready is set by function-auto-ready based on the +# observed state. Therefore auto-ready MUST run BEFORE sequencer for eventual- +# state to propagate (synthesize observed Ready -> auto-ready sets desired +# Ready -> sequencer unlocks the next stage). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + # auto-ready FIRST so sequencer sees desired.stage0.Ready reflecting observed. + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- # Stage 0 (composition-resource-name starts with stage0-) + - ^stage1- # Stage 1 (gated until stage0- desired.Ready==True) diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-stage0-downstream.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-stage0-downstream.yaml new file mode 100644 index 00000000..fa1608b9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-stage0-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: test-value diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-stage1-downstream.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-stage1-downstream.yaml new file mode 100644 index 00000000..23e1180e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-stage1-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage1-resource + name: 1-stage1-resource + namespace: default +spec: + forProvider: + configData: test-value diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-composition.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-composition.yaml new file mode 100644 index 00000000..706f8194 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-composition.yaml @@ -0,0 +1,67 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-with-envconfig-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: get-environment-configs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: test-env-config + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ $envConfig := index .context "apiextensions.crossplane.io/environment" }} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: {{ $envConfig.environment }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: {{ $envConfig.environment }} + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^0- # Stage 0 resources (rendered first) + - ^1- # Stage 1 resources (hidden until stage 0 is ready) + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-xr.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-xr.yaml new file mode 100644 index 00000000..52c724cd --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-with-envconfig-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-envconfig-test + namespace: default +spec: + crossplane: + compositionRef: + name: sequencer-with-envconfig-composition + coolField: "test-value" diff --git a/internal/diff/integration/testdata/diff/resources/sequencer-xr.yaml b/internal/diff/integration/testdata/diff/resources/sequencer-xr.yaml new file mode 100644 index 00000000..de0f52ec --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/sequencer-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-test + namespace: default +spec: + coolField: "test-value" diff --git a/internal/diff/integration/testdata/diff/resources/shared-child-composition.yaml b/internal/diff/integration/testdata/diff/resources/shared-child-composition.yaml new file mode 100644 index 00000000..c8e42f17 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/shared-child-composition.yaml @@ -0,0 +1,35 @@ +# Composes one child with a FIXED name, whatever the XR is called, so two XRs using this composition +# in one namespace both render the same object. In the cluster the first XR to create it keeps it and +# the other fails to reconcile it, so crossplane-diff must refuse to combine their diffs (#476). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-shared-child.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-shared-child + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: shared-child + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: shared-child + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/staging-composition.yaml b/internal/diff/integration/testdata/diff/resources/staging-composition.yaml new file mode 100644 index 00000000..5cf736f9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/staging-composition.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: staging-composition + labels: + provider: aws + environment: staging +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-staging-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: staging-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: "staging" + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml b/internal/diff/integration/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml new file mode 100644 index 00000000..26561167 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml @@ -0,0 +1,44 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: unguarded-templated-extra-resources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- $configMaps := index .extraResources "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} diff --git a/internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml b/internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml new file mode 100644 index 00000000..380d66c5 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.v2withv1paths.diff.example.org +spec: + compositeTypeRef: + apiVersion: v2withv1paths.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths.yaml b/internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths.yaml new file mode 100644 index 00000000..e150c8a9 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/v2-xrd-with-v1-paths.yaml @@ -0,0 +1,101 @@ +# This is a v2 XRD that explicitly supports both v1-style and v2-style composition paths. +# Used to test issue #206: v2 XRs using v1-style composition paths are not recognized. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.v2withv1paths.diff.example.org +spec: + group: v2withv1paths.diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + # v1-style composition fields (for backwards compatibility) + compositionRef: + type: object + properties: + name: + type: string + compositionRevisionRef: + type: object + properties: + name: + type: string + compositionUpdatePolicy: + type: string + enum: + - Automatic + - Manual + compositionSelector: + type: object + properties: + matchLabels: + type: object + additionalProperties: + type: string + # v2-style composition fields (nested under crossplane) + crossplane: + type: object + properties: + compositionRef: + type: object + properties: + name: + type: string + compositionRevisionRef: + type: object + properties: + name: + type: string + compositionUpdatePolicy: + type: string + enum: + - Automatic + - Manual + compositionSelector: + type: object + properties: + matchLabels: + type: object + additionalProperties: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/xdownstreamenvresource-xrd.yaml b/internal/diff/integration/testdata/diff/resources/xdownstreamenvresource-xrd.yaml new file mode 100644 index 00000000..5e2954ba --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/xdownstreamenvresource-xrd.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdownstreamenvresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XDownstreamEnvResource + plural: xdownstreamenvresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + region: + type: string + environment: + type: string + serviceLevel: + type: string diff --git a/internal/diff/integration/testdata/diff/resources/xrd-concurrent.yaml b/internal/diff/integration/testdata/diff/resources/xrd-concurrent.yaml new file mode 100644 index 00000000..350d2c84 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/xrd-concurrent.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xconcurrenttests.concurrent.diff.example.org +spec: + group: concurrent.diff.example.org + names: + kind: XConcurrentTest + plural: xconcurrenttests + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + config: + type: string + description: Configuration data for the test + default: "test-config" + dataField: + type: string + description: Additional data field + default: "default-data" + required: + - config \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/xrd-with-defaults.yaml b/internal/diff/integration/testdata/diff/resources/xrd-with-defaults.yaml new file mode 100644 index 00000000..bbd6eb8e --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/xrd-with-defaults.yaml @@ -0,0 +1,79 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xtestdefaultresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XTestDefaultResource + plural: xtestdefaultresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + default: "us-east-1" + description: "AWS region with default value" + size: + type: string + default: "small" + description: "Instance size with default" + settings: + type: object + default: + enabled: true + timeout: 30 + properties: + enabled: + type: boolean + default: true + timeout: + type: integer + default: 30 + retries: + type: integer + default: 3 + tags: + type: object + default: + environment: "development" + properties: + environment: + type: string + default: "development" + team: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/resources/xrd.yaml b/internal/diff/integration/testdata/diff/resources/xrd.yaml new file mode 100644 index 00000000..b4b57069 --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/xrd.yaml @@ -0,0 +1,51 @@ +# This is a v2 XRD so instances of the XR will be namespace scoped. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/internal/diff/integration/testdata/diff/shared-child-xrs.yaml b/internal/diff/integration/testdata/diff/shared-child-xrs.yaml new file mode 100644 index 00000000..d5fd3734 --- /dev/null +++ b/internal/diff/integration/testdata/diff/shared-child-xrs.yaml @@ -0,0 +1,24 @@ +# Two distinct XRs on the shared-child composition, in one namespace and with the SAME spec, so they +# render byte-identical children. They still contend for that child: the collision is decided by who +# would control it, not by whether the renderings match. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: shared-child-owner-a + namespace: default +spec: + coolField: "same-value" + crossplane: + compositionRef: + name: xnopresources-shared-child.diff.example.org +--- +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: shared-child-owner-b + namespace: default +spec: + coolField: "same-value" + crossplane: + compositionRef: + name: xnopresources-shared-child.diff.example.org diff --git a/internal/diff/integration/testdata/diff/unchanged-automatic-pinned-claim.yaml b/internal/diff/integration/testdata/diff/unchanged-automatic-pinned-claim.yaml new file mode 100644 index 00000000..c22ad2e2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/unchanged-automatic-pinned-claim.yaml @@ -0,0 +1,12 @@ +# resources/pinned-claim/existing-automatic.yaml's claim as its author keeps it: unchanged, but without +# the compositionRevisionRef the claim syncer copied onto the cluster copy (issue #498). +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: automatic-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org diff --git a/internal/diff/integration/testdata/diff/unchanged-lagging-automatic-xr.yaml b/internal/diff/integration/testdata/diff/unchanged-lagging-automatic-xr.yaml new file mode 100644 index 00000000..10905951 --- /dev/null +++ b/internal/diff/integration/testdata/diff/unchanged-lagging-automatic-xr.yaml @@ -0,0 +1,11 @@ +# resources/pinned-claim/existing-lagging-automatic-xr.yaml's XR as its author keeps it: unchanged, and +# without the compositionRevisionRef Crossplane's composite reconciler writes. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: lagging-xr +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org diff --git a/internal/diff/integration/testdata/diff/unmodified-xr.yaml b/internal/diff/integration/testdata/diff/unmodified-xr.yaml new file mode 100644 index 00000000..9d00b3d4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/unmodified-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" diff --git a/internal/diff/integration/testdata/diff/xr-with-ambiguous-selector.yaml b/internal/diff/integration/testdata/diff/xr-with-ambiguous-selector.yaml new file mode 100644 index 00000000..86c1d6c4 --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-ambiguous-selector.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionSelector: + matchLabels: + provider: aws diff --git a/internal/diff/integration/testdata/diff/xr-with-argocd-annotations.yaml b/internal/diff/integration/testdata/diff/xr-with-argocd-annotations.yaml new file mode 100644 index 00000000..f9dcd869 --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-argocd-annotations.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app:/XNopResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource","namespace":"default"}} + labels: + argocd.argoproj.io/instance: test-app +spec: + coolField: "new-value" diff --git a/internal/diff/integration/testdata/diff/xr-with-argocd-mixed-changes.yaml b/internal/diff/integration/testdata/diff/xr-with-argocd-mixed-changes.yaml new file mode 100644 index 00000000..3a399e9f --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-argocd-mixed-changes.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app:/XNopResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource","namespace":"default"}} + labels: + argocd.argoproj.io/instance: test-app +spec: + coolField: "modified-value" diff --git a/internal/diff/integration/testdata/diff/xr-with-composition-ref.yaml b/internal/diff/integration/testdata/diff/xr-with-composition-ref.yaml new file mode 100644 index 00000000..e417e325 --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-composition-ref.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionRef: + name: production-composition diff --git a/internal/diff/integration/testdata/diff/xr-with-composition-selector.yaml b/internal/diff/integration/testdata/diff/xr-with-composition-selector.yaml new file mode 100644 index 00000000..bf5bdc13 --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-composition-selector.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionSelector: + matchLabels: + environment: staging + provider: aws diff --git a/internal/diff/integration/testdata/diff/xr-with-missing-defaults.yaml b/internal/diff/integration/testdata/diff/xr-with-missing-defaults.yaml new file mode 100644 index 00000000..d1d9007d --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-missing-defaults.yaml @@ -0,0 +1,9 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default +spec: + # Only specify one field, leaving others to use defaults from XRD + size: "large" + # Note: region, settings, and tags are not specified and should get defaults \ No newline at end of file diff --git a/internal/diff/integration/testdata/diff/xr-with-overridden-defaults.yaml b/internal/diff/integration/testdata/diff/xr-with-overridden-defaults.yaml new file mode 100644 index 00000000..53f5dfc2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/xr-with-overridden-defaults.yaml @@ -0,0 +1,19 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-overrides + namespace: default +spec: + # Override the default region (default: us-east-1) + region: "us-west-2" + # Override the default size (default: small) + size: "xlarge" + # Partially override settings (default: {enabled: true, timeout: 30}) + settings: + enabled: false # Override default true + timeout: 60 # Override default 30 + # retries should get default value of 3 + # Override default tags (default: {environment: "development"}) + tags: + environment: "production" # Override default "development" + team: "platform" # New field, no default to override \ No newline at end of file From 52fd44885f2f31823f418ab40bfc76c734595e23 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 12:15:53 -0400 Subject: [PATCH 020/126] fix(diff): lint the ported integration tests 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 17 ++++-- internal/diff/integration/it_utils_test.go | 19 ++++++- .../diff/integration/simulate_harness_test.go | 54 ++++++++++++------- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 5a0e1396..b712c8ab 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -14,10 +14,6 @@ import ( "testing" "time" - dclient "github.com/crossplane/cli/v2/internal/diff/client" - dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" - tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/google/go-cmp/cmp" "github.com/sergi/go-diff/diffmatchpatch" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" @@ -31,6 +27,11 @@ import ( xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" xpextv2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + dclient "github.com/crossplane/cli/v2/internal/diff/client" + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) const ( @@ -421,6 +422,14 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC return } + // envtest needs kube-apiserver/etcd binaries it cannot download itself. The dev shells and + // nix test environment point KUBEBUILDER_ASSETS at them (see setup-envtest); without them + // every case would die in envtest.Start rather than skip. + if !envtestBinariesAvailable() { + t.Skip("env: no envtest binaries (KUBEBUILDER_ASSETS unset)") + return + } + // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff // cannot race the apiserver's policy informer. See the field's doc comment. if err := awaitAdmissionDenial(ctx, k8sClient, tt.awaitAdmissionDenialFor); err != nil { diff --git a/internal/diff/integration/it_utils_test.go b/internal/diff/integration/it_utils_test.go index 15baa66f..0c887938 100644 --- a/internal/diff/integration/it_utils_test.go +++ b/internal/diff/integration/it_utils_test.go @@ -15,7 +15,6 @@ import ( "testing" "time" - tu "github.com/crossplane/cli/v2/internal/diff/testutils" gyaml "gopkg.in/yaml.v3" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -32,6 +31,8 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/meta" xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) // CrossplaneFieldManager is the field manager used for SSA when setting up Crossplane-managed resources. @@ -54,7 +55,15 @@ var compositeReconcilerFields = [][]string{ // Testing data for integration tests +// The helpers below are carried verbatim from crossplane-diff's +// diff_it_utils_test.go, where they back the same builder surface. Some are +// not referenced by a case on this branch yet (the cases that used them are +// gap-skipped, and golangci's unused check is compile-time), so keep them +// ready for the cases that will use them once the gaps close. +// // createTestCompositionWithExtraResources creates a test Composition with a function-extra-resources step. +// +//nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped func createTestCompositionWithExtraResources() (*xpextv1.Composition, error) { pipelineMode := xpextv1.CompositionModePipeline @@ -142,6 +151,8 @@ func createTestCompositionWithExtraResources() (*xpextv1.Composition, error) { } // createTestXRD creates a test XRD for the XR. +// +//nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped func createTestXRD() *xpextv1.CompositeResourceDefinition { return tu.NewXRD("xexampleresources.example.org", "example.org", "XExampleResource"). WithPlural("xexampleresources"). @@ -174,6 +185,8 @@ func createTestXRD() *xpextv1.CompositeResourceDefinition { } // createExtraResource creates a test extra resource. +// +//nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped func createExtraResource() *un.Unstructured { return &un.Unstructured{ Object: map[string]any{ @@ -193,6 +206,8 @@ func createExtraResource() *un.Unstructured { } // createExistingComposedResource creates an existing composed resource with different values. +// +//nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped func createExistingComposedResource() *un.Unstructured { return &un.Unstructured{ Object: map[string]any{ @@ -227,6 +242,8 @@ func createExistingComposedResource() *un.Unstructured { } // createMatchingComposedResource creates a composed resource that matches what would be rendered. +// +//nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped func createMatchingComposedResource() *un.Unstructured { return &un.Unstructured{ Object: map[string]any{ diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 315246bf..19eb12ef 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -49,6 +49,8 @@ package integration import ( "context" "io" + "os" + "path/filepath" "strconv" "strings" "sync" @@ -75,22 +77,22 @@ import ( // positional files. Defaults match crossplane-diff's, including the // engineLimitVars defaults main.go feeds kong. type simArgs struct { - Timeout time.Duration `default:"1m" help:"How long to run before timing out."` - Namespace string ` help:"Namespace of the composite resources."` - Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` - OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2)." name:"output-version"` - NoColor bool ` help:"Disable colorized output." name:"no-color"` - MaxNestedDepth int `default:"${max_nested_depth}" help:"Maximum depth for nested XR recursion." name:"max-nested-depth"` - MaxIterations int `default:"${max_iterations}" help:"Maximum render iterations." name:"max-iterations"` - IgnorePaths []string ` help:"Paths to ignore in diffs." name:"ignore-paths"` - FunctionCredentials string ` help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` - EventualState bool ` help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` - DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` - Resource []string `sep:"," help:"Composite resources to scope to."` - IncludeManual bool ` help:"Include composites with a manual update policy." name:"include-manual"` - AnalyzeUnchanged bool ` help:"Analyze compositions identical to their in-cluster version (deprecated; --analyze-on=always)." name:"analyze-unchanged"` - AnalyzeOn string ` help:"Smallest composition change that triggers per-composite impact analysis." name:"analyze-on"` - Files []string `arg:"" optional:"" help:"Input files."` + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + Namespace string `help:"Namespace of the composite resources."` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` + OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2)." name:"output-version"` + NoColor bool `help:"Disable colorized output." name:"no-color"` + MaxNestedDepth int `default:"${max_nested_depth}" help:"Maximum depth for nested XR recursion." name:"max-nested-depth"` + MaxIterations int `default:"${max_iterations}" help:"Maximum render iterations." name:"max-iterations"` + IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` + FunctionCredentials string `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` + EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` + Resource []string `help:"Composite resources to scope to." sep:","` + IncludeManual bool `help:"Include composites with a manual update policy." name:"include-manual"` + AnalyzeUnchanged bool `help:"Analyze compositions identical to their in-cluster version (deprecated; --analyze-on=always)." name:"analyze-unchanged"` + AnalyzeOn string `help:"Smallest composition change that triggers per-composite impact analysis." name:"analyze-on"` + Files []string `arg:"" help:"Input files." optional:""` } // parseSimulateArgs parses one case's CLI-style args. A parse failure is @@ -313,7 +315,8 @@ func processorOptions(a *simArgs, stdout, stderr io.Writer, logger logging.Logge // user passed --ignore-paths" indistinguishable from "we always ignore // something", which comp-diff relies on to tell an edited composition // from a merely re-applied one. - opts := []dp.ProcessorOption{ + opts := make([]dp.ProcessorOption, 0, 15) + opts = append(opts, dp.WithLogger(logger), dp.WithAdvisories(advisories), dp.WithColorize(!a.NoColor), @@ -327,7 +330,7 @@ func processorOptions(a *simArgs, stdout, stderr io.Writer, logger logging.Logge dp.WithAnalyzeOn(a.analyzeOn()), dp.WithStdout(stdout), dp.WithStderr(stderr), - } + ) var outputFormat renderer.OutputFormat @@ -396,3 +399,18 @@ var dockerAvailable = sync.OnceValue(func() bool { type ExitCode struct { Code int } + +// envtestBinariesAvailable reports whether the envtest control plane's +// binaries are reachable: KUBEBUILDER_ASSETS points at them, or the +// setup-envtest default install path exists. +func envtestBinariesAvailable() bool { + if dir := os.Getenv("KUBEBUILDER_ASSETS"); dir != "" { + if _, err := os.Stat(filepath.Join(dir, "kube-apiserver")); err == nil { + return true + } + } + + _, err := os.Stat("/usr/local/kubebuilder/bin/kube-apiserver") + + return err == nil +} From 7fc39fae1ebc2d4bdf19bea9925c52fb1942c7a1 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 12:19:39 -0400 Subject: [PATCH 021/126] fix(diff): skip integration environment gaps before the cluster starts 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/README.md | 58 +++++++++++++++++++ internal/diff/integration/integration_test.go | 26 ++++++--- 2 files changed, 75 insertions(+), 9 deletions(-) create mode 100644 internal/diff/integration/README.md diff --git a/internal/diff/integration/README.md b/internal/diff/integration/README.md new file mode 100644 index 00000000..5f73b4fe --- /dev/null +++ b/internal/diff/integration/README.md @@ -0,0 +1,58 @@ +# crossplane-diff integration tests, ported + +This package ports crossplane-diff's integration tests (origin/main 6a140b6: +`cmd/diff/diff_integration_test.go`, `diff_it_utils_test.go`, `main_test.go` +and the `testdata` tree) case for case. The case tables are verbatim; only +the harness wiring differs. + +## How a case runs + +Every case is CLI-style arguments plus an expected exit code and expected +output, funneled through one function: `runSimulate` in +`simulate_harness_test.go`. The harness wires this repository's ported +diff engine (`internal/diff/diffprocessor`) the way crossplane-diff's +`comp` and `xr` commands wire it — same options, same load path, same +`DetermineExitCode` — and reports the exit code the command would exit +with. + +When `crossplane project simulate` grows the surface these cases need, +`runSimulate`'s body is replaced with a call to the real command. No case +table changes. + +## Skips + +Cases are never dropped. A case the current simulate surface cannot +express is ported in full and skipped with a greppable reason: + +- `gap:` — the simulate command lacks the feature. Enable the + cases by implementing the feature and removing the gap from + `simulateGap`. +- `gap:flag-parity-not-merged ()` — the flag exists on branch + `jco-simulate-flag-parity` but is not merged into this branch yet. The + harness already parses and threads these flags; the cases run as soon + as the merge lands. +- `env:` — the environment lacks something. The render engine + runs functions as Docker containers (no Docker daemon skips the case), + and envtest needs `KUBEBUILDER_ASSETS` pointing at kube-apiserver/etcd + binaries. + +A small number of skips come from crossplane-diff itself (`skip: true` +in the source tables, with the upstream reason carried verbatim). + +## Running + +```sh +# Full suite (Docker + envtest assets required): +CROSSPLANE_CRDS_DIR= \ +KUBEBUILDER_ASSETS=$(setup-envtest use -p path) \ +go test -v ./internal/diff/integration/ + +# One case: +go test -run 'TestCompDiffIntegration/CompositionChangeImpactsXRs' ./internal/diff/integration/ +``` + +`CROSSPLANE_CRDS_DIR` is set for you by `nix run .#test` (the pinned +Crossplane v2.4.1 CRDs from `nix/crossplane-crds.nix`). Without any of +these environment pieces the suite skips with an `env:` reason rather +than fail, so `go test ./...` stays green everywhere the unit suite +runs. diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index b712c8ab..ec020169 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -5,6 +5,7 @@ import ( "context" "fmt" "io" + "os" "path/filepath" run "runtime" "slices" @@ -160,7 +161,7 @@ func createTestScheme() *runtime.Scheme { // tt's setupFiles, crossplaneManagedResources, fieldManagerApplies and deleteAfterSetup. It returns the // cluster's config, a client for it, and a context bounded by the case's timeout, which it also returns. // The cluster is stopped and the context cancelled when the test ends. -func startIntegrationCluster(t *testing.T, testType DiffTestType, tt IntegrationTestCase) (*rest.Config, client.Client, context.Context, time.Duration) { +func startIntegrationCluster(t *testing.T, testType DiffTestType, tt IntegrationTestCase, testTimeout time.Duration) (*rest.Config, client.Client, context.Context, time.Duration) { t.Helper() // Create a fresh scheme for each test to avoid concurrent map access. @@ -216,12 +217,6 @@ func startIntegrationCluster(t *testing.T, testType DiffTestType, tt Integration t.Fatalf("failed to create client: %v", err) } - // Use custom timeout if specified, otherwise use default - testTimeout := defaultTimeout - if tt.timeout > 0 { - testTimeout = tt.timeout - } - ctx, cancel := context.WithTimeout(t.Context(), testTimeout) t.Cleanup(cancel) @@ -312,7 +307,11 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC return } - cfg, k8sClient, ctx, testTimeout := startIntegrationCluster(t, testType, tt) + // Use custom timeout if specified, otherwise use default + testTimeout := defaultTimeout + if tt.timeout > 0 { + testTimeout = tt.timeout + } // Set up the test files testFiles := make([]string, 0, len(tt.inputFiles)) @@ -430,6 +429,15 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC return } + // The pinned Crossplane CRDs come from the environment the test runners set (see + // tu.PinnedCrossplaneCRDsDir); without it every case would fatal in cluster setup. + if os.Getenv("CROSSPLANE_CRDS_DIR") == "" { + t.Skip("env: CROSSPLANE_CRDS_DIR unset (run via nix run .#test or set it to Crossplane's cluster/crds)") + return + } + + cfg, k8sClient, ctx, _ := startIntegrationCluster(t, testType, tt, testTimeout) + // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff // cannot race the apiserver's policy informer. See the field's doc comment. if err := awaitAdmissionDenial(ctx, k8sClient, tt.awaitAdmissionDenialFor); err != nil { @@ -5992,7 +6000,7 @@ func TestDiffSingleResourceConcurrent(t *testing.T) { "testdata/diff/resources/existing-downstream-resource.yaml", ), }, - }) + }, defaultTimeout) // One modified and one new XR, each diffed several times at once. var inputs []*un.Unstructured From 70a5abd84fa6e7dc48fd09ef25f0c325c0ee49f7 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 12:33:05 -0400 Subject: [PATCH 022/126] fix(diff): guard the concurrency test's cluster startup like the runner 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index ec020169..3c99f449 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -5988,6 +5988,23 @@ func TestDiffSingleResourceConcurrent(t *testing.T) { tu.SetupKubeTestLogger(t) + // Same environment guards the runner applies before cluster startup: this test needs the + // same pieces (Docker to render, envtest binaries, the pinned Crossplane CRDs). + if !dockerAvailable() { + t.Skip("env: no Docker daemon for the function render engine") + return + } + + if !envtestBinariesAvailable() { + t.Skip("env: no envtest binaries (KUBEBUILDER_ASSETS unset)") + return + } + + if os.Getenv("CROSSPLANE_CRDS_DIR") == "" { + t.Skip("env: CROSSPLANE_CRDS_DIR unset (run via nix run .#test or set it to Crossplane's cluster/crds)") + return + } + cfg, _, ctx, _ := startIntegrationCluster(t, XRDiffTest, IntegrationTestCase{ setupFiles: []string{ "testdata/diff/resources/xrd.yaml", From d4f66b97d05560472b7169242d3d323db366cba6 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 10:34:51 -0400 Subject: [PATCH 023/126] feat(project): add crossplane-diff flag parity to project simulate Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 16 +++ cmd/crossplane/project/simulate.go | 93 +++++++++++++++++- cmd/crossplane/project/simulate_test.go | 123 ++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 1 deletion(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 12066e69..f2237461 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -28,6 +28,22 @@ own. Pass `--include-pinned` to simulate them too. The `--timeout` flag bounds the whole simulation, not each composite resource. It doesn't include the time spent building functions. +The command accepts the same diff-tuning flags as `crossplane-diff`: + +- `--ignore-paths` leaves paths out of the diff. +- `--function-credentials` and `--function-registry-override` configure the + functions the render runs. +- `--max-nested-depth` limits nested composite resource recursion. +- `--dry-run-on` picks which resources the server-side dry run verifies: + `existing` or `all`, the default. +- `--analyze-on` sets the smallest Composition change that triggers impact + analysis: `spec-change`, `any-change`, or `always`. +- `--minimize-composition` collapses each changed Composition to one line in + the diff output. +- `--eventual-state` shows the state after all reconciliation cycles complete. +- `--max-recv-message-size` raises the function gRPC message limit, in MB. +- `--no-color` and `--compact` change how the diff prints. + The command exits with status 0 when the simulation succeeds, whether or not it finds changes, 1 when it fails, 2 when only schema validation fails, and 130 when interrupted. Pass `--exit-code` to exit with status 3 when it finds diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index b77da450..52debce4 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -26,7 +26,9 @@ import ( "github.com/alecthomas/kong" "github.com/charmbracelet/x/term" "github.com/spf13/afero" + corev1 "k8s.io/api/core/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" k8stypes "k8s.io/apimachinery/pkg/types" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -35,6 +37,7 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + ld "github.com/crossplane/cli/v2/cmd/crossplane/common/load" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/diff/client" @@ -69,6 +72,18 @@ type simulateCmd struct { Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` ExitCode bool `help:"Exit with status 3 when the simulation finds changes."` + NoColor bool `help:"Disable colorized output."` + Compact bool `help:"Show compact diffs with minimal context."` + MaxNestedDepth int `default:"10" help:"Maximum depth for nested XR recursion."` + IgnorePaths []string `help:"Paths to ignore in diffs (e.g., 'metadata.annotations[argocd.argoproj.io/tracking-id]')."` + FunctionCredentials FunctionCredentials `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." placeholder:"PATH"` + FunctionRegistryOverride string `help:"Override the registry for all function images (e.g., 'my-company.registry.io')."` + EventualState bool `help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)."` + MaxRecvMessageSize int `env:"MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4MB if undefined)."` + MinimizeComposition bool `help:"Collapse each changed composition to a single marker line (human-readable output only; JSON/YAML keeps full detail; errors and no-change compositions still print in full)."` + AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` + // CrossplaneVersion and CrossplaneImage select the crossplane render // backend, as they do for crossplane-diff. When neither is set the render // engine pulls xpkg.crossplane.io/crossplane/crossplane:stable. @@ -78,6 +93,63 @@ type simulateCmd struct { Impersonation kube.ImpersonationFlags `embed:""` } +// FunctionCredentials holds Secret credentials loaded from a file path. It +// implements kong.MapperValue to load the secrets at parse time, so a bad +// path fails before any build or cluster connection. +type FunctionCredentials struct { + Path string + Secrets []corev1.Secret +} + +// Decode implements kong.MapperValue. +func (f *FunctionCredentials) Decode(ctx *kong.DecodeContext) error { + var path string + if err := ctx.Scan.PopValueInto("path", &path); err != nil { + return err + } + if path == "" { + return nil + } + + secrets, err := loadFunctionCredentials(path) + if err != nil { + return err + } + if len(secrets) == 0 { + return errors.Errorf("no Secret resources found in %q: file must contain v1/Secret resources", path) + } + + f.Path = path + f.Secrets = secrets + return nil +} + +// loadFunctionCredentials loads the v1 Secrets in a YAML file or directory. +// Other resources are skipped. +func loadFunctionCredentials(path string) ([]corev1.Secret, error) { + loader, err := ld.NewLoader(path) + if err != nil { + return nil, errors.Wrapf(err, "cannot create loader for path %q", path) + } + resources, err := loader.Load() + if err != nil { + return nil, errors.Wrapf(err, "cannot load resources from %q", path) + } + + secrets := make([]corev1.Secret, 0, len(resources)) + for _, res := range resources { + if res.GetKind() != "Secret" || res.GetAPIVersion() != "v1" { + continue + } + secret := corev1.Secret{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(res.UnstructuredContent(), &secret); err != nil { + return nil, errors.Wrapf(err, "cannot convert Secret %q to corev1.Secret", res.GetName()) + } + secrets = append(secrets, secret) + } + return secrets, nil +} + func (c *simulateCmd) Help() string { return simulateHelp } @@ -218,13 +290,29 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f dp.WithAdvisories(dp.NewAdvisoryCollector(k.Stderr)), dp.WithStdout(k.Stdout), dp.WithStderr(k.Stderr), - dp.WithColorize(isTerminal(k.Stdout)), + dp.WithColorize(!c.NoColor && isTerminal(k.Stdout)), + dp.WithCompact(c.Compact), + dp.WithMaxNestedDepth(c.MaxNestedDepth), + dp.WithEventualState(c.EventualState), + dp.WithMinimizeComposition(c.MinimizeComposition), + dp.WithAnalyzeOn(dp.AnalyzeOn(c.AnalyzeOn)), + dp.WithDryRunOn(dp.DryRunOn(c.DryRunOn)), + dp.WithIgnorePaths(append([]string{lastAppliedPath}, c.IgnorePaths...)), dp.WithOutputFormat(renderer.OutputFormat(c.Output)), dp.WithIncludeManual(c.IncludePinned), dp.WithMaxRenderIterations(c.MaxIterations), dp.WithFunctionProviderFactory(dp.NewProjectFunctionProviderFactory(fns)), } + if len(c.FunctionCredentials.Secrets) > 0 { + opts = append(opts, dp.WithFunctionCredentials(c.FunctionCredentials.Secrets)) + } + if c.FunctionRegistryOverride != "" { + opts = append(opts, dp.WithFunctionRegistryOverride(c.FunctionRegistryOverride)) + } + if c.MaxRecvMessageSize > 0 { + opts = append(opts, dp.WithMaxRecvMessageSize(c.MaxRecvMessageSize)) + } if c.CrossplaneVersion != "" { opts = append(opts, dp.WithCrossplaneVersion(c.CrossplaneVersion)) } @@ -256,6 +344,9 @@ func isTerminal(w io.Writer) bool { return ok && term.IsTerminal(f.Fd()) } +// lastAppliedPath is always ignored in diffs, as in crossplane-diff. +const lastAppliedPath = "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]" + // errChangesDetected is the error a run with --exit-code returns when it // finds changes. var errChangesDetected = errors.New("changes detected") diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index de0703f6..aa5cf42f 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -20,12 +20,16 @@ import ( "bytes" "context" "errors" + "os" + "path/filepath" "testing" "time" "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8stypes "k8s.io/apimachinery/pkg/types" @@ -53,6 +57,16 @@ func parseSimulate(t *testing.T, args ...string) (*simulateCmd, error) { } func TestSimulateFlags(t *testing.T) { + dir := t.TempDir() + creds := filepath.Join(dir, "creds.yaml") + noSecrets := filepath.Join(dir, "none.yaml") + if err := os.WriteFile(creds, []byte("apiVersion: v1\nkind: Secret\nmetadata:\n name: creds\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(noSecrets, []byte("apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: cm\n"), 0o600); err != nil { + t.Fatal(err) + } + type want struct { cmd *simulateCmd err bool @@ -71,6 +85,8 @@ func TestSimulateFlags(t *testing.T) { MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, Output: "diff", + MaxNestedDepth: 10, + DryRunOn: "all", }}, }, "AllFlags": { @@ -93,8 +109,72 @@ func TestSimulateFlags(t *testing.T) { Output: "json", ExitCode: true, CrossplaneVersion: "v2.3.4", + MaxNestedDepth: 10, + DryRunOn: "all", }}, }, + "ParityFlags": { + reason: "The crossplane-diff parity flags should land on their fields.", + args: []string{ + "--no-color", "--compact", "--max-nested-depth", "3", "--ignore-paths", "a.b", "--ignore-paths", "c.d", + "--function-registry-override", "reg.example.com", "--eventual-state", "--max-recv-message-size", "16", + "--minimize-composition", "--analyze-on", "always", "--dry-run-on", "existing", + }, + want: want{cmd: &simulateCmd{ + ProjectFile: "crossplane-project.yaml", + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: time.Minute, + Output: "diff", + NoColor: true, + Compact: true, + MaxNestedDepth: 3, + IgnorePaths: []string{"a.b", "c.d"}, + FunctionRegistryOverride: "reg.example.com", + EventualState: true, + MaxRecvMessageSize: 16, + MinimizeComposition: true, + AnalyzeOn: "always", + DryRunOn: "existing", + }}, + }, + "FunctionCredentials": { + reason: "--function-credentials should load the Secrets in the file at parse time.", + args: []string{"--function-credentials", creds}, + want: want{cmd: &simulateCmd{ + ProjectFile: "crossplane-project.yaml", + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: time.Minute, + Output: "diff", + MaxNestedDepth: 10, + DryRunOn: "all", + FunctionCredentials: FunctionCredentials{Path: creds, Secrets: []corev1.Secret{{ + TypeMeta: metav1.TypeMeta{APIVersion: "v1", Kind: "Secret"}, + ObjectMeta: metav1.ObjectMeta{Name: "creds"}, + }}}, + }}, + }, + "FunctionCredentialsMissing": { + reason: "A missing credentials path should be rejected at parse time.", + args: []string{"--function-credentials", filepath.Join(t.TempDir(), "nope.yaml")}, + want: want{err: true}, + }, + "FunctionCredentialsNoSecrets": { + reason: "A credentials file with no Secrets should be rejected.", + args: []string{"--function-credentials", noSecrets}, + want: want{err: true}, + }, + "UnknownDryRunOn": { + reason: "Unknown --dry-run-on values should be rejected.", + args: []string{"--dry-run-on", "none"}, + want: want{err: true}, + }, + "UnknownAnalyzeOn": { + reason: "Unknown --analyze-on values should be rejected.", + args: []string{"--analyze-on", "never"}, + want: want{err: true}, + }, "UnknownOutput": { reason: "Output formats other than diff, json and yaml should be rejected.", args: []string{"-o", "plan"}, @@ -124,6 +204,8 @@ func TestSimulateFlags(t *testing.T) { MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, Output: "diff", + MaxNestedDepth: 10, + DryRunOn: "all", CrossplaneImage: "example.com/crossplane:stable", }}, }, @@ -202,6 +284,17 @@ func TestSimulateProcessorOptions(t *testing.T) { IncludePinned: true, MaxIterations: 7, CrossplaneVersion: "v2.3.4", + + Compact: true, + MaxNestedDepth: 4, + IgnorePaths: []string{"a.b"}, + FunctionCredentials: FunctionCredentials{Secrets: []corev1.Secret{{ObjectMeta: metav1.ObjectMeta{Name: "creds"}}}}, + FunctionRegistryOverride: "reg.example.com", + EventualState: true, + MaxRecvMessageSize: 16, + MinimizeComposition: true, + AnalyzeOn: "spec-change", + DryRunOn: "existing", } cfg := dp.ProcessorConfig{} @@ -220,6 +313,16 @@ func TestSimulateProcessorOptions(t *testing.T) { StderrIsK bool HasAdvisories bool HasFunctionProvider bool + Compact bool + MaxNestedDepth int + IgnorePaths []string + Credentials []corev1.Secret + RegistryOverride string + EventualState bool + MaxRecvMessageSize int + MinimizeComposition bool + AnalyzeOn dp.AnalyzeOn + DryRunOn dp.DryRunOn } want := got{ OutputFormat: renderer.OutputFormatYAML, @@ -232,6 +335,16 @@ func TestSimulateProcessorOptions(t *testing.T) { StderrIsK: true, HasAdvisories: true, HasFunctionProvider: true, + Compact: true, + MaxNestedDepth: 4, + IgnorePaths: []string{lastAppliedPath, "a.b"}, + Credentials: c.FunctionCredentials.Secrets, + RegistryOverride: "reg.example.com", + EventualState: true, + MaxRecvMessageSize: 16, + MinimizeComposition: true, + AnalyzeOn: dp.AnalyzeOnSpecChange, + DryRunOn: dp.DryRunOnExisting, } g := got{ OutputFormat: cfg.OutputFormat, @@ -244,6 +357,16 @@ func TestSimulateProcessorOptions(t *testing.T) { StderrIsK: cfg.Stderr == stderr, HasAdvisories: cfg.Advisories != nil, HasFunctionProvider: cfg.Factories.FunctionProvider != nil, + Compact: cfg.Compact, + MaxNestedDepth: cfg.MaxNestedDepth, + IgnorePaths: cfg.IgnorePaths, + Credentials: cfg.FunctionCredentials, + RegistryOverride: cfg.FunctionRegistryOverride, + EventualState: cfg.EventualState, + MaxRecvMessageSize: cfg.MaxRecvMessageSize, + MinimizeComposition: cfg.MinimizeComposition, + AnalyzeOn: cfg.AnalyzeOn, + DryRunOn: cfg.DryRunOn, } if diff := cmp.Diff(want, g); diff != "" { t.Errorf("processorOptions(...): -want, +got:\n%s", diff) From f95734e2073c44428dbcc0c21a2722af8ed74c13 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 10:35:43 -0400 Subject: [PATCH 024/126] test(kube,diff): cover RESTConfig and client.New Co-Authored-By: Claude Sonnet 5.5 Signed-off-by: Jonathan Ogilvie --- internal/diff/client/client_test.go | 61 ++++++++++++++ internal/kube/config_test.go | 125 ++++++++++++++++++++++++++++ 2 files changed, 186 insertions(+) create mode 100644 internal/diff/client/client_test.go create mode 100644 internal/kube/config_test.go diff --git a/internal/diff/client/client_test.go b/internal/diff/client/client_test.go new file mode 100644 index 00000000..e211a163 --- /dev/null +++ b/internal/diff/client/client_test.go @@ -0,0 +1,61 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package client + +import ( + "testing" + + "k8s.io/client-go/rest" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +func TestNew(t *testing.T) { + cases := map[string]struct { + reason string + config *rest.Config + err bool + }{ + "ValidConfig": { + reason: "A valid config should build every client without contacting the cluster.", + config: &rest.Config{Host: "https://example.com"}, + }, + "InvalidTLS": { + reason: "A config with unusable TLS data should fail client creation.", + config: &rest.Config{ + Host: "https://example.com", + TLSClientConfig: rest.TLSClientConfig{CAData: []byte("not a cert"), CertData: []byte("x")}, + }, + err: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := New(tc.config, logging.NewNopLogger()) + if gotErr := err != nil; gotErr != tc.err { + t.Fatalf("\n%s\nNew(...): want error %t, got %v", tc.reason, tc.err, err) + } + if tc.err { + return + } + if got == nil || got.K8sClients.Resource == nil || got.XpClients.Composition == nil || got.XpClients.Function == nil { + t.Errorf("\n%s\nNew(...): want fully populated clients, got %+v", tc.reason, got) + } + }) + } +} diff --git a/internal/kube/config_test.go b/internal/kube/config_test.go new file mode 100644 index 00000000..2158a62b --- /dev/null +++ b/internal/kube/config_test.go @@ -0,0 +1,125 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kube + +import ( + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/client-go/rest" +) + +const testKubeconfig = `apiVersion: v1 +kind: Config +current-context: one +clusters: +- name: c1 + cluster: {server: "https://one.example.com"} +- name: c2 + cluster: {server: "https://two.example.com"} +contexts: +- name: one + context: {cluster: c1, user: u, namespace: ns-one} +- name: two + context: {cluster: c2, user: u, namespace: ns-two} +users: +- name: u + user: {token: abc} +` + +func TestRESTConfig(t *testing.T) { + type args struct { + kubeContext string + imp ImpersonationFlags + } + type want struct { + host string + namespace string + impersonate rest.ImpersonationConfig + qps float32 + burst int + err bool + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "CurrentContext": { + reason: "An empty context should use the kubeconfig's current context and default QPS/Burst.", + want: want{host: "https://one.example.com", namespace: "ns-one", qps: 20, burst: 30}, + }, + "ContextOverride": { + reason: "A named context should override the current context.", + args: args{kubeContext: "two"}, + want: want{host: "https://two.example.com", namespace: "ns-two", qps: 20, burst: 30}, + }, + "UnknownContext": { + reason: "A context missing from the kubeconfig should error.", + args: args{kubeContext: "nope"}, + want: want{err: true}, + }, + "Impersonation": { + reason: "Impersonation flags should be applied to the config.", + args: args{imp: ImpersonationFlags{As: "jane", AsGroup: []string{"admins"}, AsUID: "u1"}}, + want: want{ + host: "https://one.example.com", namespace: "ns-one", qps: 20, burst: 30, + impersonate: rest.ImpersonationConfig{UserName: "jane", Groups: []string{"admins"}, UID: "u1"}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config") + if err := os.WriteFile(path, []byte(testKubeconfig), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("KUBECONFIG", path) + + cfg, cc, err := RESTConfig(tc.args.kubeContext, tc.args.imp) + if gotErr := err != nil; gotErr != tc.want.err { + t.Fatalf("\n%s\nRESTConfig(...): want error %t, got %v", tc.reason, tc.want.err, err) + } + if tc.want.err { + return + } + + ns, _, err := cc.Namespace() + if err != nil { + t.Fatalf("Namespace(): %v", err) + } + got := want{host: cfg.Host, namespace: ns, impersonate: cfg.Impersonate, qps: cfg.QPS, burst: cfg.Burst} + if diff := cmp.Diff(tc.want, got, cmp.AllowUnexported(want{})); diff != "" { + t.Errorf("\n%s\nRESTConfig(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestRESTConfigMissingKubeconfig(t *testing.T) { + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "absent")) + t.Setenv("HOME", t.TempDir()) + t.Setenv("KUBERNETES_SERVICE_HOST", "") + + if _, _, err := RESTConfig("", ImpersonationFlags{}); err == nil { + t.Error("RESTConfig(...): want error for a missing kubeconfig, got nil") + } +} From b2056d32e62844f8dce45de3776e494533c99faf Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 12:41:38 -0400 Subject: [PATCH 025/126] test(diff): enable flag-parity integration cases 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 Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/README.md | 4 - .../diff/integration/simulate_harness_test.go | 77 ++++++++++++------- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/internal/diff/integration/README.md b/internal/diff/integration/README.md index 5f73b4fe..621f1149 100644 --- a/internal/diff/integration/README.md +++ b/internal/diff/integration/README.md @@ -27,10 +27,6 @@ express is ported in full and skipped with a greppable reason: - `gap:` — the simulate command lacks the feature. Enable the cases by implementing the feature and removing the gap from `simulateGap`. -- `gap:flag-parity-not-merged ()` — the flag exists on branch - `jco-simulate-flag-parity` but is not merged into this branch yet. The - harness already parses and threads these flags; the cases run as soon - as the merge lands. - `env:` — the environment lacks something. The render engine runs functions as Docker containers (no Docker daemon skips the case), and envtest needs `KUBEBUILDER_ASSETS` pointing at kube-apiserver/etcd diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 19eb12ef..3fc0c3f1 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -36,10 +36,6 @@ limitations under the License. // - gap: — the simulate command lacks the feature this case // needs. Grep the skip reasons to find what to enable as features // land. -// - gap:flag-parity-not-merged () — the flag exists on branch -// jco-simulate-flag-parity but is not merged into this branch yet. -// The harness already wires these flags, so the case runs as soon as -// that branch merges. // - env: — the environment lacks something the case needs. // The render engine runs functions as Docker containers, so without // a Docker daemon no rendering case can run; cases that would not @@ -58,6 +54,8 @@ import ( "time" "github.com/alecthomas/kong" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -93,6 +91,12 @@ type simArgs struct { AnalyzeUnchanged bool `help:"Analyze compositions identical to their in-cluster version (deprecated; --analyze-on=always)." name:"analyze-unchanged"` AnalyzeOn string `help:"Smallest composition change that triggers per-composite impact analysis." name:"analyze-on"` Files []string `arg:"" help:"Input files." optional:""` + + // functionSecrets holds the Secrets --function-credentials loaded at + // parse time, the way crossplane-diff's kong MapperValue and + // simulate's own flag do: a bad path fails before any build or + // cluster connection. + functionSecrets []corev1.Secret } // parseSimulateArgs parses one case's CLI-style args. A parse failure is @@ -110,9 +114,46 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA t.Fatalf("failed to parse args %v: %v", args, err) } + if a.FunctionCredentials != "" { + secrets, err := loadFunctionSecrets(a.FunctionCredentials) + if err != nil { + t.Fatalf("failed to load --function-credentials %q: %v", a.FunctionCredentials, err) + } + if len(secrets) == 0 { + t.Fatalf("no Secret resources found in %q: file must contain v1/Secret resources", a.FunctionCredentials) + } + a.functionSecrets = secrets + } + return &a } +// loadFunctionSecrets mirrors simulate's loadFunctionCredentials: the v1 +// Secrets in a YAML file or directory, other resources skipped. +func loadFunctionSecrets(path string) ([]corev1.Secret, error) { + loader, err := ld.NewLoader(path) + if err != nil { + return nil, errors.Wrapf(err, "cannot create loader for path %q", path) + } + resources, err := loader.Load() + if err != nil { + return nil, errors.Wrapf(err, "cannot load resources from %q", path) + } + + secrets := make([]corev1.Secret, 0, len(resources)) + for _, res := range resources { + if res.GetKind() != "Secret" || res.GetAPIVersion() != "v1" { + continue + } + secret := corev1.Secret{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(res.UnstructuredContent(), &secret); err != nil { + return nil, errors.Wrapf(err, "cannot convert Secret %q to corev1.Secret", res.GetName()) + } + secrets = append(secrets, secret) + } + return secrets, nil +} + // engineLimitVars ports crossplane-diff main.go's kong.Vars of the same // name: the engine's defaults for --max-nested-depth and // --max-iterations, so the flags' defaults follow the engine rather than @@ -139,27 +180,6 @@ func simulateGap(testType DiffTestType, a *simArgs) string { return "gap: simulate has no xr mode (XR files as input) yet" } - // Flags that exist on the flag-parity branch but are not merged into - // this branch yet. The harness already wires them; the cases run as - // soon as the merge lands. - flagParity := []struct { - set bool - flag string - }{ - {a.NoColor, "--no-color"}, - {len(a.IgnorePaths) > 0, "--ignore-paths"}, - {a.FunctionCredentials != "", "--function-credentials"}, - {a.EventualState, "--eventual-state"}, - {a.AnalyzeUnchanged, "--analyze-unchanged (simulate's --analyze-on=always)"}, - {a.AnalyzeOn != "", "--analyze-on"}, - {a.DryRunOn != "" && a.DryRunOn != "all", "--dry-run-on"}, - } - for _, f := range flagParity { - if f.set { - return "gap:flag-parity-not-merged (" + f.flag + ")" - } - } - // simulate scopes to one composite resource with --name (with // --namespace for a namespaced one); it cannot express the repeated // or comma-separated --resource scoping these cases need. @@ -349,8 +369,11 @@ func processorOptions(a *simArgs, stdout, stderr io.Writer, logger logging.Logge opts = append(opts, dp.WithOutputFormat(outputFormat), dp.WithOutputVersion(renderer.OutputVersion(a.OutputVersion))) // Function credentials load at parse time in crossplane-diff (a kong - // MapperValue); the cases that pass them are gap-skipped before this - // point, so nothing reaches the processor without them. + // MapperValue), as parseSimulateArgs does here; the processor gets + // them only when the flag loaded some. + if len(a.functionSecrets) > 0 { + opts = append(opts, dp.WithFunctionCredentials(a.functionSecrets)) + } return opts } From 5e47c11fc4ecfa79701453189695d0aa21f3ad17 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 19 Oct 2026 12:50:33 -0400 Subject: [PATCH 026/126] build(nix): provide envtest assets for integration tests 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 Signed-off-by: Jonathan Ogilvie --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ flake.nix | 1 + nix/apps.nix | 32 ++++++++++++++++++++++++++++++++ nix/checks.nix | 13 +++++++++++-- nix/envtest-assets.nix | 39 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 nix/envtest-assets.nix diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49aa97db..198c495a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,28 @@ jobs: file: result/coverage.txt token: ${{ secrets.CODECOV_TOKEN }} + # The diff integration tests run the render engine's functions as Docker + # containers, so they cannot run in the Nix sandbox like the unit tests. + # The app runs outside the sandbox against the runner's Docker daemon. + integration-tests: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Run Integration Tests + run: nix run .#integration-test --option warn-dirty false + # Build all artifacts build-artifacts: permissions: diff --git a/flake.nix b/flake.nix index 05c6b38d..8eeecc3c 100644 --- a/flake.nix +++ b/flake.nix @@ -137,6 +137,7 @@ in { test = apps.test { }; + integration-test = apps.integration-test { }; lint = apps.lint { }; generate = apps.generate { }; tidy = apps.tidy { }; diff --git a/nix/apps.nix b/nix/apps.nix index afcec82f..c74955dd 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -13,6 +13,11 @@ # Each app declares its tool dependencies via runtimeInputs, with inheritPath # set to false. This ensures apps only use explicitly declared tools. { pkgs }: +let + # envtest binaries for the integration tests; null on non-Linux systems, + # where the tests skip through their env: guards instead. + envtestAssets = import ./envtest-assets.nix { inherit pkgs; }; +in { # Run Go unit tests. test = _: { @@ -34,6 +39,33 @@ ); }; + # Run the diff integration tests. Unlike the sandboxed `test` check, this app + # runs outside the Nix sandbox so it can reach a Docker daemon for the + # render engine; envtest binaries and Crossplane CRDs come from the Nix + # store. CI runs it on GitHub runners, where a host Docker daemon is + # available (see the integration-tests job in .github/workflows/ci.yml). + integration-test = _: { + type = "app"; + meta.description = "Run the diff integration tests (needs Docker)"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-integration-test"; + runtimeInputs = [ pkgs.unstable.go_1_26 ]; + inheritPath = false; + text = '' + export CGO_ENABLED=0 + export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ + import ./crossplane-crds.nix { inherit pkgs; } + }}" + ${pkgs.lib.optionalString ( + envtestAssets != null + ) ''export KUBEBUILDER_ASSETS="''${KUBEBUILDER_ASSETS:-${envtestAssets}}"''} + go test ./internal/diff/integration/... "$@" + ''; + } + ); + }; + # Run linters with auto-fix. Formats code first, then reports remaining issues. lint = _: { type = "app"; diff --git a/nix/checks.nix b/nix/checks.nix index d6e8e4f2..827dd1c9 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -17,6 +17,9 @@ let # See nix/go-builders.nix. inherit (import ./go-builders.nix { inherit pkgs self; }) buildRoot; crossplaneCRDs = import ./crossplane-crds.nix { inherit pkgs; }; + # envtest binaries are Linux-only; on other systems the integration tests + # skip through their env: guards instead. + envtestAssets = import ./envtest-assets.nix { inherit pkgs; }; in { # Run Go unit tests with coverage @@ -27,8 +30,14 @@ in inherit version; src = self; - env.CGO_ENABLED = "0"; - env.CROSSPLANE_CRDS_DIR = crossplaneCRDs; + # The sandbox has no Docker daemon, so the integration tests' render + # engine cases still skip there (env: guards); the envtest cluster + # cases that do not need Docker can run. + env = { + CGO_ENABLED = "0"; + CROSSPLANE_CRDS_DIR = crossplaneCRDs; + KUBEBUILDER_ASSETS = pkgs.lib.optionalString (envtestAssets != null) envtestAssets; + }; dontBuild = true; diff --git a/nix/envtest-assets.nix b/nix/envtest-assets.nix new file mode 100644 index 00000000..711a0ef5 --- /dev/null +++ b/nix/envtest-assets.nix @@ -0,0 +1,39 @@ +# envtest control-plane binaries (kube-apiserver, etcd, kubectl) for the diff +# integration tests, from the controller-tools envtest release. Pinned to the +# Kubernetes version crossplane-diff's Earthfile targets (KUBE_VERSION). +# Tests find them through KUBEBUILDER_ASSETS; on non-Linux systems there is +# nothing to provide and null is returned so callers can skip wiring. +{ pkgs }: +let + version = "1.30.3"; + hashes = { + amd64 = "sha256-DDwRyCoyUfBEaw5WPmYRmfrVEhDpmscC1MIroilgo9I="; + arm64 = "sha256-Z26hrMSpoO04w9dr7CH2/b3v6U6QyunAj6vqD+opk5M="; + }; + arch = + { + x86_64 = "amd64"; + aarch64 = "arm64"; + } + .${pkgs.stdenv.hostPlatform.uname.processor} or null; +in +if arch == null then + null +else + pkgs.runCommand "envtest-assets-${version}" + { + nativeBuildInputs = [ pkgs.gnutar ]; + meta = { + description = "envtest control-plane binaries (Kubernetes v${version})"; + platforms = pkgs.lib.platforms.linux; + }; + } + '' + mkdir -p $out + tar -xzf ${ + pkgs.fetchurl { + url = "https://github.com/kubernetes-sigs/controller-tools/releases/download/envtest-v${version}/envtest-v${version}-linux-${arch}.tar.gz"; + hash = hashes.${arch}; + } + } --strip-components=2 -C $out controller-tools/envtest + '' From 9b37e7055a6bd200628a7be8d18757cc645808bd Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 11:15:57 -0400 Subject: [PATCH 027/126] build(nix): run integration tests in the main test target 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) Signed-off-by: Jonathan Ogilvie --- .github/workflows/ci.yml | 34 +++++++--------------------------- .gitignore | 1 + flake.nix | 1 - nix/apps.nix | 36 +++++++++--------------------------- nix/checks.nix | 4 ++-- 5 files changed, 19 insertions(+), 57 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 198c495a..bb498e80 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,9 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4 - unit-tests: + # Runs the whole suite, diff integration tests included, outside the Nix + # sandbox so the render engine can use the runner's Docker daemon. + tests: runs-on: ubuntu-24.04 steps: @@ -101,38 +103,16 @@ jobs: name: crossplane authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: Run Unit Tests - run: nix build .#checks.x86_64-linux.test --print-build-logs + - name: Run Tests + run: nix run .#test --option warn-dirty false - - name: Publish Unit Test Coverage + - name: Publish Test Coverage uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: flags: unittests - file: result/coverage.txt + file: coverage.txt token: ${{ secrets.CODECOV_TOKEN }} - # The diff integration tests run the render engine's functions as Docker - # containers, so they cannot run in the Nix sandbox like the unit tests. - # The app runs outside the sandbox against the runner's Docker daemon. - integration-tests: - runs-on: ubuntu-24.04 - - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - - name: Install Nix - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 - - - name: Setup Cachix - uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17 - with: - name: crossplane - authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - - name: Run Integration Tests - run: nix run .#integration-test --option warn-dirty false - # Build all artifacts build-artifacts: permissions: diff --git a/.gitignore b/.gitignore index ebb4d21e..cdbe6c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ AGENTS.md # Ignore local dev environment variables .env +/coverage.txt diff --git a/flake.nix b/flake.nix index 8eeecc3c..05c6b38d 100644 --- a/flake.nix +++ b/flake.nix @@ -137,7 +137,6 @@ in { test = apps.test { }; - integration-test = apps.integration-test { }; lint = apps.lint { }; generate = apps.generate { }; tidy = apps.tidy { }; diff --git a/nix/apps.nix b/nix/apps.nix index c74955dd..bed0ac49 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -19,39 +19,21 @@ let envtestAssets = import ./envtest-assets.nix { inherit pkgs; }; in { - # Run Go unit tests. + # Run the full Go test suite with coverage, diff integration tests included. + # Unlike the sandboxed `test` check, this app runs outside the Nix sandbox so + # the integration tests can reach a Docker daemon for the render engine; + # envtest binaries and Crossplane CRDs come from the Nix store. CI runs it on + # GitHub runners (see the tests job in .github/workflows/ci.yml). + # -coverpkg=./... counts coverage of every package from every test binary, + # so the integration tests' coverage of internal/diff is kept. test = _: { type = "app"; - meta.description = "Run unit tests"; + meta.description = "Run all tests with coverage (integration tests need Docker)"; program = pkgs.lib.getExe ( pkgs.writeShellApplication { name = "crossplane-cli-test"; runtimeInputs = [ pkgs.unstable.go_1_26 ]; inheritPath = false; - text = '' - export CGO_ENABLED=0 - export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ - import ./crossplane-crds.nix { inherit pkgs; } - }}" - go test -covermode=count ./... "$@" - ''; - } - ); - }; - - # Run the diff integration tests. Unlike the sandboxed `test` check, this app - # runs outside the Nix sandbox so it can reach a Docker daemon for the - # render engine; envtest binaries and Crossplane CRDs come from the Nix - # store. CI runs it on GitHub runners, where a host Docker daemon is - # available (see the integration-tests job in .github/workflows/ci.yml). - integration-test = _: { - type = "app"; - meta.description = "Run the diff integration tests (needs Docker)"; - program = pkgs.lib.getExe ( - pkgs.writeShellApplication { - name = "crossplane-cli-integration-test"; - runtimeInputs = [ pkgs.unstable.go_1_26 ]; - inheritPath = false; text = '' export CGO_ENABLED=0 export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ @@ -60,7 +42,7 @@ in ${pkgs.lib.optionalString ( envtestAssets != null ) ''export KUBEBUILDER_ASSETS="''${KUBEBUILDER_ASSETS:-${envtestAssets}}"''} - go test ./internal/diff/integration/... "$@" + go test -covermode=count -coverpkg=./... -coverprofile=coverage.txt ./... "$@" ''; } ); diff --git a/nix/checks.nix b/nix/checks.nix index 827dd1c9..97bb0626 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -31,8 +31,8 @@ in src = self; # The sandbox has no Docker daemon, so the integration tests' render - # engine cases still skip there (env: guards); the envtest cluster - # cases that do not need Docker can run. + # engine cases skip here (env: guards). CI runs the full suite, with + # Docker, through the `test` app in nix/apps.nix instead. env = { CGO_ENABLED = "0"; CROSSPLANE_CRDS_DIR = crossplaneCRDs; From 863154354b4013ca068d6f2c4ac5f31fbd908467 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 12:07:45 -0400 Subject: [PATCH 028/126] Revert "build(nix): run integration tests in the main test target" This reverts commit 881e78765ed3695a816e05722d61a386e8045ca1. Signed-off-by: Jonathan Ogilvie --- .github/workflows/ci.yml | 34 +++++++++++++++++++++++++++------- .gitignore | 1 - flake.nix | 1 + nix/apps.nix | 36 +++++++++++++++++++++++++++--------- nix/checks.nix | 4 ++-- 5 files changed, 57 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb498e80..198c495a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,9 +85,7 @@ jobs: - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4 - # Runs the whole suite, diff integration tests included, outside the Nix - # sandbox so the render engine can use the runner's Docker daemon. - tests: + unit-tests: runs-on: ubuntu-24.04 steps: @@ -103,16 +101,38 @@ jobs: name: crossplane authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} - - name: Run Tests - run: nix run .#test --option warn-dirty false + - name: Run Unit Tests + run: nix build .#checks.x86_64-linux.test --print-build-logs - - name: Publish Test Coverage + - name: Publish Unit Test Coverage uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 with: flags: unittests - file: coverage.txt + file: result/coverage.txt token: ${{ secrets.CODECOV_TOKEN }} + # The diff integration tests run the render engine's functions as Docker + # containers, so they cannot run in the Nix sandbox like the unit tests. + # The app runs outside the sandbox against the runner's Docker daemon. + integration-tests: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install Nix + uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@38b082610b782e7e93e209c35fd730d399dee866 # v17 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Run Integration Tests + run: nix run .#integration-test --option warn-dirty false + # Build all artifacts build-artifacts: permissions: diff --git a/.gitignore b/.gitignore index cdbe6c8d..ebb4d21e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,4 +45,3 @@ AGENTS.md # Ignore local dev environment variables .env -/coverage.txt diff --git a/flake.nix b/flake.nix index 05c6b38d..8eeecc3c 100644 --- a/flake.nix +++ b/flake.nix @@ -137,6 +137,7 @@ in { test = apps.test { }; + integration-test = apps.integration-test { }; lint = apps.lint { }; generate = apps.generate { }; tidy = apps.tidy { }; diff --git a/nix/apps.nix b/nix/apps.nix index bed0ac49..c74955dd 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -19,21 +19,39 @@ let envtestAssets = import ./envtest-assets.nix { inherit pkgs; }; in { - # Run the full Go test suite with coverage, diff integration tests included. - # Unlike the sandboxed `test` check, this app runs outside the Nix sandbox so - # the integration tests can reach a Docker daemon for the render engine; - # envtest binaries and Crossplane CRDs come from the Nix store. CI runs it on - # GitHub runners (see the tests job in .github/workflows/ci.yml). - # -coverpkg=./... counts coverage of every package from every test binary, - # so the integration tests' coverage of internal/diff is kept. + # Run Go unit tests. test = _: { type = "app"; - meta.description = "Run all tests with coverage (integration tests need Docker)"; + meta.description = "Run unit tests"; program = pkgs.lib.getExe ( pkgs.writeShellApplication { name = "crossplane-cli-test"; runtimeInputs = [ pkgs.unstable.go_1_26 ]; inheritPath = false; + text = '' + export CGO_ENABLED=0 + export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ + import ./crossplane-crds.nix { inherit pkgs; } + }}" + go test -covermode=count ./... "$@" + ''; + } + ); + }; + + # Run the diff integration tests. Unlike the sandboxed `test` check, this app + # runs outside the Nix sandbox so it can reach a Docker daemon for the + # render engine; envtest binaries and Crossplane CRDs come from the Nix + # store. CI runs it on GitHub runners, where a host Docker daemon is + # available (see the integration-tests job in .github/workflows/ci.yml). + integration-test = _: { + type = "app"; + meta.description = "Run the diff integration tests (needs Docker)"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-integration-test"; + runtimeInputs = [ pkgs.unstable.go_1_26 ]; + inheritPath = false; text = '' export CGO_ENABLED=0 export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ @@ -42,7 +60,7 @@ in ${pkgs.lib.optionalString ( envtestAssets != null ) ''export KUBEBUILDER_ASSETS="''${KUBEBUILDER_ASSETS:-${envtestAssets}}"''} - go test -covermode=count -coverpkg=./... -coverprofile=coverage.txt ./... "$@" + go test ./internal/diff/integration/... "$@" ''; } ); diff --git a/nix/checks.nix b/nix/checks.nix index 97bb0626..827dd1c9 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -31,8 +31,8 @@ in src = self; # The sandbox has no Docker daemon, so the integration tests' render - # engine cases skip here (env: guards). CI runs the full suite, with - # Docker, through the `test` app in nix/apps.nix instead. + # engine cases still skip there (env: guards); the envtest cluster + # cases that do not need Docker can run. env = { CGO_ENABLED = "0"; CROSSPLANE_CRDS_DIR = crossplaneCRDs; From cbd9abfb64d9f7ce9d0d338a6e14b5b54c5210f9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 12:09:11 -0400 Subject: [PATCH 029/126] test(diff): put integration tests behind the integration build tag 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) Signed-off-by: Jonathan Ogilvie --- .golangci.yml | 4 ++++ internal/diff/integration/README.md | 15 +++++++------- internal/diff/integration/doc.go | 20 +++++++++++++++++++ internal/diff/integration/integration_test.go | 2 ++ internal/diff/integration/it_utils_test.go | 2 ++ internal/diff/integration/main_test.go | 2 ++ .../diff/integration/simulate_harness_test.go | 2 ++ nix/apps.nix | 2 +- 8 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 internal/diff/integration/doc.go diff --git a/.golangci.yml b/.golangci.yml index 477607d9..c54f70d2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -6,6 +6,10 @@ run: # runs our unit tests. Lint them anyway, so they don't rot. build-tags: - compilegate + # The diff integration tests need Docker and envtest, so they sit behind + # the integration tag and run in their own target, not the unit tests. + # Lint them anyway. + - integration output: formats: diff --git a/internal/diff/integration/README.md b/internal/diff/integration/README.md index 621f1149..b6028082 100644 --- a/internal/diff/integration/README.md +++ b/internal/diff/integration/README.md @@ -41,14 +41,15 @@ in the source tables, with the upstream reason carried verbatim). # Full suite (Docker + envtest assets required): CROSSPLANE_CRDS_DIR= \ KUBEBUILDER_ASSETS=$(setup-envtest use -p path) \ -go test -v ./internal/diff/integration/ +go test -tags integration -v ./internal/diff/integration/ # One case: -go test -run 'TestCompDiffIntegration/CompositionChangeImpactsXRs' ./internal/diff/integration/ +go test -tags integration -run 'TestCompDiffIntegration/CompositionChangeImpactsXRs' ./internal/diff/integration/ ``` -`CROSSPLANE_CRDS_DIR` is set for you by `nix run .#test` (the pinned -Crossplane v2.4.1 CRDs from `nix/crossplane-crds.nix`). Without any of -these environment pieces the suite skips with an `env:` reason rather -than fail, so `go test ./...` stays green everywhere the unit suite -runs. +The suite is behind the `integration` build tag, so `go test ./...` and +`nix run .#test` do not compile it. `nix run .#integration-test` runs it +with `CROSSPLANE_CRDS_DIR` (the pinned Crossplane v2.4.1 CRDs from +`nix/crossplane-crds.nix`) and `KUBEBUILDER_ASSETS` set for you. Without +Docker or envtest assets, the cases skip with an `env:` reason rather +than fail. diff --git a/internal/diff/integration/doc.go b/internal/diff/integration/doc.go new file mode 100644 index 00000000..934f08d2 --- /dev/null +++ b/internal/diff/integration/doc.go @@ -0,0 +1,20 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// The diff integration tests are behind the integration build tag (see +// README.md). This untagged file keeps the package buildable without it. + +package integration diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 3c99f449..c396fb31 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -1,3 +1,5 @@ +//go:build integration + package integration import ( diff --git a/internal/diff/integration/it_utils_test.go b/internal/diff/integration/it_utils_test.go index 0c887938..8775d5bc 100644 --- a/internal/diff/integration/it_utils_test.go +++ b/internal/diff/integration/it_utils_test.go @@ -1,3 +1,5 @@ +//go:build integration + package integration import ( diff --git a/internal/diff/integration/main_test.go b/internal/diff/integration/main_test.go index 733c642d..58dbe47e 100644 --- a/internal/diff/integration/main_test.go +++ b/internal/diff/integration/main_test.go @@ -1,3 +1,5 @@ +//go:build integration + package integration import ( diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 3fc0c3f1..e552f66c 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -1,3 +1,5 @@ +//go:build integration + /* Copyright 2026 The Crossplane Authors. diff --git a/nix/apps.nix b/nix/apps.nix index c74955dd..02943cfa 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -60,7 +60,7 @@ in ${pkgs.lib.optionalString ( envtestAssets != null ) ''export KUBEBUILDER_ASSETS="''${KUBEBUILDER_ASSETS:-${envtestAssets}}"''} - go test ./internal/diff/integration/... "$@" + go test -tags integration ./internal/diff/integration/... "$@" ''; } ); From 51b00fa37ca0d6aad954b6a8aa6fa3c2e07d0b25 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 12:20:44 -0400 Subject: [PATCH 030/126] build(nix): publish integration test coverage The integration-test app writes coverage-integration.txt with -covermode=count -coverpkg=./..., since the integration tests are their own package and would otherwise credit nothing to internal/diff. CI uploads it to Codecov with flag integration, alongside the unittests flag; Codecov merges the uploads per commit. The README gives a local merge command. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .github/workflows/ci.yml | 7 +++++++ .gitignore | 2 ++ internal/diff/integration/README.md | 14 ++++++++++++++ nix/apps.nix | 5 ++++- 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 198c495a..4402da7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -133,6 +133,13 @@ jobs: - name: Run Integration Tests run: nix run .#integration-test --option warn-dirty false + - name: Publish Integration Test Coverage + uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7 + with: + flags: integration + file: coverage-integration.txt + token: ${{ secrets.CODECOV_TOKEN }} + # Build all artifacts build-artifacts: permissions: diff --git a/.gitignore b/.gitignore index ebb4d21e..afade55e 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,5 @@ AGENTS.md # Ignore local dev environment variables .env +/coverage-integration.txt +/coverage-merged.txt diff --git a/internal/diff/integration/README.md b/internal/diff/integration/README.md index b6028082..fd43ee39 100644 --- a/internal/diff/integration/README.md +++ b/internal/diff/integration/README.md @@ -53,3 +53,17 @@ with `CROSSPLANE_CRDS_DIR` (the pinned Crossplane v2.4.1 CRDs from `nix/crossplane-crds.nix`) and `KUBEBUILDER_ASSETS` set for you. Without Docker or envtest assets, the cases skip with an `env:` reason rather than fail. + +## Coverage + +`nix run .#integration-test` writes `coverage-integration.txt` +(`-covermode=count -coverpkg=./...`); CI uploads it to Codecov with flag +`integration`, next to the unit tests' `unittests` flag, and Codecov +merges them per commit. To merge locally with the unit profile from the +sandboxed check: + +```sh +nix build .#checks.$(nix eval --raw --impure --expr builtins.currentSystem).test +{ cat result/coverage.txt; tail -n +2 coverage-integration.txt; } > coverage-merged.txt +go tool cover -func=coverage-merged.txt | tail -1 +``` diff --git a/nix/apps.nix b/nix/apps.nix index 02943cfa..b2d2f4ff 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -60,7 +60,10 @@ in ${pkgs.lib.optionalString ( envtestAssets != null ) ''export KUBEBUILDER_ASSETS="''${KUBEBUILDER_ASSETS:-${envtestAssets}}"''} - go test -tags integration ./internal/diff/integration/... "$@" + # -coverpkg credits the tests' coverage to the packages they exercise; + # without it the profile only covers the test package itself. + go test -tags integration -covermode=count -coverpkg=./... \ + -coverprofile=coverage-integration.txt ./internal/diff/integration/... "$@" ''; } ); From 7824b49ca21179aef7e3cc8c711a319712af9543 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 11:51:15 -0400 Subject: [PATCH 031/126] test(diff): run the XR integration cases through the engine harness The engine harness already wires crossplane-diff's xr command (PerformDiff with the cluster's composition provider), so the xr-mode gap was a gap in the simulate command, not in the engine. Run the 98 XR cases now, as the baseline the simulate command's XR mode must match. 139 of 142 subtests run and pass; the rest are the repeated --resource gap and two upstream skips. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/simulate_harness_test.go | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index e552f66c..d7050437 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -174,14 +174,7 @@ func engineLimitVars() kong.Option { // // Keep this in sync with cmd/crossplane/project/simulate.go's flags: a // flag that lands there moves out of here, and its cases start running. -func simulateGap(testType DiffTestType, a *simArgs) string { - // The project simulate command previews compositions from a project; - // XR files as input (crossplane-diff's xr command, issue #367's - // `project simulate -f xr.yaml`) is not implemented yet. - if testType == XRDiffTest { - return "gap: simulate has no xr mode (XR files as input) yet" - } - +func simulateGap(_ DiffTestType, a *simArgs) string { // simulate scopes to one composite resource with --name (with // --namespace for a namespaced one); it cannot express the repeated // or comma-separated --resource scoping these cases need. From e08d585795ad0af90f29ac84a8e1b544fe9de4a9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 12:14:04 -0400 Subject: [PATCH 032/126] feat(project): add --output-version to project simulate crossplane-diff selects the structured output schema with --output-version; simulate already ran the engine that supports it but didn't expose the flag. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 4 +++- cmd/crossplane/project/simulate_test.go | 16 +++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 52debce4..bb4b3e6f 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -69,7 +69,8 @@ type simulateCmd struct { IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` - Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` + OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2). Has no effect on -o diff."` ExitCode bool `help:"Exit with status 3 when the simulation finds changes."` NoColor bool `help:"Disable colorized output."` @@ -299,6 +300,7 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f dp.WithDryRunOn(dp.DryRunOn(c.DryRunOn)), dp.WithIgnorePaths(append([]string{lastAppliedPath}, c.IgnorePaths...)), dp.WithOutputFormat(renderer.OutputFormat(c.Output)), + dp.WithOutputVersion(renderer.OutputVersion(c.OutputVersion)), dp.WithIncludeManual(c.IncludePinned), dp.WithMaxRenderIterations(c.MaxIterations), dp.WithFunctionProviderFactory(dp.NewProjectFunctionProviderFactory(fns)), diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index aa5cf42f..1278f30c 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -85,6 +85,7 @@ func TestSimulateFlags(t *testing.T) { MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, Output: "diff", + OutputVersion: "v1", MaxNestedDepth: 10, DryRunOn: "all", }}, @@ -94,7 +95,7 @@ func TestSimulateFlags(t *testing.T) { args: []string{ "-f", "p.yaml", "-c", "staging", "-n", "team-a", "--name", "db", "--include-pinned", "--max-iterations", "3", "--timeout", "2m", - "--max-concurrency", "2", "-o", "json", "--crossplane-version", "v2.3.4", "--exit-code", + "--max-concurrency", "2", "-o", "json", "--output-version", "v2", "--crossplane-version", "v2.3.4", "--exit-code", "--as", "jane", "--as-group", "admins", }, want: want{cmd: &simulateCmd{ @@ -107,6 +108,7 @@ func TestSimulateFlags(t *testing.T) { Timeout: 2 * time.Minute, MaxConcurrency: 2, Output: "json", + OutputVersion: "v2", ExitCode: true, CrossplaneVersion: "v2.3.4", MaxNestedDepth: 10, @@ -126,6 +128,7 @@ func TestSimulateFlags(t *testing.T) { MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, Output: "diff", + OutputVersion: "v1", NoColor: true, Compact: true, MaxNestedDepth: 3, @@ -147,6 +150,7 @@ func TestSimulateFlags(t *testing.T) { MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, Output: "diff", + OutputVersion: "v1", MaxNestedDepth: 10, DryRunOn: "all", FunctionCredentials: FunctionCredentials{Path: creds, Secrets: []corev1.Secret{{ @@ -175,6 +179,11 @@ func TestSimulateFlags(t *testing.T) { args: []string{"--analyze-on", "never"}, want: want{err: true}, }, + "UnknownOutputVersion": { + reason: "Structured output schema versions other than v1 and v2 should be rejected.", + args: []string{"--output-version", "v3"}, + want: want{err: true}, + }, "UnknownOutput": { reason: "Output formats other than diff, json and yaml should be rejected.", args: []string{"-o", "plan"}, @@ -204,6 +213,7 @@ func TestSimulateFlags(t *testing.T) { MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, Output: "diff", + OutputVersion: "v1", MaxNestedDepth: 10, DryRunOn: "all", CrossplaneImage: "example.com/crossplane:stable", @@ -281,6 +291,7 @@ func TestSimulateProcessorOptions(t *testing.T) { k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: stderr}} c := &simulateCmd{ Output: "yaml", + OutputVersion: "v2", IncludePinned: true, MaxIterations: 7, CrossplaneVersion: "v2.3.4", @@ -304,6 +315,7 @@ func TestSimulateProcessorOptions(t *testing.T) { type got struct { OutputFormat renderer.OutputFormat + OutputVersion renderer.OutputVersion IncludeManual bool MaxRenderIterations int CrossplaneVersion string @@ -326,6 +338,7 @@ func TestSimulateProcessorOptions(t *testing.T) { } want := got{ OutputFormat: renderer.OutputFormatYAML, + OutputVersion: renderer.OutputVersionV2, IncludeManual: true, MaxRenderIterations: 7, CrossplaneVersion: "v2.3.4", @@ -348,6 +361,7 @@ func TestSimulateProcessorOptions(t *testing.T) { } g := got{ OutputFormat: cfg.OutputFormat, + OutputVersion: cfg.OutputVersion, IncludeManual: cfg.IncludeManual, MaxRenderIterations: cfg.MaxRenderIterations, CrossplaneVersion: cfg.CrossplaneVersion, From 38b87dcc7a54e268a094172e94118d6c8c955b73 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 12:39:24 -0400 Subject: [PATCH 033/126] feat(project)!: make project simulate's -f a repeatable --input Revert point before the input modes land. -f keeps its default (the project file) and still takes exactly one project file, but becomes --input []string, the shape the simulate design gives it, so it can also take changed Composition or XR files. The long form changes from --project-file to --input; project simulate is alpha. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 16 +++++++++++----- cmd/crossplane/project/simulate_test.go | 15 ++++++++++----- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index bb4b3e6f..d16d346e 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -59,9 +59,9 @@ var simulateHelp string // simulateCmd previews how a project's compositions would change the // composite resources in a cluster. type simulateCmd struct { - ProjectFile string `default:"${project_file}" help:"Path to project definition." short:"f"` - MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + Input []string `default:"${project_file}" help:"Path to the project file to simulate." placeholder:"PATH" sep:"none" short:"f"` + MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` @@ -159,6 +159,12 @@ func (c *simulateCmd) Help() string { // during parsing, so an unsupported render backend fails before any build or // cluster connection. func (c *simulateCmd) Validate() error { + // ponytail: -f takes one project file for now; Composition and XR + // files are the next input modes (crossplane/cli#367). + if len(c.Input) != 1 { + return errors.Errorf("-f takes exactly one project file, got %d inputs", len(c.Input)) + } + if c.MaxIterations < 1 { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } @@ -189,7 +195,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin return err } - projFilePath, err := filepath.Abs(c.ProjectFile) + projFilePath, err := filepath.Abs(c.Input[0]) if err != nil { return err } @@ -198,7 +204,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin proj, err := projectfile.Parse(projFS, projFileName) if err != nil { - return errors.Wrapf(err, "failed to parse project file %q", c.ProjectFile) + return errors.Wrapf(err, "failed to parse project file %q", c.Input[0]) } comps, err := project.LoadCompositions(proj, projFS) diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 1278f30c..34881369 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -80,7 +80,7 @@ func TestSimulateFlags(t *testing.T) { "Defaults": { reason: "With no flags the defaults should apply.", want: want{cmd: &simulateCmd{ - ProjectFile: "crossplane-project.yaml", + Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, @@ -99,7 +99,7 @@ func TestSimulateFlags(t *testing.T) { "--as", "jane", "--as-group", "admins", }, want: want{cmd: &simulateCmd{ - ProjectFile: "p.yaml", + Input: []string{"p.yaml"}, Context: "staging", Namespace: "team-a", Name: "db", @@ -123,7 +123,7 @@ func TestSimulateFlags(t *testing.T) { "--minimize-composition", "--analyze-on", "always", "--dry-run-on", "existing", }, want: want{cmd: &simulateCmd{ - ProjectFile: "crossplane-project.yaml", + Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, @@ -145,7 +145,7 @@ func TestSimulateFlags(t *testing.T) { reason: "--function-credentials should load the Secrets in the file at parse time.", args: []string{"--function-credentials", creds}, want: want{cmd: &simulateCmd{ - ProjectFile: "crossplane-project.yaml", + Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, @@ -179,6 +179,11 @@ func TestSimulateFlags(t *testing.T) { args: []string{"--analyze-on", "never"}, want: want{err: true}, }, + "TwoInputs": { + reason: "-f should take exactly one project file.", + args: []string{"-f", "a.yaml", "-f", "b.yaml"}, + want: want{err: true}, + }, "UnknownOutputVersion": { reason: "Structured output schema versions other than v1 and v2 should be rejected.", args: []string{"--output-version", "v3"}, @@ -208,7 +213,7 @@ func TestSimulateFlags(t *testing.T) { reason: "A render image with no comparable version should be accepted.", args: []string{"--crossplane-image", "example.com/crossplane:stable"}, want: want{cmd: &simulateCmd{ - ProjectFile: "crossplane-project.yaml", + Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, From 57f0ca8db9bfb7376057485b126e19e9522669bb Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 12:53:22 -0400 Subject: [PATCH 034/126] feat(project): simulate Composition and composite resource files -f now takes the project file (the default), changed Composition files, or changed composite resource and claim files, repeatable, with - for standard input, as the simulate design and crossplane/cli#367 describe. - A single project file simulates the project, as before. - Compositions simulate their impact on the cluster's composite resources with the cluster's functions, like crossplane-diff comp. - Composite resources and claims render with the Composition the cluster selects and the cluster's functions, like crossplane-diff xr. A project file among other inputs, and Compositions mixed with other resources, are rejected. Flags that only apply to some inputs are rejected when set for the others: the Composition-scoping flags for composite resource files, the project build flags for anything but a project. SimulateHooks lets a test inject the cluster's REST config, a local render binary and terminal colorizing; the CLI never sets it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 69 +++-- cmd/crossplane/project/simulate.go | 341 +++++++++++++++++++++--- cmd/crossplane/project/simulate_test.go | 267 ++++++++++++++++++- 3 files changed, 611 insertions(+), 66 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index f2237461..15015268 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -1,20 +1,29 @@ -The `project simulate` command previews how applying a project's Compositions -would change the composite resources in a cluster. It renders each composite -resource that uses one of the project's Compositions with the project's -version of that Composition, and shows the difference from what's in the -cluster. It doesn't change the cluster. - -This command: - -- Builds the project's embedded functions and loads them into the local - Docker daemon, and resolves the project's function dependencies. -- Collects the Compositions under the project's APIs path. -- Connects to the cluster in your current Kubernetes context, or the one - `--context` names. -- Renders each affected composite resource with the project's functions in - place of the cluster's functions of the same name, and the cluster's - functions for every other pipeline step. -- Prints the difference. +The `project simulate` command previews how applying a project's Compositions, +or changed Composition or composite resource files, would change the composite +resources in a cluster. It renders each affected composite resource and shows +the difference from what's in the cluster. It doesn't change the cluster. + +The `-f` flag picks what to simulate. Repeat it to pass more than one file or +directory, or pass `-` to read from standard input: + +- A project file, the default, simulates the project. The command builds the + project's embedded functions and loads them into the local Docker daemon, + resolves its function dependencies, and collects the Compositions under its + APIs path. It renders each composite resource that uses one of those + Compositions with the project's version of the Composition, using the + project's functions in place of the cluster's functions of the same name. +- Composition files simulate those Compositions without a project. The command + renders each composite resource in the cluster that uses one of them, with + the functions installed in the cluster. +- Composite resource or claim files simulate those resources. The command + renders each one with the Composition the cluster selects for it and the + functions installed in the cluster. + +A project file must be the only `-f` input, and one run can't mix Compositions +with composite resources. + +The command connects to the cluster in your current Kubernetes context, or the +one `--context` names. The command needs Docker, to build and run functions, and read access to the cluster's Crossplane resources. It verifies the rendered resources with @@ -25,6 +34,12 @@ By default, `simulate` skips composite resources with a `Manual` composition update policy, because they don't adopt a new Composition revision on their own. Pass `--include-pinned` to simulate them too. +Some flags apply only to some inputs. `--namespace`, `--name`, +`--include-pinned`, `--analyze-on`, and `--minimize-composition` choose which +composite resources a Composition change reaches, so composite resource files +don't take them. `--max-concurrency` and `--cache-dir` configure the project's +function builds, so only a project file takes them. + The `--timeout` flag bounds the whole simulation, not each composite resource. It doesn't include the time spent building functions. @@ -43,6 +58,8 @@ The command accepts the same diff-tuning flags as `crossplane-diff`: - `--eventual-state` shows the state after all reconciliation cycles complete. - `--max-recv-message-size` raises the function gRPC message limit, in MB. - `--no-color` and `--compact` change how the diff prints. +- `--output-version` picks the schema version of JSON and YAML output: `v1`, + the default, or `v2`. The command exits with status 0 when the simulation succeeds, whether or not it finds changes, 1 when it fails, 2 when only schema validation fails, and 130 @@ -81,3 +98,21 @@ Fail a CI job when the project would change the cluster: ```shell crossplane project simulate --exit-code ``` + +Simulate a changed Composition without a project: + +```shell +crossplane project simulate -f composition.yaml +``` + +Simulate changed composite resources: + +```shell +crossplane project simulate -f xr.yaml -f claim.yaml +``` + +Simulate composite resources read from standard input: + +```shell +cat xr.yaml | crossplane project simulate -f - +``` diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index d16d346e..84139dde 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -18,6 +18,7 @@ package project import ( "context" + "encoding/json" "io" "os" "path/filepath" @@ -30,11 +31,13 @@ import ( un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" ld "github.com/crossplane/cli/v2/cmd/crossplane/common/load" @@ -56,12 +59,13 @@ import ( //go:embed help/simulate.md var simulateHelp string -// simulateCmd previews how a project's compositions would change the -// composite resources in a cluster. +// simulateCmd previews how a project's compositions, or changed Composition +// or composite resource files, would change the composite resources in a +// cluster. type simulateCmd struct { - Input []string `default:"${project_file}" help:"Path to the project file to simulate." placeholder:"PATH" sep:"none" short:"f"` + Input []string `default:"${project_file}" help:"Project file (default), or changed Composition or composite resource files. Repeatable; - reads standard input." placeholder:"PATH" sep:"none" short:"f"` MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` @@ -92,6 +96,25 @@ type simulateCmd struct { CrossplaneImage string `help:"Override the full crossplane render image reference. Minimum v2.3.4 when its tag is a semantic version." placeholder:"IMAGE" xor:"crossplane-render-backend"` Impersonation kube.ImpersonationFlags `embed:""` + + // Hooks is for tests; the CLI never sets it. + Hooks *SimulateHooks `kong:"-"` +} + +// SimulateHooks replace what project simulate otherwise takes from its +// environment, so a test can run the real command against a cluster it +// started. Fields left zero keep the CLI's behavior. +type SimulateHooks struct { + // RESTConfig replaces loading the kubeconfig. + RESTConfig *rest.Config + + // RenderBinary renders with this local crossplane binary instead of the + // crossplane render image. + RenderBinary string + + // Terminal colorizes diffs as if stdout were a terminal, unless + // --no-color is set. + Terminal bool } // FunctionCredentials holds Secret credentials loaded from a file path. It @@ -159,12 +182,6 @@ func (c *simulateCmd) Help() string { // during parsing, so an unsupported render backend fails before any build or // cluster connection. func (c *simulateCmd) Validate() error { - // ponytail: -f takes one project file for now; Composition and XR - // files are the next input modes (crossplane/cli#367). - if len(c.Input) != 1 { - return errors.Errorf("-f takes exactly one project file, got %d inputs", len(c.Input)) - } - if c.MaxIterations < 1 { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } @@ -181,67 +198,298 @@ func (c *simulateCmd) Validate() error { return nil } -// Run builds the project's functions and previews its compositions against -// the cluster. +// Run previews the inputs -f names against the cluster: a project's +// compositions, with its functions built locally, or changed Composition or +// composite resource files, with functions from the cluster. func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config) error { + in, err := loadInputs(c.Input) + if err != nil { + return err + } + if err := validateModeFlags(in.mode, setFlags(k)); err != nil { + return err + } + if err := docker.Check(sigCtx); err != nil { - return errors.Wrap(err, "cannot connect to Docker, which project simulate needs to build and run functions") + return errors.Wrap(err, "cannot connect to Docker, which project simulate needs to run functions") } // Load the kubeconfig before the slow function build, so a bad context // fails fast. - restCfg, _, err := kube.RESTConfig(c.Context, c.Impersonation) + restCfg, err := c.restConfig() if err != nil { return err } - projFilePath, err := filepath.Abs(c.Input[0]) + var fns []pkgv1.Function + comps := in.resources + if in.mode == projectMode { + comps, fns, err = c.buildProject(sigCtx, logger, sp, cliCfg, in.projectFile) + if err != nil { + return err + } + } + + clients, err := client.New(restCfg, logger) if err != nil { return err } + + opts := c.processorOptions(k, logger, fns) + xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) + + // ponytail: the timeout bounds the whole simulation because the diff + // engine has no per-XR deadline. Make it per composite resource once the + // engine supports one. + ctx, cancel := context.WithTimeout(sigCtx, c.Timeout) + defer cancel() + + if in.mode == xrMode { + // Compositions and functions come from the cluster, as in + // crossplane-diff's xr command. A claim is simulated as its XR; the + // design's printing of the result under the claim's name + // (crossplane/cli#367) belongs in the renderer, keyed by the claim + // labels the engine copies onto the XR. + return c.simulate(ctx, logger, clients.Initialize, xrProc, func(ctx context.Context) (bool, error) { + hasDiffs, err := xrProc.PerformDiff(ctx, in.resources, clients.XpClients.Composition.FindMatchingComposition) + return hasDiffs, errors.Wrap(err, "cannot simulate composite resources") + }) + } + + proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) + return c.simulate(ctx, logger, clients.Initialize, proc, c.diffCompositions(proc, comps, in.mode)) +} + +// restConfig returns the cluster to simulate against: the hooks' when set, +// otherwise the kubeconfig's. +func (c *simulateCmd) restConfig() (*rest.Config, error) { + if c.Hooks != nil && c.Hooks.RESTConfig != nil { + return c.Hooks.RESTConfig, nil + } + cfg, _, err := kube.RESTConfig(c.Context, c.Impersonation) + return cfg, err +} + +// buildProject returns the project's compositions and its locally built +// functions. +func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config, projectFile string) ([]*un.Unstructured, []pkgv1.Function, error) { + projFilePath, err := filepath.Abs(projectFile) + if err != nil { + return nil, nil, err + } projFS := afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)) projFileName := filepath.Base(projFilePath) proj, err := projectfile.Parse(projFS, projFileName) if err != nil { - return errors.Wrapf(err, "failed to parse project file %q", c.Input[0]) + return nil, nil, errors.Wrapf(err, "failed to parse project file %q", projectFile) } comps, err := project.LoadCompositions(proj, projFS) if err != nil { - return errors.Wrap(err, "cannot load compositions from project") + return nil, nil, errors.Wrap(err, "cannot load compositions from project") } if len(comps) == 0 { - return errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) + return nil, nil, errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) } - fns, err := project.LoadFunctions(sigCtx, logger, sp, cliCfg, projFS, projFilePath, projFileName, c.MaxConcurrency, c.newClientAndResolver) + fns, err := project.LoadFunctions(ctx, logger, sp, cliCfg, projFS, projFilePath, projFileName, c.MaxConcurrency, c.newClientAndResolver) if err != nil { - return err + return nil, nil, err } + return comps, fns, nil +} - clients, err := client.New(restCfg, logger) +// diffCompositions returns the diff simulate runs for Compositions: their +// impact on the composite resources in scope. +func (c *simulateCmd) diffCompositions(proc dp.CompDiffProcessor, comps []*un.Unstructured, mode inputMode) func(context.Context) (bool, error) { + msg := "cannot simulate Compositions" + if mode == projectMode { + msg = "cannot simulate project" + } + return func(ctx context.Context) (bool, error) { + ns, refs := c.scope() + hasDiffs, err := proc.DiffComposition(ctx, comps, ns, refs) + return hasDiffs, errors.Wrap(err, msg) + } +} + +// inputMode is what -f names, which decides where the simulation's +// Compositions and functions come from. +type inputMode int + +const ( + // projectMode simulates a project: its Compositions, with its functions + // built locally and overlaid on the cluster's. + projectMode inputMode = iota + // compositionMode simulates changed Compositions against the composite + // resources on the cluster that use them, with the cluster's functions. + compositionMode + // xrMode simulates changed composite resources (or claims) with the + // cluster's Compositions and functions. + xrMode +) + +// inputs are the loaded -f inputs. +type inputs struct { + mode inputMode + + // projectFile is the project file, in projectMode. + projectFile string + + // resources are the Compositions or composite resources to simulate, + // in the other modes. + resources []*un.Unstructured +} + +// loadInputs loads and classifies the -f inputs. A single project file +// selects projectMode. Anything else is loaded as resources: Compositions +// select compositionMode, and resources with no Composition among them +// select xrMode. A project file among other inputs, or Compositions mixed +// with other resources, are rejected. +func loadInputs(paths []string) (*inputs, error) { + if len(paths) == 1 && paths[0] != "-" { + isProj, err := isProjectFile(paths[0]) + if err != nil { + return nil, err + } + if isProj { + return &inputs{mode: projectMode, projectFile: paths[0]}, nil + } + } + + loader, err := ld.NewCompositeLoader(paths) if err != nil { - return err + return nil, errors.Wrap(err, "cannot load -f inputs") + } + resources, err := loader.Load() + if err != nil { + return nil, errors.Wrap(err, "cannot load -f inputs") + } + // The loader also returns each Composition's pipeline step inputs, which + // are part of the Composition rather than resources of their own. + stepInputs, err := pipelineInputs(resources) + if err != nil { + return nil, err + } + + var comps, others []*un.Unstructured + for _, r := range resources { + gvk := r.GroupVersionKind() + switch { + case gvk.GroupVersion().String() == projectfile.APIVersion && gvk.Kind == projectfile.Kind: + return nil, errors.New("a project file must be the only -f input, named by its path; pass it alone, or pass Composition or composite resource files without it") + case gvk == apiextensionsv1.CompositionGroupVersionKind: + comps = append(comps, r) + default: + key, err := json.Marshal(r.Object) + if err != nil { + return nil, errors.Wrapf(err, "cannot compare %s %q to Composition pipeline inputs", gvk.Kind, r.GetName()) + } + if stepInputs[string(key)] > 0 { + stepInputs[string(key)]-- + continue + } + others = append(others, r) + } } - opts := c.processorOptions(k, logger, fns) - xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) - proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) + switch { + case len(comps) == 0: + return &inputs{mode: xrMode, resources: others}, nil + case len(others) == 0: + return &inputs{mode: compositionMode, resources: comps}, nil + default: + return nil, errors.Errorf("-f inputs mix Compositions with other resources (%s %q); simulate Compositions and composite resources in separate runs", others[0].GetKind(), others[0].GetName()) + } +} - // ponytail: the timeout bounds the whole simulation because the diff - // engine has no per-XR deadline. Make it per composite resource once the - // engine supports one. - ctx, cancel := context.WithTimeout(sigCtx, c.Timeout) - defer cancel() +// isProjectFile reports whether path is a project file. A directory is not. +func isProjectFile(path string) (bool, error) { + fi, err := os.Stat(path) + if err != nil { + return false, errors.Wrapf(err, "cannot read -f input %q", path) + } + if fi.IsDir() { + return false, nil + } + isProj, err := projectfile.IsProjectFile(afero.NewOsFs(), path) + return isProj, errors.Wrapf(err, "cannot read -f input %q", path) +} - return c.simulate(ctx, logger, clients.Initialize, proc, comps) +// pipelineInputs returns the JSON of every pipeline step input of the +// Compositions in resources, counted, so loadInputs can tell the inputs the +// loader extracted from those Compositions from resources of their own. +func pipelineInputs(resources []*un.Unstructured) (map[string]int, error) { + inputs := map[string]int{} + for _, r := range resources { + if r.GroupVersionKind() != apiextensionsv1.CompositionGroupVersionKind { + continue + } + steps, _, _ := un.NestedSlice(r.Object, "spec", "pipeline") + for _, step := range steps { + in, ok := step.(map[string]any)["input"] + if !ok || in == nil { + continue + } + key, err := json.Marshal(in) + if err != nil { + return nil, errors.Wrapf(err, "cannot read the pipeline inputs of Composition %q", r.GetName()) + } + inputs[string(key)]++ + } + } + return inputs, nil +} + +// validateModeFlags rejects flags set on the command line that don't apply +// to mode. Composite resource files name the composite resources to +// simulate, so the flags that pick and tune which ones a Composition change +// reaches don't apply to them, as in crossplane-diff, whose xr command lacks +// its comp command's equivalents. Functions come from the cluster unless -f +// names a project, so the build flags apply only to a project. +func validateModeFlags(mode inputMode, set map[string]bool) error { + if mode == projectMode { + return nil + } + for _, f := range []string{"max-concurrency", "cache-dir"} { + if set[f] { + return errors.Errorf("--%s configures the project's function builds, so it applies only when -f names a project file; Composition and composite resource files use the cluster's functions", f) + } + } + if mode != xrMode { + return nil + } + for _, f := range []string{"namespace", "name", "include-pinned", "minimize-composition", "analyze-on"} { + if set[f] { + return errors.Errorf("--%s selects which composite resources a Composition change reaches, so it doesn't apply when -f names composite resource files; drop it, or pass Composition or project files", f) + } + } + return nil +} + +// setFlags returns the names of the flags set on the command line, not +// those left at their defaults or set from the environment. +func setFlags(k *kong.Context) map[string]bool { + set := map[string]bool{} + for _, p := range k.Path { + if p.Flag != nil { + set[p.Flag.Name] = true + } + } + return set +} + +// processor is what simulate initializes and cleans up around a diff. +type processor interface { + Initialize(ctx context.Context) error + dp.Cleaner } -// simulate initializes the clients and processor and previews comps, mapping -// the result to the command's exit status. ctx must not be cancelled by its -// owner until simulate returns, so a cancellation reads as an interrupt. -func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initClients func(context.Context, logging.Logger) error, proc dp.CompDiffProcessor, comps []*un.Unstructured) (err error) { +// simulate initializes the clients and processor and runs diff, mapping the +// result to the command's exit status. ctx must not be cancelled by its owner +// until simulate returns, so a cancellation reads as an interrupt. +func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initClients func(context.Context, logging.Logger) error, proc processor, diff func(context.Context) (bool, error)) (err error) { // An interrupted run reports the interruption, not the cancelled calls it // caused. defer func() { @@ -257,15 +505,14 @@ func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initC return errors.Wrap(err, "cannot initialize clients") } if err := proc.Initialize(ctx); err != nil { - return errors.Wrap(err, "cannot initialize composition diff processor") + return errors.Wrap(err, "cannot initialize diff processor") } - ns, refs := c.scope() - hasDiffs, err := proc.DiffComposition(ctx, comps, ns, refs) + hasDiffs, err := diff(ctx) if err != nil { - // DiffComposition has already rendered the result, including its - // errors. A run that failed only schema validation exits 2. - return exitError{error: errors.Wrap(err, "cannot simulate project"), code: dp.DetermineExitCode(err, false)} + // The diff has already rendered the result, including its errors. A + // run that failed only schema validation exits 2. + return exitError{error: err, code: dp.DetermineExitCode(err, false)} } // Changes are what a simulation is for, so they fail the run only when @@ -297,7 +544,7 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f dp.WithAdvisories(dp.NewAdvisoryCollector(k.Stderr)), dp.WithStdout(k.Stdout), dp.WithStderr(k.Stderr), - dp.WithColorize(!c.NoColor && isTerminal(k.Stdout)), + dp.WithColorize(!c.NoColor && (c.Hooks != nil && c.Hooks.Terminal || isTerminal(k.Stdout))), dp.WithCompact(c.Compact), dp.WithMaxNestedDepth(c.MaxNestedDepth), dp.WithEventualState(c.EventualState), @@ -309,7 +556,12 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f dp.WithOutputVersion(renderer.OutputVersion(c.OutputVersion)), dp.WithIncludeManual(c.IncludePinned), dp.WithMaxRenderIterations(c.MaxIterations), - dp.WithFunctionProviderFactory(dp.NewProjectFunctionProviderFactory(fns)), + } + + // Project functions, built locally, replace the cluster's. With none, + // every function comes from the cluster. + if len(fns) > 0 { + opts = append(opts, dp.WithFunctionProviderFactory(dp.NewProjectFunctionProviderFactory(fns))) } if len(c.FunctionCredentials.Secrets) > 0 { @@ -327,6 +579,9 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f if c.CrossplaneImage != "" { opts = append(opts, dp.WithCrossplaneImage(c.CrossplaneImage)) } + if c.Hooks != nil && c.Hooks.RenderBinary != "" { + opts = append(opts, dp.WithCrossplaneRenderBinary(c.Hooks.RenderBinary)) + } return opts } diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 34881369..31bd6ae9 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -22,6 +22,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" "time" @@ -179,10 +180,19 @@ func TestSimulateFlags(t *testing.T) { args: []string{"--analyze-on", "never"}, want: want{err: true}, }, - "TwoInputs": { - reason: "-f should take exactly one project file.", - args: []string{"-f", "a.yaml", "-f", "b.yaml"}, - want: want{err: true}, + "RepeatedInput": { + reason: "-f should be repeatable, and a comma should not split a path.", + args: []string{"-f", "a.yaml", "-f", "b,c.yaml"}, + want: want{cmd: &simulateCmd{ + Input: []string{"a.yaml", "b,c.yaml"}, + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: time.Minute, + Output: "diff", + OutputVersion: "v1", + MaxNestedDepth: 10, + DryRunOn: "all", + }}, }, "UnknownOutputVersion": { reason: "Structured output schema versions other than v1 and v2 should be rejected.", @@ -314,7 +324,7 @@ func TestSimulateProcessorOptions(t *testing.T) { } cfg := dp.ProcessorConfig{} - for _, o := range c.processorOptions(k, logging.NewNopLogger(), []pkgv1.Function{}) { + for _, o := range c.processorOptions(k, logging.NewNopLogger(), []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "fn"}}}) { o(&cfg) } @@ -390,8 +400,32 @@ func TestSimulateProcessorOptions(t *testing.T) { if diff := cmp.Diff(want, g); diff != "" { t.Errorf("processorOptions(...): -want, +got:\n%s", diff) } + + // Without project functions every function comes from the cluster, so + // there is nothing to overlay. + cfg = dp.ProcessorConfig{} + for _, o := range c.processorOptions(k, logging.NewNopLogger(), nil) { + o(&cfg) + } + if cfg.Factories.FunctionProvider != nil { + t.Errorf("processorOptions(..., nil): want no function provider overlay, got one") + } + + // The test hooks render with a local binary and colorize as for a + // terminal. + c.Hooks = &SimulateHooks{RenderBinary: "/bin/crossplane", Terminal: true} + c.CrossplaneVersion = "" + cfg = dp.ProcessorConfig{} + for _, o := range c.processorOptions(k, logging.NewNopLogger(), nil) { + o(&cfg) + } + if cfg.CrossplaneRenderBinary != "/bin/crossplane" || !cfg.Colorize { + t.Errorf("processorOptions(...) with hooks: want render binary %q and colorize, got %q and %t", "/bin/crossplane", cfg.CrossplaneRenderBinary, cfg.Colorize) + } } +var _ dp.CompDiffProcessor = &fakeCompProc{} + // fakeCompProc is a CompDiffProcessor that records its calls. type fakeCompProc struct { initErr error @@ -537,7 +571,8 @@ func TestSimulateRun(t *testing.T) { cancel() } - err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), tc.args.initClients, tc.args.proc, []*un.Unstructured{{}}) + diff := tc.args.cmd.diffCompositions(tc.args.proc, []*un.Unstructured{{}}, compositionMode) + err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), tc.args.initClients, tc.args.proc, diff) if diff := cmp.Diff(tc.want.code, exitCode(err)); diff != "" { t.Errorf("\n%s\nsimulate(...): -want exit code, +got:\n%s", tc.reason, diff) @@ -560,3 +595,223 @@ func TestSimulateRun(t *testing.T) { }) } } + +func TestLoadInputs(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path + } + + proj := write("crossplane-project.yaml", "apiVersion: dev.crossplane.io/v1alpha1\nkind: Project\nmetadata:\n name: p\n") + comp := write("comp.yaml", `apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: c +spec: + compositeTypeRef: + apiVersion: example.org/v1 + kind: XThing + mode: Pipeline + pipeline: + - step: render + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline +`) + xr := write("xr.yaml", "apiVersion: example.org/v1\nkind: XThing\nmetadata:\n name: x\n") + claim := write("claim.yaml", "apiVersion: example.org/v1\nkind: Thing\nmetadata:\n name: t\n namespace: default\n") + // A GoTemplate of its own is a resource, not a pipeline input. + goTemplate := write("gotemplate.yaml", "apiVersion: gotemplating.fn.crossplane.io/v1beta1\nkind: GoTemplate\nsource: Inline\n") + comps := write("comps/a.yaml", "apiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: a\n") + empty := write("empty.yaml", "# nothing here\n") + notProject := write("meta.yaml", "apiVersion: meta.pkg.crossplane.io/v1\nkind: Configuration\nmetadata:\n name: cfg\n") + + type want struct { + mode inputMode + projectFile string + names []string + err string + } + + cases := map[string]struct { + reason string + paths []string + want want + }{ + "ProjectFile": { + reason: "A single project file should select project mode.", + paths: []string{proj}, + want: want{mode: projectMode, projectFile: proj}, + }, + "Composition": { + reason: "A Composition should select composition mode; its pipeline inputs are part of it.", + paths: []string{comp}, + want: want{mode: compositionMode, names: []string{"c"}}, + }, + "CompositionDirectory": { + reason: "A directory of Compositions should select composition mode.", + paths: []string{filepath.Dir(comps)}, + want: want{mode: compositionMode, names: []string{"a"}}, + }, + "Compositions": { + reason: "Several Composition files should select composition mode.", + paths: []string{comp, comps}, + want: want{mode: compositionMode, names: []string{"c", "a"}}, + }, + "XRAndClaim": { + reason: "Composite resources and claims should select XR mode.", + paths: []string{xr, claim}, + want: want{mode: xrMode, names: []string{"x", "t"}}, + }, + "NonProjectSingleFile": { + reason: "A single file that isn't a project file should be loaded as resources.", + paths: []string{notProject}, + want: want{mode: xrMode, names: []string{"cfg"}}, + }, + "ProjectWithResources": { + reason: "A project file combined with resource files should be rejected.", + paths: []string{proj, xr}, + want: want{err: "a project file must be the only -f input"}, + }, + "ProjectInDirectory": { + reason: "A directory holding a project file should be rejected the same way.", + paths: []string{dir}, + want: want{err: "a project file must be the only -f input"}, + }, + "CompositionWithXR": { + reason: "Compositions mixed with composite resources should be rejected.", + paths: []string{comp, xr}, + want: want{err: `-f inputs mix Compositions with other resources (XThing "x")`}, + }, + "CompositionWithStandaloneGoTemplate": { + reason: "A resource that only looks like a pipeline input should still count as another resource.", + paths: []string{comp, goTemplate, goTemplate}, + want: want{err: "-f inputs mix Compositions with other resources (GoTemplate"}, + }, + "Empty": { + reason: "Inputs with no resources should be rejected.", + paths: []string{empty}, + want: want{err: "no resources found"}, + }, + "Missing": { + reason: "A missing input should be rejected.", + paths: []string{filepath.Join(dir, "nope.yaml")}, + want: want{err: "cannot read -f input"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := loadInputs(tc.paths) + if tc.want.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.want.err) { + t.Fatalf("\n%s\nloadInputs(%v): want error containing %q, got %v", tc.reason, tc.paths, tc.want.err, err) + } + return + } + if err != nil { + t.Fatalf("\n%s\nloadInputs(%v): %v", tc.reason, tc.paths, err) + } + names := []string{} + for _, r := range got.resources { + names = append(names, r.GetName()) + } + if diff := cmp.Diff(tc.want, want{mode: got.mode, projectFile: got.projectFile, names: names}, cmp.AllowUnexported(want{}), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("\n%s\nloadInputs(%v): -want, +got:\n%s", tc.reason, tc.paths, diff) + } + }) + } +} + +func TestValidateModeFlags(t *testing.T) { + cases := map[string]struct { + reason string + mode inputMode + args []string + err string + }{ + "ProjectAcceptsAll": { + reason: "A project accepts every flag.", + mode: projectMode, + args: []string{"-n", "a", "--name", "b", "--include-pinned", "--max-concurrency", "2", "--cache-dir", "/c", "--analyze-on", "always", "--minimize-composition"}, + }, + "CompositionAcceptsScope": { + reason: "Compositions accept the flags that scope their impact.", + mode: compositionMode, + args: []string{"-n", "a", "--name", "b", "--include-pinned", "--analyze-on", "always", "--minimize-composition"}, + }, + "CompositionRejectsBuildFlags": { + reason: "Compositions use the cluster's functions, so the build flags don't apply.", + mode: compositionMode, + args: []string{"--max-concurrency", "2"}, + err: "--max-concurrency configures the project's function builds", + }, + "XRRejectsCacheDir": { + reason: "Composite resources use the cluster's functions, so the build flags don't apply.", + mode: xrMode, + args: []string{"--cache-dir", "/c"}, + err: "--cache-dir configures the project's function builds", + }, + "XRRejectsNamespace": { + reason: "Composite resource files name what to simulate, so -n doesn't apply.", + mode: xrMode, + args: []string{"-n", "a"}, + err: "--namespace selects which composite resources a Composition change reaches", + }, + "XRRejectsIncludePinned": { + reason: "--include-pinned doesn't apply to composite resource files.", + mode: xrMode, + args: []string{"--include-pinned"}, + err: "--include-pinned selects", + }, + "XRAcceptsCommon": { + reason: "Composite resources accept the flags shared with crossplane-diff's xr command.", + mode: xrMode, + args: []string{"-o", "json", "--no-color", "--ignore-paths", "a.b", "--eventual-state", "--timeout", "2m", "--dry-run-on", "existing"}, + }, + "DefaultsAreNotSet": { + reason: "Flags left at their defaults, or set from the environment, are not rejected.", + mode: xrMode, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Setenv("CROSSPLANE_XPKG_CACHE", "/env-cache") + + var cli struct { + Simulate simulateCmd `cmd:""` + } + parser, err := kong.New(&cli, kong.Vars{"project_file": "crossplane-project.yaml"}, kong.Exit(func(int) {})) + if err != nil { + t.Fatalf("kong.New(...): %v", err) + } + k, err := parser.Parse(append([]string{"simulate"}, tc.args...)) + if err != nil { + t.Fatalf("Parse(%v): %v", tc.args, err) + } + + err = validateModeFlags(tc.mode, setFlags(k)) + if tc.err == "" { + if err != nil { + t.Errorf("\n%s\nvalidateModeFlags(...): %v", tc.reason, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.err) { + t.Errorf("\n%s\nvalidateModeFlags(...): want error containing %q, got %v", tc.reason, tc.err, err) + } + }) + } +} From 9497f572124098293c62d0f15d7ded47be3b62d8 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 13:05:55 -0400 Subject: [PATCH 035/126] test(diff): run the integration cases through project simulate runSimulate now translates each case's crossplane-diff args into project simulate args and runs the real command through kong, with SimulateHooks pointing it at the case's envtest cluster. Composition cases exercise simulate's Composition-file mode, XR cases its composite resource mode. The case tables are unchanged, and the same 139 of 142 subtests pass as through the engine harness; the rest are the repeated --resource gap and two upstream skips. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/README.md | 27 +- .../diff/integration/simulate_harness_test.go | 417 +++++------------- 2 files changed, 134 insertions(+), 310 deletions(-) diff --git a/internal/diff/integration/README.md b/internal/diff/integration/README.md index fd43ee39..2d65bf5b 100644 --- a/internal/diff/integration/README.md +++ b/internal/diff/integration/README.md @@ -7,26 +7,25 @@ the harness wiring differs. ## How a case runs -Every case is CLI-style arguments plus an expected exit code and expected -output, funneled through one function: `runSimulate` in -`simulate_harness_test.go`. The harness wires this repository's ported -diff engine (`internal/diff/diffprocessor`) the way crossplane-diff's -`comp` and `xr` commands wire it — same options, same load path, same -`DetermineExitCode` — and reports the exit code the command would exit -with. - -When `crossplane project simulate` grows the surface these cases need, -`runSimulate`'s body is replaced with a call to the real command. No case -table changes. +Every case is crossplane-diff CLI-style arguments plus an expected exit +code and expected output, funneled through one function: `runSimulate` in +`simulate_harness_test.go`. It translates the arguments into +`crossplane project simulate` arguments (input files as `-f`, a single +`--resource` as `--name`, `--include-manual` as `--include-pinned`, +`--analyze-unchanged` as `--analyze-on=always`, plus `--exit-code`) and +runs the real command through kong. `project.SimulateHooks` points the +command at the case's envtest cluster in place of a kubeconfig, so the +cases run in parallel. Composition cases run simulate's Composition-file +mode, XR cases its composite resource mode. ## Skips Cases are never dropped. A case the current simulate surface cannot express is ported in full and skipped with a greppable reason: -- `gap:` — the simulate command lacks the feature. Enable the - cases by implementing the feature and removing the gap from - `simulateGap`. +- `gap:` — the simulate command lacks the feature, or behaves + differently from crossplane-diff. Enable the cases by implementing the + feature and removing the gap from `simulateGap`. - `env:` — the environment lacks something. The render engine runs functions as Docker containers (no Docker daemon skips the case), and envtest needs `KUBEBUILDER_ASSETS` pointing at kube-apiserver/etcd diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index d7050437..c3ead527 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -17,18 +17,11 @@ limitations under the License. */ // This file is the simulate harness: the one function every integration -// case funnels through. It ports crossplane-diff's command layer -// (origin/main 6a140b6: cmd/diff/main.go's CommonCmdFields and -// engineLimitVars, cmd_utils.go's defaultProcessorOptions, -// comp.go/xr.go's processor wiring and Run bodies) onto this repository's -// ported engine, driving it with CLI-style arguments the way the real -// commands' kong wiring does. -// -// It exists because `crossplane project simulate` does not yet cover the -// whole surface these cases need. When it does, replace runSimulate's -// body with a call to the real command and no case table changes: the -// cases are CLI args plus an expected exit code and expected output, and -// runSimulate is the only thing standing between them and execution. +// case funnels through. The cases are crossplane-diff's (origin/main +// 6a140b6), written as its comp and xr commands' CLI args plus an expected +// exit code and output. runSimulate translates those args into +// `crossplane project simulate` args and runs the real command through +// kong against the case's envtest cluster, so no case table changes. // // # Gap and environment skips // @@ -49,56 +42,45 @@ import ( "io" "os" "path/filepath" - "strconv" - "strings" "sync" "testing" "time" "github.com/alecthomas/kong" - corev1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/rest" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - ld "github.com/crossplane/cli/v2/cmd/crossplane/common/load" - "github.com/crossplane/cli/v2/internal/diff/client" + "github.com/crossplane/cli/v2/cmd/crossplane/project" + "github.com/crossplane/cli/v2/internal/config" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/ref" - "github.com/crossplane/cli/v2/internal/diff/renderer" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" ) -// simArgs mirrors the crossplane-diff flags the integration cases pass: -// the CommonCmdFields subset the runner threads, plus each command's -// positional files. Defaults match crossplane-diff's, including the -// engineLimitVars defaults main.go feeds kong. +// simArgs mirrors the crossplane-diff flags the integration cases pass, +// plus each command's positional files, so runSimulate can translate +// them into project simulate's flags. type simArgs struct { - Timeout time.Duration `default:"1m" help:"How long to run before timing out."` - Namespace string `help:"Namespace of the composite resources."` - Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` - OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2)." name:"output-version"` - NoColor bool `help:"Disable colorized output." name:"no-color"` - MaxNestedDepth int `default:"${max_nested_depth}" help:"Maximum depth for nested XR recursion." name:"max-nested-depth"` - MaxIterations int `default:"${max_iterations}" help:"Maximum render iterations." name:"max-iterations"` - IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` - FunctionCredentials string `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` - EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` - DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` - Resource []string `help:"Composite resources to scope to." sep:","` - IncludeManual bool `help:"Include composites with a manual update policy." name:"include-manual"` - AnalyzeUnchanged bool `help:"Analyze compositions identical to their in-cluster version (deprecated; --analyze-on=always)." name:"analyze-unchanged"` - AnalyzeOn string `help:"Smallest composition change that triggers per-composite impact analysis." name:"analyze-on"` - Files []string `arg:"" help:"Input files." optional:""` - - // functionSecrets holds the Secrets --function-credentials loaded at - // parse time, the way crossplane-diff's kong MapperValue and - // simulate's own flag do: a bad path fails before any build or - // cluster connection. - functionSecrets []corev1.Secret + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + CrossplaneRenderBinary string `help:"Local crossplane binary to render with." name:"crossplane-render-binary"` + Namespace string `help:"Namespace of the composite resources."` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` + OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2)." name:"output-version"` + NoColor bool `help:"Disable colorized output." name:"no-color"` + IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` + FunctionCredentials string `help:"Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` + EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` + Resource []string `help:"Composite resources to scope to." sep:","` + IncludeManual bool `help:"Include composites with a manual update policy." name:"include-manual"` + AnalyzeUnchanged bool `help:"Analyze compositions identical to their in-cluster version." name:"analyze-unchanged"` + AnalyzeOn string `help:"Smallest composition change that triggers impact analysis." name:"analyze-on"` + Files []string `arg:"" help:"Input files." optional:""` } // parseSimulateArgs parses one case's CLI-style args. A parse failure is @@ -107,7 +89,7 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA t.Helper() var a simArgs - parser, err := kong.New(&a, engineLimitVars(), kong.Name(string(testType))) + parser, err := kong.New(&a, kong.Name(string(testType))) if err != nil { t.Fatalf("failed to create kong parser: %v", err) } @@ -116,64 +98,13 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA t.Fatalf("failed to parse args %v: %v", args, err) } - if a.FunctionCredentials != "" { - secrets, err := loadFunctionSecrets(a.FunctionCredentials) - if err != nil { - t.Fatalf("failed to load --function-credentials %q: %v", a.FunctionCredentials, err) - } - if len(secrets) == 0 { - t.Fatalf("no Secret resources found in %q: file must contain v1/Secret resources", a.FunctionCredentials) - } - a.functionSecrets = secrets - } - return &a } -// loadFunctionSecrets mirrors simulate's loadFunctionCredentials: the v1 -// Secrets in a YAML file or directory, other resources skipped. -func loadFunctionSecrets(path string) ([]corev1.Secret, error) { - loader, err := ld.NewLoader(path) - if err != nil { - return nil, errors.Wrapf(err, "cannot create loader for path %q", path) - } - resources, err := loader.Load() - if err != nil { - return nil, errors.Wrapf(err, "cannot load resources from %q", path) - } - - secrets := make([]corev1.Secret, 0, len(resources)) - for _, res := range resources { - if res.GetKind() != "Secret" || res.GetAPIVersion() != "v1" { - continue - } - secret := corev1.Secret{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(res.UnstructuredContent(), &secret); err != nil { - return nil, errors.Wrapf(err, "cannot convert Secret %q to corev1.Secret", res.GetName()) - } - secrets = append(secrets, secret) - } - return secrets, nil -} - -// engineLimitVars ports crossplane-diff main.go's kong.Vars of the same -// name: the engine's defaults for --max-nested-depth and -// --max-iterations, so the flags' defaults follow the engine rather than -// a number duplicated here. -func engineLimitVars() kong.Option { - return kong.Vars{ - "max_nested_depth": strconv.Itoa(dp.DefaultMaxNestedDepth), - "max_iterations": strconv.Itoa(dp.DefaultMaxRenderIterations), - } -} - // simulateGap returns the reason the parsed args cannot run against the // current simulate surface, or "" when they can. The runner calls it // before starting envtest, so a skipped case costs nothing, and // runSimulate never executes args it could not express. -// -// Keep this in sync with cmd/crossplane/project/simulate.go's flags: a -// flag that lands there moves out of here, and its cases start running. func simulateGap(_ DiffTestType, a *simArgs) string { // simulate scopes to one composite resource with --name (with // --namespace for a namespaced one); it cannot express the repeated @@ -185,222 +116,122 @@ func simulateGap(_ DiffTestType, a *simArgs) string { return "" } -// runSimulate runs one case's args against the diff engine wired the way -// crossplane-diff's comp and xr commands wire it, and returns the exit -// code the command would exit with, the error it would fail with, and -// everything it wrote. Fresh clients per call, the way a second CLI -// invocation would see cold caches; the determinism cases rely on it. -func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []string, stdout, stderr io.Writer) (int, error) { +// simulateArgv translates the parsed crossplane-diff args into project +// simulate's: the input files as -f, a single --resource as --name (and +// --namespace), --include-manual as --include-pinned, and +// --analyze-unchanged as its replacement --analyze-on=always. --exit-code +// makes simulate exit 3 on changes, as crossplane-diff always does. +func simulateArgv(t *testing.T, a *simArgs) []string { t.Helper() - a := parseSimulateArgs(t, testType, args) - - // Bind an advisory collector the way main() does, so advisories land - // on stderr AND in structured output. - logger := tu.TestLogger(t, true) - advisories := dp.NewAdvisoryCollector(stderr) - - exitCode := dp.ExitCodeSuccess - - run := func() (int, error) { - clients, err := client.New(cfg, logger) - if err != nil { - return dp.ExitCodeToolError, errors.Wrap(err, "cannot create clients") - } - - opts := processorOptions(a, stdout, stderr, logger, advisories) - xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) - - loader, err := ld.NewCompositeLoader(a.Files) - if err != nil { - return dp.ExitCodeToolError, errors.Wrap(err, "cannot create resource loader") - } + argv := []string{ + "project", "simulate", "--exit-code", + "--timeout=" + a.Timeout.String(), + "--output=" + a.Output, + "--output-version=" + a.OutputVersion, + "--dry-run-on=" + a.DryRunOn, + } + for _, f := range a.Files { + argv = append(argv, "-f", f) + } - ctx, cancel, err := initializeClients(context.Background(), a.Timeout, clients, logger) + ns := a.Namespace + if len(a.Resource) == 1 { + r, err := ref.Parse(a.Resource[0]) if err != nil { - return dp.ExitCodeToolError, err + t.Fatalf("cannot parse --resource %q: %v", a.Resource[0], err) } - defer cancel() - - // An interrupted run reports the interruption, not the cancelled - // calls it caused. Registered after cancel, so it runs before it: - // the run's own cancel must not read as an interrupt. - err = func() (err error) { - defer func() { - if dp.Interrupted(ctx) { - exitCode, err = dp.ExitCodeInterrupted, dp.ErrInterrupted - } - }() - - // Covers paths that return before rendering; Cleanup is - // idempotent. - defer dp.CleanupDetached(ctx, procFor(testType, xrProc, clients, opts), logger) - - switch testType { - case XRDiffTest: - resources, err := loader.Load() - if err != nil { - return errors.Wrap(err, "cannot load resources") - } - - if err := xrProc.Initialize(ctx); err != nil { - return errors.Wrap(err, "cannot initialize diff processor") - } - - hasDiffs, err := xrProc.PerformDiff(ctx, resources, clients.XpClients.Composition.FindMatchingComposition) - exitCode = dp.DetermineExitCode(err, hasDiffs) - if err != nil { - return errors.Wrap(err, "unable to process one or more resources") - } - - return nil - - case CompositionDiffTest: - proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) - - if err := proc.Initialize(ctx); err != nil { - return errors.Wrap(err, "cannot initialize composition diff processor") - } - - compositions, err := loader.Load() - if err != nil { - return errors.Wrap(err, "cannot load compositions") - } - - // A single --resource scopes to one composite resource, as - // simulate's --name does; the repeated and comma-separated - // forms never reach here (simulateGap skips them first). - refs, err := ref.ParseAll(a.Resource) - if err != nil { - return err - } - - hasDiffs, err := proc.DiffComposition(ctx, compositions, a.Namespace, refs) - exitCode = dp.DetermineExitCode(err, hasDiffs) - if err != nil { - return errors.Wrap(err, "unable to process composition diff") - } - - return nil - } - - t.Fatalf("unknown test type %q", testType) - return nil - }() - - return exitCode, err + ns = r.Namespace + argv = append(argv, "--name="+r.Name) + } + if ns != "" { + argv = append(argv, "--namespace="+ns) } - return run() -} - -// procFor builds the composition processor for a comp run, for -// CleanupDetached: the comp command cleans up the composition processor, -// which wraps the XR processor the render engine runs on. -func procFor(testType DiffTestType, xrProc dp.DiffProcessor, clients *client.Clients, opts []dp.ProcessorOption) dp.Cleaner { - if testType != CompositionDiffTest { - return xrProc + if a.NoColor { + argv = append(argv, "--no-color") + } + for _, p := range a.IgnorePaths { + argv = append(argv, "--ignore-paths="+p) + } + if a.FunctionCredentials != "" { + argv = append(argv, "--function-credentials="+a.FunctionCredentials) + } + if a.EventualState { + argv = append(argv, "--eventual-state") + } + if a.IncludeManual { + argv = append(argv, "--include-pinned") } - return dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) -} -// initializeClients ports crossplane-diff's initializeAppContext over the -// ported client bundle: the run context bounded by the timeout, with the -// clients initialized on it. -func initializeClients(sigCtx context.Context, timeout time.Duration, clients *client.Clients, log logging.Logger) (context.Context, context.CancelFunc, error) { - ctx, cancel := context.WithTimeout(sigCtx, timeout) - if err := clients.Initialize(ctx, log); err != nil { - cancel() - return nil, nil, errors.Wrap(err, "cannot initialize client") + switch { + case a.AnalyzeOn != "": + argv = append(argv, "--analyze-on="+a.AnalyzeOn) + case a.AnalyzeUnchanged: + argv = append(argv, "--analyze-on=always") } - return ctx, cancel, nil + return argv } -// processorOptions returns the options both processors get, replicating -// crossplane-diff's defaultProcessorOptions (cmd/diff/cmd_utils.go) — the -// single source of truth for behavior defaults in the command layer — -// plus the wiring its comp and xr commands add around the logger, the -// advisory channel, stdout and stderr. -func processorOptions(a *simArgs, stdout, stderr io.Writer, logger logging.Logger, advisories *dp.AdvisoryCollector) []dp.ProcessorOption { - // IgnorePaths carries only what the user asked to mask. The paths that - // are always filtered (kubectl's last-applied-configuration) are - // stripped unconditionally by the renderer instead — see - // renderer.alwaysIgnoredPaths. Prepending them here would make "the - // user passed --ignore-paths" indistinguishable from "we always ignore - // something", which comp-diff relies on to tell an edited composition - // from a merely re-applied one. - opts := make([]dp.ProcessorOption, 0, 15) - opts = append(opts, - dp.WithLogger(logger), - dp.WithAdvisories(advisories), - dp.WithColorize(!a.NoColor), - dp.WithMaxNestedDepth(a.maxNestedDepth()), - dp.WithMaxRenderIterations(a.maxIterations()), - dp.WithEventualState(a.EventualState), - dp.WithIgnorePaths(a.IgnorePaths), - dp.WithDryRunOn(dp.DryRunOn(a.DryRunOn)), - dp.WithIncludeManual(a.IncludeManual), - dp.WithMinimizeComposition(false), - dp.WithAnalyzeOn(a.analyzeOn()), - dp.WithStdout(stdout), - dp.WithStderr(stderr), - ) +// runSimulate runs one case's args through the real `crossplane project +// simulate` command against cfg, and returns the exit code the command +// would exit with, the error it would fail with, and everything it wrote. +// Each call parses a fresh command, so a second run starts with cold +// client caches just as a second CLI invocation would; the determinism +// cases rely on it. +func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []string, stdout, stderr io.Writer) (int, error) { + t.Helper() - var outputFormat renderer.OutputFormat + a := parseSimulateArgs(t, testType, args) - switch renderer.OutputFormat(a.Output) { - case renderer.OutputFormatJSON: - outputFormat = renderer.OutputFormatJSON - case renderer.OutputFormatYAML: - outputFormat = renderer.OutputFormatYAML - case renderer.OutputFormatDiff: - outputFormat = renderer.OutputFormatDiff - default: - // Empty string or unrecognized values fall back to the human-readable diff. - outputFormat = renderer.OutputFormatDiff + var cli struct { + Project project.Cmd `cmd:""` } - - opts = append(opts, dp.WithOutputFormat(outputFormat), dp.WithOutputVersion(renderer.OutputVersion(a.OutputVersion))) - - // Function credentials load at parse time in crossplane-diff (a kong - // MapperValue), as parseSimulateArgs does here; the processor gets - // them only when the flag loaded some. - if len(a.functionSecrets) > 0 { - opts = append(opts, dp.WithFunctionCredentials(a.functionSecrets)) + parser, err := kong.New(&cli, + kong.Vars{"project_file": clixpkg.ProjectFile}, + kong.Writers(stdout, stderr), + kong.Exit(func(int) { t.Fatal("kong tried to exit the test binary") }), + kong.BindTo(context.Background(), (*context.Context)(nil)), + kong.BindTo(tu.TestLogger(t, true), (*logging.Logger)(nil)), + kong.BindTo(terminal.NewNopSpinnerPrinter(), (*terminal.SpinnerPrinter)(nil)), + kong.Bind(&config.Config{}), + ) + if err != nil { + t.Fatalf("failed to create kong parser: %v", err) } - return opts -} - -// analyzeOn ports crossplane-diff comp.go's analyzeOn(): the -// --analyze-on value, or --analyze-unchanged's equivalent "always", or -// the engine default with neither. -func (a *simArgs) analyzeOn() dp.AnalyzeOn { - if a.AnalyzeOn != "" { - return dp.AnalyzeOn(a.AnalyzeOn) - } - if a.AnalyzeUnchanged { - return dp.AnalyzeOnAlways + argv := simulateArgv(t, a) + kctx, err := parser.Parse(argv) + if err != nil { + t.Fatalf("failed to parse project simulate args %v: %v", argv, err) } - return dp.AnalyzeOnAnyChange -} -// maxNestedDepth and maxIterations resolve the kong-var defaults, which -// kong leaves as "${max_nested_depth}" text when the vars are not fed to -// the parser — engineLimitVars feeds them, so these only guard that. -func (a *simArgs) maxNestedDepth() int { - if strings.HasPrefix(strconv.Itoa(a.MaxNestedDepth), "$") || a.MaxNestedDepth == 0 { - return dp.DefaultMaxNestedDepth + // Inject the case's cluster in place of the kubeconfig, and render the + // way crossplane-diff's tests did: with the local crossplane binary + // when there is one, and colorized unless --no-color. + cli.Project.Simulate.Hooks = &project.SimulateHooks{ + RESTConfig: cfg, + RenderBinary: a.CrossplaneRenderBinary, + Terminal: true, } - return a.MaxNestedDepth -} -func (a *simArgs) maxIterations() int { - if a.MaxIterations == 0 { - return dp.DefaultMaxRenderIterations + err = kctx.Run() + + var ec kong.ExitCoder + switch { + case err == nil: + return dp.ExitCodeSuccess, nil + case errors.As(err, &ec) && ec.ExitCode() == dp.ExitCodeDiffDetected: + // simulate signals changes found under --exit-code with an error + // carrying the exit code; crossplane-diff, whose cases these are, + // signals them with the exit code alone. + return dp.ExitCodeDiffDetected, nil + case errors.As(err, &ec): + return ec.ExitCode(), err + default: + return dp.ExitCodeToolError, err } - return a.MaxIterations } // dockerAvailable reports whether the local Docker daemon answers, which @@ -412,12 +243,6 @@ var dockerAvailable = sync.OnceValue(func() bool { return docker.Check(ctx) == nil }) -// ExitCode carries the exit code a run determined, matching -// crossplane-diff's cmd/diff type the case tables were written against. -type ExitCode struct { - Code int -} - // envtestBinariesAvailable reports whether the envtest control plane's // binaries are reachable: KUBEBUILDER_ASSETS points at them, or the // setup-envtest default install path exists. From 9f6f3b1f0437e0840c72be637b27f44a482ccf91 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 14:27:20 -0400 Subject: [PATCH 036/126] fix(project): stop ignoring last-applied-configuration in project simulate project simulate always added kubectl's last-applied-configuration annotation to --ignore-paths, so a change to it never counted as a change. crossplane-diff only hides the annotation from display and keeps it for verdicts, which the ported renderer already does, so pass only the user's --ignore-paths. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 5 +---- cmd/crossplane/project/simulate_test.go | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 84139dde..f541e873 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -551,7 +551,7 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f dp.WithMinimizeComposition(c.MinimizeComposition), dp.WithAnalyzeOn(dp.AnalyzeOn(c.AnalyzeOn)), dp.WithDryRunOn(dp.DryRunOn(c.DryRunOn)), - dp.WithIgnorePaths(append([]string{lastAppliedPath}, c.IgnorePaths...)), + dp.WithIgnorePaths(c.IgnorePaths), dp.WithOutputFormat(renderer.OutputFormat(c.Output)), dp.WithOutputVersion(renderer.OutputVersion(c.OutputVersion)), dp.WithIncludeManual(c.IncludePinned), @@ -607,9 +607,6 @@ func isTerminal(w io.Writer) bool { return ok && term.IsTerminal(f.Fd()) } -// lastAppliedPath is always ignored in diffs, as in crossplane-diff. -const lastAppliedPath = "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]" - // errChangesDetected is the error a run with --exit-code returns when it // finds changes. var errChangesDetected = errors.New("changes detected") diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 31bd6ae9..14074747 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -365,7 +365,7 @@ func TestSimulateProcessorOptions(t *testing.T) { HasFunctionProvider: true, Compact: true, MaxNestedDepth: 4, - IgnorePaths: []string{lastAppliedPath, "a.b"}, + IgnorePaths: []string{"a.b"}, Credentials: c.FunctionCredentials.Secrets, RegistryOverride: "reg.example.com", EventualState: true, From ab526963240e2167c9baf1d39603a9522a6dbfcb Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 14:27:20 -0400 Subject: [PATCH 037/126] fix(project): reject -f inputs that are not composite resources A single -f file that isn't a project file is loaded as resources, and anything but Compositions selects XR mode. Package metadata, a Provider, an XRD or a ConfigMap then failed deep in the engine with "resource ... requires its XR type to find a composition: no XRD found that defines XR type ...", with no hint at what -f accepts. Check each XR-mode input against the cluster's XRDs, as a claim and then as an XR, the same lookups FindMatchingComposition makes, and fail with the offending kind, name and what -f accepts. A static deny-list of Crossplane API groups was the alternative, but it would miss arbitrary non-XR kinds such as a ConfigMap, and an XRD check can't reject a real XR or claim. The definition client loads the XRDs once when it initializes, so the check makes no extra calls. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 4 +- cmd/crossplane/project/simulate.go | 23 +++++++++ cmd/crossplane/project/simulate_test.go | 67 ++++++++++++++++++++++++- 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 15015268..e04b7c88 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -20,7 +20,9 @@ directory, or pass `-` to read from standard input: functions installed in the cluster. A project file must be the only `-f` input, and one run can't mix Compositions -with composite resources. +with composite resources. Any other resource, such as package metadata, a +Provider or a ConfigMap, is rejected: each one must be a kind an XRD in the +cluster defines as a composite resource or claim. The command connects to the cluster in your current Kubernetes context, or the one `--context` names. diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index f541e873..ed223763 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -44,6 +44,7 @@ import ( "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/diff/client" + xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" "github.com/crossplane/cli/v2/internal/docker" @@ -251,6 +252,9 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin // (crossplane/cli#367) belongs in the renderer, keyed by the claim // labels the engine copies onto the XR. return c.simulate(ctx, logger, clients.Initialize, xrProc, func(ctx context.Context) (bool, error) { + if err := checkComposites(ctx, clients.XpClients.Definition, in.resources); err != nil { + return false, err + } hasDiffs, err := xrProc.PerformDiff(ctx, in.resources, clients.XpClients.Composition.FindMatchingComposition) return hasDiffs, errors.Wrap(err, "cannot simulate composite resources") }) @@ -404,6 +408,25 @@ func loadInputs(paths []string) (*inputs, error) { } } +// checkComposites rejects resources that no XRD in the cluster defines as a +// composite resource or claim, such as package metadata, a Provider or a +// ConfigMap, which the engine would otherwise fail on with no hint at what -f +// accepts. The definition client caches the XRDs when it initializes, so the +// check costs no extra calls. +func checkComposites(ctx context.Context, defs xpclient.DefinitionClient, resources []*un.Unstructured) error { + for _, r := range resources { + gvk := r.GroupVersionKind() + if xrd, err := defs.GetXRDForClaim(ctx, gvk); err == nil && xrd != nil { + continue + } + if xrd, err := defs.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { + continue + } + return errors.Errorf("-f inputs include %s %q (%s), which no XRD in the cluster defines as a composite resource or claim; -f accepts a project file alone, Composition files, or composite resource or claim files", gvk.Kind, r.GetName(), gvk.GroupVersion()) + } + return nil +} + // isProjectFile reports whether path is a project file. A directory is not. func isProjectFile(path string) (bool, error) { fi, err := os.Stat(path) diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 14074747..f83fc974 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -32,6 +32,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -40,6 +41,7 @@ import ( dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" + "github.com/crossplane/cli/v2/internal/diff/testutils" ) // parseSimulate parses args as `simulate` flags, running kong's validation. @@ -675,7 +677,7 @@ spec: want: want{mode: xrMode, names: []string{"x", "t"}}, }, "NonProjectSingleFile": { - reason: "A single file that isn't a project file should be loaded as resources.", + reason: "A single file that isn't a project file should be loaded as resources; checkComposites rejects it once XRDs are known.", paths: []string{notProject}, want: want{mode: xrMode, names: []string{"cfg"}}, }, @@ -815,3 +817,66 @@ func TestValidateModeFlags(t *testing.T) { }) } } + +func TestCheckComposites(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XThing"} + claimGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Thing"} + xrd := &un.Unstructured{} + lookup := func(want schema.GroupVersionKind) func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { + return func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if gvk == want { + return xrd, nil + } + return nil, errors.New("no XRD found") + } + } + defs := &testutils.MockDefinitionClient{GetXRDForXRFn: lookup(xrGVK), GetXRDForClaimFn: lookup(claimGVK)} + res := func(apiVersion, kind, name string) *un.Unstructured { + return &un.Unstructured{Object: map[string]any{"apiVersion": apiVersion, "kind": kind, "metadata": map[string]any{"name": name}}} + } + + cases := map[string]struct { + reason string + resources []*un.Unstructured + err string + }{ + "XRAndClaim": { + reason: "Resources an XRD defines as a composite resource or claim should be accepted.", + resources: []*un.Unstructured{res("example.org/v1", "XThing", "x"), res("example.org/v1", "Thing", "t")}, + }, + "ConfigurationMetadata": { + reason: "A Configuration package metadata file should be rejected.", + resources: []*un.Unstructured{res("meta.pkg.crossplane.io/v1", "Configuration", "cfg")}, + err: `-f inputs include Configuration "cfg" (meta.pkg.crossplane.io/v1), which no XRD in the cluster defines as a composite resource or claim`, + }, + "Provider": { + reason: "A Provider should be rejected.", + resources: []*un.Unstructured{res("example.org/v1", "XThing", "x"), res("pkg.crossplane.io/v1", "Provider", "provider-aws")}, + err: `-f inputs include Provider "provider-aws" (pkg.crossplane.io/v1)`, + }, + "ConfigMap": { + reason: "A non-Crossplane kind should be rejected.", + resources: []*un.Unstructured{res("v1", "ConfigMap", "cm")}, + err: `-f inputs include ConfigMap "cm" (v1)`, + }, + "WrongVersion": { + reason: "A composite resource at a version its XRD doesn't define should be rejected.", + resources: []*un.Unstructured{res("example.org/v2", "XThing", "x")}, + err: `-f inputs include XThing "x" (example.org/v2)`, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := checkComposites(context.Background(), defs, tc.resources) + if tc.err == "" { + if err != nil { + t.Fatalf("\n%s\ncheckComposites(...): %v", tc.reason, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.err) { + t.Fatalf("\n%s\ncheckComposites(...): want error containing %q, got %v", tc.reason, tc.err, err) + } + }) + } +} From 72823e052df5f168b009d6fb2596ba2e66bf09f5 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 15:24:20 -0400 Subject: [PATCH 038/126] feat(project)!: exit 3 when project simulate finds changes project simulate signalled changes found only under --exit-code, and then as an error, so kong printed "crossplane: error: changes detected" on a run that succeeded. Changes found are a result, not an error, and gating CI on them is what a simulation is for. Add common.ExitStatus, which main binds and exits with after a run that returns no error, and have simulate set it to 3 when it finds changes. Errors still go through FatalIfErrorf and take precedence, so the codes rank as crossplane-diff's do: tool error 1 over schema validation 2 over changes 3. An interrupted run now returns ErrInterrupted unwrapped, since main already exits 130 for it. BREAKING CHANGE: project simulate exits 3 when it finds changes, and the --exit-code flag is removed. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/common/exit.go | 26 +++++++++ cmd/crossplane/main.go | 9 ++++ cmd/crossplane/project/help/simulate.md | 20 +++---- cmd/crossplane/project/simulate.go | 47 ++++++++-------- cmd/crossplane/project/simulate_test.go | 53 +++++++++---------- internal/diff/integration/README.md | 7 ++- .../diff/integration/simulate_harness_test.go | 17 +++--- 7 files changed, 103 insertions(+), 76 deletions(-) create mode 100644 cmd/crossplane/common/exit.go diff --git a/cmd/crossplane/common/exit.go b/cmd/crossplane/common/exit.go new file mode 100644 index 00000000..fc6fe091 --- /dev/null +++ b/cmd/crossplane/common/exit.go @@ -0,0 +1,26 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package common + +// ExitStatus is the exit status of a command that returns no error. main +// binds a pointer to one, so a command's Run can take it as a parameter and +// set a non-zero status for a result that is not an error, such as changes +// found. An error the command returns takes precedence over the status. +type ExitStatus struct { + // Code is the status to exit with. Zero unless the command sets it. + Code int +} diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index bd2dd449..0ed585e4 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -37,6 +37,7 @@ import ( runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" "github.com/crossplane/cli/v2/cmd/crossplane/cluster" + "github.com/crossplane/cli/v2/cmd/crossplane/common" "github.com/crossplane/cli/v2/cmd/crossplane/completion" "github.com/crossplane/cli/v2/cmd/crossplane/composition" configcmd "github.com/crossplane/cli/v2/cmd/crossplane/config" @@ -131,6 +132,7 @@ func main() { os.Exit(1) } + status := &common.ExitStatus{} parser := kong.Must(&cli{}, kong.Name("crossplane"), // Binding a variable to kong context makes it available to all commands @@ -139,6 +141,8 @@ func main() { kong.BindTo(configcmd.ConfigPath(cfgPath), (*configcmd.ConfigPath)(nil)), // Bind the loaded config so commands can read feature flags at runtime. kong.Bind(cfg), + // Commands set the exit status of a run that returns no error. + kong.Bind(status), kongVars, kong.Help(helpPrinter), kong.UsageOnError()) @@ -185,7 +189,12 @@ func main() { } os.Exit(130) } + // An error is printed and exits 1, or with its ExitCoder's code; + // otherwise the run exits with the status its command set. ctx.FatalIfErrorf(err) + if status.Code != 0 { + os.Exit(status.Code) + } } // exitOnSignal waits for SIGINT or SIGTERM, then exits with status 130 diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index e04b7c88..b3a9ae2f 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -63,10 +63,16 @@ The command accepts the same diff-tuning flags as `crossplane-diff`: - `--output-version` picks the schema version of JSON and YAML output: `v1`, the default, or `v2`. -The command exits with status 0 when the simulation succeeds, whether or not it -finds changes, 1 when it fails, 2 when only schema validation fails, and 130 -when interrupted. Pass `--exit-code` to exit with status 3 when it finds -changes, for example to gate a CI pipeline. +The command exits with one of these statuses, so a CI pipeline can gate on +the result: + +- 0: the simulation succeeded and found no changes. +- 1: the simulation failed. +- 2: only schema validation failed. +- 3: the simulation succeeded and found changes. +- 130: the simulation was interrupted. + +When more than one applies, the lowest of 1, 2 and 3 wins. ## Examples @@ -95,12 +101,6 @@ Print the result as JSON: crossplane project simulate -o json ``` -Fail a CI job when the project would change the cluster: - -```shell -crossplane project simulate --exit-code -``` - Simulate a changed Composition without a project: ```shell diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index ed223763..eff7c9f5 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -40,6 +40,7 @@ import ( apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/cli/v2/cmd/crossplane/common" ld "github.com/crossplane/cli/v2/cmd/crossplane/common/load" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/dependency" @@ -76,7 +77,6 @@ type simulateCmd struct { Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2). Has no effect on -o diff."` - ExitCode bool `help:"Exit with status 3 when the simulation finds changes."` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` @@ -202,7 +202,7 @@ func (c *simulateCmd) Validate() error { // Run previews the inputs -f names against the cluster: a project's // compositions, with its functions built locally, or changed Composition or // composite resource files, with functions from the cluster. -func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config) error { +func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config, status *common.ExitStatus) error { in, err := loadInputs(c.Input) if err != nil { return err @@ -251,7 +251,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin // design's printing of the result under the claim's name // (crossplane/cli#367) belongs in the renderer, keyed by the claim // labels the engine copies onto the XR. - return c.simulate(ctx, logger, clients.Initialize, xrProc, func(ctx context.Context) (bool, error) { + return c.simulate(ctx, logger, status, clients.Initialize, xrProc, func(ctx context.Context) (bool, error) { if err := checkComposites(ctx, clients.XpClients.Definition, in.resources); err != nil { return false, err } @@ -261,7 +261,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin } proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) - return c.simulate(ctx, logger, clients.Initialize, proc, c.diffCompositions(proc, comps, in.mode)) + return c.simulate(ctx, logger, status, clients.Initialize, proc, c.diffCompositions(proc, comps, in.mode)) } // restConfig returns the cluster to simulate against: the hooks' when set, @@ -509,15 +509,17 @@ type processor interface { dp.Cleaner } -// simulate initializes the clients and processor and runs diff, mapping the -// result to the command's exit status. ctx must not be cancelled by its owner -// until simulate returns, so a cancellation reads as an interrupt. -func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initClients func(context.Context, logging.Logger) error, proc processor, diff func(context.Context) (bool, error)) (err error) { +// simulate initializes the clients and processor and runs diff. A run that +// finds changes and fails nothing sets status to 3 and returns nil; one that +// fails only schema validation returns an error that exits 2. ctx must not be +// cancelled by its owner until simulate returns, so a cancellation reads as +// an interrupt. +func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, status *common.ExitStatus, initClients func(context.Context, logging.Logger) error, proc processor, diff func(context.Context) (bool, error)) (err error) { // An interrupted run reports the interruption, not the cancelled calls it - // caused. + // caused. main exits 130 for it. defer func() { if dp.Interrupted(ctx) { - err = exitError{error: dp.ErrInterrupted, code: dp.ExitCodeInterrupted} + err = dp.ErrInterrupted } }() @@ -531,20 +533,19 @@ func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, initC return errors.Wrap(err, "cannot initialize diff processor") } + // Exit codes rank as crossplane-diff's do: a tool error (1) over schema + // validation (2) over changes found (3) over success (0). hasDiffs, err := diff(ctx) - if err != nil { - // The diff has already rendered the result, including its errors. A - // run that failed only schema validation exits 2. - return exitError{error: err, code: dp.DetermineExitCode(err, false)} - } - - // Changes are what a simulation is for, so they fail the run only when - // asked, as with git diff --exit-code. - if c.ExitCode && hasDiffs { - return exitError{error: errChangesDetected, code: dp.ExitCodeDiffDetected} + switch code := dp.DetermineExitCode(err, hasDiffs); code { + case dp.ExitCodeSchemaValidation: + return exitError{error: err, code: code} + case dp.ExitCodeDiffDetected: + // Changes found are a result, not an error, so nothing is printed. + status.Code = code + return nil } - return nil + return err } // scope returns the namespace and composite resource refs DiffComposition @@ -630,10 +631,6 @@ func isTerminal(w io.Writer) bool { return ok && term.IsTerminal(f.Fd()) } -// errChangesDetected is the error a run with --exit-code returns when it -// finds changes. -var errChangesDetected = errors.New("changes detected") - // exitError is an error that sets the command's exit status. kong's // FatalIfErrorf honours it. type exitError struct { diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index f83fc974..a5c31449 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -39,6 +39,7 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/cli/v2/cmd/crossplane/common" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" "github.com/crossplane/cli/v2/internal/diff/testutils" @@ -51,7 +52,7 @@ func parseSimulate(t *testing.T, args ...string) (*simulateCmd, error) { var cli struct { Simulate simulateCmd `cmd:""` } - parser, err := kong.New(&cli, kong.Vars{"project_file": "crossplane-project.yaml"}, kong.Exit(func(int) {})) + parser, err := kong.New(&cli, kong.Vars{"project_file": "crossplane-project.yaml"}, kong.Exit(func(int) {}), kong.Bind(&common.ExitStatus{})) if err != nil { t.Fatalf("kong.New(...): %v", err) } @@ -98,7 +99,7 @@ func TestSimulateFlags(t *testing.T) { args: []string{ "-f", "p.yaml", "-c", "staging", "-n", "team-a", "--name", "db", "--include-pinned", "--max-iterations", "3", "--timeout", "2m", - "--max-concurrency", "2", "-o", "json", "--output-version", "v2", "--crossplane-version", "v2.3.4", "--exit-code", + "--max-concurrency", "2", "-o", "json", "--output-version", "v2", "--crossplane-version", "v2.3.4", "--as", "jane", "--as-group", "admins", }, want: want{cmd: &simulateCmd{ @@ -112,7 +113,6 @@ func TestSimulateFlags(t *testing.T) { MaxConcurrency: 2, Output: "json", OutputVersion: "v2", - ExitCode: true, CrossplaneVersion: "v2.3.4", MaxNestedDepth: 10, DryRunOn: "all", @@ -454,9 +454,10 @@ func (f *fakeCompProc) Cleanup(context.Context) error { return nil } -func exitCode(err error) int { +// exitCode returns the code main would exit with for err and status. +func exitCode(err error, status *common.ExitStatus) int { if err == nil { - return 0 + return status.Code } var ec kong.ExitCoder if errors.As(err, &ec) { @@ -494,31 +495,26 @@ func TestSimulateRun(t *testing.T) { want: want{diffed: true}, }, "Changes": { - reason: "Changes alone should not fail the run.", + reason: "Changes should exit 3, as a result rather than an error.", args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { return true, nil }}}, - want: want{diffed: true}, + want: want{code: dp.ExitCodeDiffDetected, diffed: true}, }, - "ChangesWithExitCode": { - reason: "With --exit-code, changes should exit 3.", - args: args{cmd: simulateCmd{ExitCode: true}, initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { - return true, nil - }}}, - want: want{code: dp.ExitCodeDiffDetected, err: errChangesDetected, diffed: true}, - }, - "NoChangesWithExitCode": { - reason: "With --exit-code, a run with no changes should still succeed.", - args: args{cmd: simulateCmd{ExitCode: true}, initClients: noInit, proc: &fakeCompProc{}}, - want: want{diffed: true}, - }, - "ErrorWithExitCode": { - reason: "With --exit-code, a tool error should still exit 1, not 3.", - args: args{cmd: simulateCmd{ExitCode: true}, initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + "ErrorWithChanges": { + reason: "A tool error should exit 1, not 3, even when there are changes.", + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { return true, errBoom }}}, want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, }, + "SchemaValidationErrorWithChanges": { + reason: "A schema validation failure should exit 2, not 3, even when there are changes.", + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { + return true, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) + }}}, + want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, + }, "Namespaced": { reason: "--namespace alone should reach DiffComposition as the namespace.", args: args{cmd: simulateCmd{Namespace: "team-a"}, initClients: noInit, proc: &fakeCompProc{}}, @@ -557,11 +553,11 @@ func TestSimulateRun(t *testing.T) { want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, }, "Interrupted": { - reason: "An interrupted run should report the interruption and exit 130, whatever it returned.", + reason: "An interrupted run should return the interruption, whatever it returned, which main exits 130 for.", args: args{cancel: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(ctx context.Context) (bool, error) { - return false, ctx.Err() + return true, ctx.Err() }}}, - want: want{code: dp.ExitCodeInterrupted, err: dp.ErrInterrupted, diffed: true}, + want: want{code: dp.ExitCodeToolError, err: dp.ErrInterrupted, diffed: true}, }, } @@ -574,9 +570,10 @@ func TestSimulateRun(t *testing.T) { } diff := tc.args.cmd.diffCompositions(tc.args.proc, []*un.Unstructured{{}}, compositionMode) - err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), tc.args.initClients, tc.args.proc, diff) + status := &common.ExitStatus{} + err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), status, tc.args.initClients, tc.args.proc, diff) - if diff := cmp.Diff(tc.want.code, exitCode(err)); diff != "" { + if diff := cmp.Diff(tc.want.code, exitCode(err, status)); diff != "" { t.Errorf("\n%s\nsimulate(...): -want exit code, +got:\n%s", tc.reason, diff) } if !errors.Is(err, tc.want.err) { @@ -795,7 +792,7 @@ func TestValidateModeFlags(t *testing.T) { var cli struct { Simulate simulateCmd `cmd:""` } - parser, err := kong.New(&cli, kong.Vars{"project_file": "crossplane-project.yaml"}, kong.Exit(func(int) {})) + parser, err := kong.New(&cli, kong.Vars{"project_file": "crossplane-project.yaml"}, kong.Exit(func(int) {}), kong.Bind(&common.ExitStatus{})) if err != nil { t.Fatalf("kong.New(...): %v", err) } diff --git a/internal/diff/integration/README.md b/internal/diff/integration/README.md index 2d65bf5b..d7a8daf1 100644 --- a/internal/diff/integration/README.md +++ b/internal/diff/integration/README.md @@ -12,10 +12,9 @@ code and expected output, funneled through one function: `runSimulate` in `simulate_harness_test.go`. It translates the arguments into `crossplane project simulate` arguments (input files as `-f`, a single `--resource` as `--name`, `--include-manual` as `--include-pinned`, -`--analyze-unchanged` as `--analyze-on=always`, plus `--exit-code`) and -runs the real command through kong. `project.SimulateHooks` points the -command at the case's envtest cluster in place of a kubeconfig, so the -cases run in parallel. Composition cases run simulate's Composition-file +`--analyze-unchanged` as `--analyze-on=always`) and runs the real command +through kong. `project.SimulateHooks` points the command at the case's +envtest cluster in place of a kubeconfig, so the cases run in parallel. Composition cases run simulate's Composition-file mode, XR cases its composite resource mode. ## Skips diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index c3ead527..166eaaa8 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -52,6 +52,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/cli/v2/cmd/crossplane/common" "github.com/crossplane/cli/v2/cmd/crossplane/project" "github.com/crossplane/cli/v2/internal/config" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" @@ -119,13 +120,12 @@ func simulateGap(_ DiffTestType, a *simArgs) string { // simulateArgv translates the parsed crossplane-diff args into project // simulate's: the input files as -f, a single --resource as --name (and // --namespace), --include-manual as --include-pinned, and -// --analyze-unchanged as its replacement --analyze-on=always. --exit-code -// makes simulate exit 3 on changes, as crossplane-diff always does. +// --analyze-unchanged as its replacement --analyze-on=always. func simulateArgv(t *testing.T, a *simArgs) []string { t.Helper() argv := []string{ - "project", "simulate", "--exit-code", + "project", "simulate", "--timeout=" + a.Timeout.String(), "--output=" + a.Output, "--output-version=" + a.OutputVersion, @@ -188,6 +188,7 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s var cli struct { Project project.Cmd `cmd:""` } + status := &common.ExitStatus{} parser, err := kong.New(&cli, kong.Vars{"project_file": clixpkg.ProjectFile}, kong.Writers(stdout, stderr), @@ -196,6 +197,7 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s kong.BindTo(tu.TestLogger(t, true), (*logging.Logger)(nil)), kong.BindTo(terminal.NewNopSpinnerPrinter(), (*terminal.SpinnerPrinter)(nil)), kong.Bind(&config.Config{}), + kong.Bind(status), ) if err != nil { t.Fatalf("failed to create kong parser: %v", err) @@ -218,15 +220,12 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s err = kctx.Run() + // Exit as main does: an error with its ExitCoder's code or 1, otherwise + // with the status the command set. var ec kong.ExitCoder switch { case err == nil: - return dp.ExitCodeSuccess, nil - case errors.As(err, &ec) && ec.ExitCode() == dp.ExitCodeDiffDetected: - // simulate signals changes found under --exit-code with an error - // carrying the exit code; crossplane-diff, whose cases these are, - // signals them with the exit code alone. - return dp.ExitCodeDiffDetected, nil + return status.Code, nil case errors.As(err, &ec): return ec.ExitCode(), err default: From a74e96b9ac99993af2cb4040b0764bbde0181992 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 15:44:01 -0400 Subject: [PATCH 039/126] fix(diff): print errors once, from the command, not inline 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/comp_processor.go | 36 ++++++++++--- .../diff/diffprocessor/comp_processor_test.go | 34 +++++++------ internal/diff/diffprocessor/diff_processor.go | 14 +++--- .../diff/diffprocessor/diff_processor_test.go | 23 ++++----- internal/diff/diffprocessor/errors.go | 6 +-- .../diff/diffprocessor/processor_config.go | 5 +- internal/diff/diffprocessor/warnings.go | 6 +-- .../diff/integration/simulate_harness_test.go | 12 +++-- internal/diff/renderer/comp_diff_renderer.go | 50 +++++-------------- .../diff/renderer/comp_diff_renderer_test.go | 45 +++++++---------- internal/diff/renderer/diff_formatter.go | 4 +- internal/diff/renderer/diff_renderer.go | 26 +++------- internal/diff/renderer/diff_renderer_test.go | 31 ++++-------- internal/diff/renderer/structured_renderer.go | 19 ++----- .../diff/renderer/structured_renderer_test.go | 15 +++--- internal/diff/renderer/types/types.go | 23 +++------ 16 files changed, 152 insertions(+), 197 deletions(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 8c4f70a4..bc2f0874 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -288,24 +288,44 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit totalXRErrors := len(output.Errors) output.Errors = withInterruption(ctx, output.Errors) - // Always render output (even if all compositions failed) to ensure valid structured output - // The renderer will include errors in the structured output and write them to stderr + // Always render output (even if all compositions failed) to ensure valid structured output. The + // renderer includes errors in structured output but does not print them: they are returned, below, + // to the command, which prints them once. if err := p.compDiffRenderer.RenderCompDiff(output); err != nil { return hasDiffs, errors.Wrap(err, "failed to render composition diff") } - // Check for XR processing errors after rendering (so users see the output first). - // Return an error so CI/CD pipelines get a non-zero exit code when impact analysis failed. + // Check for failures after rendering (so users see the output first), and return an error so + // CI/CD pipelines get a non-zero exit code. The renderer does not print errors, so the returned + // error names each failing resource. A run in which only some compositions failed still + // succeeds: each failure is in the output, under its composition. + var errs []error + if totalXRErrors > 0 { - return hasDiffs, errors.Errorf("impact analysis failed for %d XR(s)", totalXRErrors) + errs = append(errs, errors.Wrapf(outputErrorsJoined(output.Errors[:totalXRErrors]), "impact analysis failed for %d XR(s)", totalXRErrors)) } - // Return error if all compositions failed if compositionErrors > 0 && compositionErrors == len(output.Compositions) { - return hasDiffs, errors.New("failed to process all compositions") + failed := make([]dt.OutputError, 0, compositionErrors) + for _, comp := range output.Compositions { + failed = append(failed, *comp.Error) + } + + errs = append(errs, errors.Wrap(outputErrorsJoined(failed), "failed to process all compositions")) + } + + return hasDiffs, errors.Join(errs...) +} + +// outputErrorsJoined returns errs as one error that names each one's resource, the way PerformDiff +// names the resources it failed to process. +func outputErrorsJoined(errs []dt.OutputError) error { + joined := make([]error, 0, len(errs)) + for _, e := range errs { + joined = append(joined, errors.Errorf("unable to process resource %s: %s", e.ResourceID, e.Message)) } - return hasDiffs, nil + return errors.Join(joined...) } // preflightResourceRefs resolves user --resource refs against every supplied composition before diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 3bbdf0dd..4b7739a5 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -209,6 +209,8 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { // is still rendered. verifyOutput func(t *testing.T, output string) wantErr bool + // wantErrContains, when set, must appear in the returned error. + wantErrContains string }{ // In v2 a composition that fails to process carries its error as a structured errors[] entry, // and the top-level errors[] lists it too. @@ -227,6 +229,8 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { WithCompositionErrorContaining("cannot convert composition")) }, wantErr: true, + // The renderer prints no errors, so the returned one must name the composition. + wantErrContains: "failed to process all compositions: unable to process resource Composition/test-composition: cannot convert composition", }, "SuccessfulDiff": { namespace: "default", @@ -490,6 +494,10 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { return } + if tt.wantErrContains != "" && !strings.Contains(err.Error(), tt.wantErrContains) { + t.Errorf("DiffComposition() error = %v, want it to contain %q", err, tt.wantErrContains) + } + if tt.verifyOutput != nil { tt.verifyOutput(t, stdout.String()) } @@ -1587,10 +1595,10 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_NestedXRCompositionLookup(t *te } } -// TestDefaultCompDiffProcessor_DiffComposition_StderrErrorOutput verifies that when -// XR processing fails, detailed errors are written to stderr for human visibility. -// This tests the WithStderr option and the stderr error output path. -func TestDefaultCompDiffProcessor_DiffComposition_StderrErrorOutput(t *testing.T) { +// TestDefaultCompDiffProcessor_DiffComposition_ReturnsErrorNotStderr verifies that +// when XR processing fails, the returned error names the failing XR and carries the +// detail, and nothing is written to stderr: the command prints the error once. +func TestDefaultCompDiffProcessor_DiffComposition_ReturnsErrorNotStderr(t *testing.T) { ctx := t.Context() // Create test composition @@ -1658,21 +1666,17 @@ func TestDefaultCompDiffProcessor_DiffComposition_StderrErrorOutput(t *testing.T // Should return error because one XR failed if err == nil { - t.Error("Expected error due to XR processing failure, got nil") + t.Fatal("Expected error due to XR processing failure, got nil") } - // Verify stderr contains the error details - stderrOutput := stderrBuf.String() - if !strings.Contains(stderrOutput, "ERROR:") { - t.Errorf("Expected stderr to contain 'ERROR:', got: %q", stderrOutput) - } - - if !strings.Contains(stderrOutput, "XResource/fail-xr") { - t.Errorf("Expected stderr to contain resource ID 'XResource/fail-xr', got: %q", stderrOutput) + for _, want := range []string{"impact analysis failed for 1 XR(s)", "unable to process resource XResource/fail-xr", "render pipeline failed"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Expected the returned error to contain %q, got: %v", want, err) + } } - if !strings.Contains(stderrOutput, "render pipeline failed") { - t.Errorf("Expected stderr to contain error message 'render pipeline failed', got: %q", stderrOutput) + if stderrBuf.Len() != 0 { + t.Errorf("Expected nothing on stderr, got: %q", stderrBuf.String()) } } diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 549c17af..2f0a71cd 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -317,8 +317,8 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un. res := toRender[i].Resource resourceID := fmt.Sprintf("%s/%s", groups[i].XR.Kind, groups[i].XR.Name) - // Debug, not Info: this failure is already surfaced as an OutputError, which goes to - // stderr and into structured output. Raising it as a warning too would double-report it. + // Debug, not Info: this failure is already surfaced in the returned error and as an + // OutputError in the output. Raising it as a warning too would double-report it. p.config.Logger.Debug("Failed to process resource", "resource", resourceID, "namespace", res.GetNamespace(), @@ -338,8 +338,8 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un. } // Third: renders that reach one resource in ways no single diff can represent. Collected before the - // output is rendered so they reach errors[] and stderr like any other error; the output is still - // rendered, so structured output stays valid. + // output is rendered so they reach errors[] and the returned error like any other error; the output + // is still rendered, so structured output stays valid. for _, err := range validator.RenderOverlaps(groups) { errs = append(errs, err) outputErrors = append(outputErrors, dt.OutputError{Message: err.Error()}) @@ -352,8 +352,10 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un. outputErrors = withInterruption(ctx, outputErrors) - // Always render (even if only errors exist) to ensure valid structured output - // The renderer will include errors in the structured output and write them to stderr + // Always render (even if only errors exist) to ensure valid structured output. The renderer + // includes errors in structured output but does not print them: they are returned, below, to the + // command, which prints them once. Every outputErrors entry has its counterpart in errs, except + // the interruption's, which the command reports itself. err := p.diffRenderer.RenderDiffs(groups, outputErrors, p.config.Advisories.Advisories()) if err != nil { p.config.Logger.Debug("Failed to render diffs", "error", err) diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index b99b6ae7..50c86549 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -1202,10 +1202,10 @@ func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *t } } -// TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput verifies that when -// resource processing fails, detailed errors are written to stderr for human visibility. -// This tests the WithStderr option and the stderr error output path. -func TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput(t *testing.T) { +// TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr verifies that when +// resource processing fails, the returned error names the resource and carries +// the detail, and nothing is written to stderr: the command prints the error once. +func TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr(t *testing.T) { ctx := t.Context() // Create test resource @@ -1263,17 +1263,14 @@ func TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput(t *testing.T) { t.Fatal("PerformDiff() expected error but got none") } - // Verify stderr contains the error output - stderrOutput := stderrBuf.String() - - // The error should be formatted using FormatError() which produces: - // "ERROR: {ResourceID}: {Message}" - if !strings.Contains(stderrOutput, "ERROR: XR1/my-xr-1:") { - t.Errorf("Expected stderr to contain 'ERROR: XR1/my-xr-1:', got: %q", stderrOutput) + for _, want := range []string{"unable to process resource XR1/my-xr-1", "composition not found"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Expected the returned error to contain %q, got: %v", want, err) + } } - if !strings.Contains(stderrOutput, "composition not found") { - t.Errorf("Expected stderr to contain 'composition not found' error detail, got: %q", stderrOutput) + if stderrBuf.Len() != 0 { + t.Errorf("Expected nothing on stderr, got: %q", stderrBuf.String()) } } diff --git a/internal/diff/diffprocessor/errors.go b/internal/diff/diffprocessor/errors.go index c587a762..57bb4322 100644 --- a/internal/diff/diffprocessor/errors.go +++ b/internal/diff/diffprocessor/errors.go @@ -47,8 +47,8 @@ const ( ExitCodeInterrupted = 130 ) -// ErrInterrupted is the error an interrupted run reports, in errors[] and on stderr, in place of the -// cancelled calls the interruption caused. +// ErrInterrupted is the error an interrupted run reports, in errors[] and as the command's error, in +// place of the cancelled calls the interruption caused. var ErrInterrupted = errors.New("run interrupted by signal; results are incomplete") // Interrupted reports whether the run context ctx was cancelled by a signal. The run context is a @@ -198,7 +198,7 @@ func NewOutputError(resourceID string, err error) dt.OutputError { // // The inner SchemaValidationError deliberately gets an empty ResourceID: its // Error() would otherwise prefix "schema validation error for : ", which -// both duplicates the id that OutputError.FormatError already prints and +// both duplicates the id OutputError.ResourceID already carries and // mislabels an admission rejection as a schema problem. func NewAdmissionRejectionError(rejectedDesc string, obj *un.Unstructured, err error) *SchemaValidationError { msg := fmt.Sprintf("the cluster rejected %s: %s", rejectedDesc, err.Error()) diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index e0a10a61..3702273d 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -170,7 +170,8 @@ type ProcessorConfig struct { // Stdout is the writer for diff output (defaults to os.Stdout) Stdout io.Writer - // Stderr is the writer for error output (defaults to os.Stderr) + // Stderr is the writer for warnings (defaults to os.Stderr). Errors are + // returned to the caller, not written here. Stderr io.Writer // Logger is the logger to use @@ -371,7 +372,7 @@ func WithStdout(w io.Writer) ProcessorOption { } } -// WithStderr sets the writer for error output. +// WithStderr sets the writer for warnings. func WithStderr(w io.Writer) ProcessorOption { return func(config *ProcessorConfig) { config.Stderr = w diff --git a/internal/diff/diffprocessor/warnings.go b/internal/diff/diffprocessor/warnings.go index 71c020da..2536740e 100644 --- a/internal/diff/diffprocessor/warnings.go +++ b/internal/diff/diffprocessor/warnings.go @@ -43,9 +43,9 @@ import ( // // # Why it writes to stderr itself // -// Owning the human rendering here means one mechanism produces both views, the same dual-emission -// contract OutputError follows, and the text stays fit for a CLI rather than taking a logger's -// development-mode format (timestamp, level, caller). +// Owning the human rendering here means one mechanism produces both views, and the text stays fit +// for a CLI rather than taking a logger's development-mode format (timestamp, level, caller). +// Errors differ: they are returned to the command, which prints them once. // // # Why identical advisories are collapsed // diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 166eaaa8..3521fb2c 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -176,7 +176,8 @@ func simulateArgv(t *testing.T, a *simArgs) []string { // runSimulate runs one case's args through the real `crossplane project // simulate` command against cfg, and returns the exit code the command -// would exit with, the error it would fail with, and everything it wrote. +// would exit with, the error it would fail with, and everything it wrote, +// including the error as main prints it. // Each call parses a fresh command, so a second run starts with cold // client caches just as a second CLI invocation would; the determinism // cases rely on it. @@ -190,6 +191,7 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s } status := &common.ExitStatus{} parser, err := kong.New(&cli, + kong.Name("crossplane"), kong.Vars{"project_file": clixpkg.ProjectFile}, kong.Writers(stdout, stderr), kong.Exit(func(int) { t.Fatal("kong tried to exit the test binary") }), @@ -219,9 +221,13 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s } err = kctx.Run() + // Exit as main does: print an error the way FatalIfErrorf does, the only + // place errors are printed, and exit with its ExitCoder's code or 1; + // otherwise exit with the status the command set. + if err != nil { + parser.Errorf("%s", err) + } - // Exit as main does: an error with its ExitCoder's code or 1, otherwise - // with the status the command set. var ec kong.ExitCoder switch { case err == nil: diff --git a/internal/diff/renderer/comp_diff_renderer.go b/internal/diff/renderer/comp_diff_renderer.go index 32774ff1..78635443 100644 --- a/internal/diff/renderer/comp_diff_renderer.go +++ b/internal/diff/renderer/comp_diff_renderer.go @@ -41,8 +41,9 @@ const ( // Both human-readable and structured (JSON/YAML) renderers implement this interface. type CompDiffRenderer interface { // RenderCompDiff renders the complete composition diff output. - // Top-level and tool errors go to DiffOptions.Stderr and are included in - // structured output payloads. Per-composition messages (diffs, "no changes", + // Top-level errors are included in structured output payloads but not + // written to stderr: the processor returns them to the command, which + // prints them once. Per-composition messages (diffs, "no changes", // per-composition errors) go to DiffOptions.Stdout as part of the diff narrative. RenderCompDiff(output *CompDiffOutput) error } @@ -64,8 +65,8 @@ func NewDefaultCompDiffRenderer(logger logging.Logger, diffRenderer DiffRenderer } // RenderCompDiff renders the composition diff in human-readable format. -// Top-level errors go to r.opts.Stderr. Per-composition output (diffs, status -// messages, per-composition errors) goes to r.opts.Stdout. +// Per-composition output (diffs, status messages, per-composition errors) goes +// to r.opts.Stdout. Top-level errors are not printed; see CompDiffRenderer. func (r *DefaultCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { stdout := r.opts.Stdout @@ -83,18 +84,7 @@ func (r *DefaultCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { // One run-wide summary of unverified additions; see DiffRenderer.RenderDiffs for why it is derived // at render time, and why the per-composition reuse of the diff renderer above does not emit it. - if err := writeWarnings(r.opts.Stderr, compDryRunWarnings(output)); err != nil { - return err - } - - // Write top-level errors to stderr - for _, e := range output.Errors { - if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { - return errors.Wrap(err, "failed to write error to stderr") - } - } - - return nil + return writeWarnings(r.opts.Stderr, compDryRunWarnings(output)) } // renderComposition renders every section belonging to a single composition: its own changes, @@ -590,9 +580,9 @@ func NewStructuredCompDiffRenderer(logger logging.Logger, opts DiffOptions) Comp } } -// RenderCompDiff renders the composition diff in structured format (JSON/YAML). -// Top-level errors go to both r.opts.Stderr (for human visibility) and the -// structured output payload. Per-composition data goes to r.opts.Stdout. +// RenderCompDiff renders the composition diff in structured format (JSON/YAML) +// to r.opts.Stdout. Top-level errors go into the payload only; see +// CompDiffRenderer. func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { // One run-wide summary of unverified additions, derived here from the diffs and appended after the // warnings raised during the run. See DiffRenderer.RenderDiffs. @@ -605,14 +595,9 @@ func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) erro v2.Warnings = warnings // Anything but v2, including the empty value, is v1. - var ( - jsonOutput any = v2 - errs = v2.Errors - ) - + var jsonOutput any = v2 if r.opts.OutputVersion != OutputVersionV2 { - v1 := v2.v1() - jsonOutput, errs = v1, v1.Errors + jsonOutput = v2.v1() } var ( @@ -641,18 +626,7 @@ func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) erro } // Of the warnings, only the ones derived here go to stderr: the rest went there when raised. - if err := writeWarnings(r.opts.Stderr, dryRun); err != nil { - return err - } - - // Write errors to stderr for human visibility (they're also included in the structured output) - for _, e := range errs { - if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { - return errors.Wrap(err, "failed to write error to stderr") - } - } - - return nil + return writeWarnings(r.opts.Stderr, dryRun) } // buildStructuredCompOutput converts internal CompDiffOutput to the v2 document, from which v1 is diff --git a/internal/diff/renderer/comp_diff_renderer_test.go b/internal/diff/renderer/comp_diff_renderer_test.go index 4e72b7c7..67c7ba56 100644 --- a/internal/diff/renderer/comp_diff_renderer_test.go +++ b/internal/diff/renderer/comp_diff_renderer_test.go @@ -591,10 +591,10 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { } } -// TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr verifies that -// top-level errors in CompDiffOutput.Errors are written to stderr (not stdout) -// to follow Unix conventions. -func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testing.T) { +// TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsNotPrinted verifies +// that top-level errors in CompDiffOutput.Errors are written to neither stdout +// nor stderr: the processor returns them to the command, which prints them once. +func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsNotPrinted(t *testing.T) { output := &CompDiffOutput{ Compositions: []CompositionDiff{}, Errors: []dt.OutputError{ @@ -622,23 +622,19 @@ func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testin t.Fatalf("RenderCompDiff() failed: %v", err) } - stderrOut := stderr.String() for _, e := range output.Errors { - if !strings.Contains(stderrOut, e.FormatError()) { - t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) - } - - // Verify errors were NOT written to stdout - if strings.Contains(stdout.String(), e.FormatError()) { - t.Errorf("Expected stdout to NOT contain error %q, got: %q", e.FormatError(), stdout.String()) + for name, out := range map[string]string{"stdout": stdout.String(), "stderr": stderr.String()} { + if strings.Contains(out, e.Message) { + t.Errorf("Expected %s not to contain error %q, got: %q", name, e.Message, out) + } } } } -// TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr verifies that -// top-level errors in CompDiffOutput.Errors are written to stderr in addition to being -// included in the structured output. -func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *testing.T) { +// TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsInOutputOnly verifies +// that top-level errors in CompDiffOutput.Errors are included in the structured +// output but not written to stderr. +func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsInOutputOnly(t *testing.T) { output := &CompDiffOutput{ Compositions: []CompositionDiff{}, Errors: []dt.OutputError{ @@ -667,15 +663,14 @@ func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsToStderr(t *tes t.Fatalf("RenderCompDiff() failed: %v", err) } - // Verify errors in stderr stderrOut := stderr.String() for _, e := range output.Errors { - if !strings.Contains(stderrOut, e.FormatError()) { - t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + if strings.Contains(stderrOut, e.Message) { + t.Errorf("Expected stderr not to contain error %q, got: %q", e.Message, stderrOut) } } - // Verify errors are ALSO in structured output (stdout) + // Verify errors are in structured output (stdout) stdoutStr := stdout.String() for _, e := range output.Errors { if !strings.Contains(stdoutStr, e.Message) { @@ -1332,8 +1327,7 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { cases := map[string]struct { version OutputVersion // want is the whole document, as the type of the selected version. - want any - wantStderr []string + want any }{ "DefaultIsV1": { want: compDiffWire{ @@ -1359,7 +1353,6 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { // v1 does not list the failed composition here. Errors: []dt.OutputError{*xrErr}, }, - wantStderr: []string{xrErr.FormatError()}, }, "V2": { version: OutputVersionV2, @@ -1386,7 +1379,6 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { // v2 appends the failed composition, so the list is the union of the document's errors. Errors: []dt.OutputError{*xrErr, *compErr}, }, - wantStderr: []string{xrErr.FormatError(), compErr.FormatError()}, }, } @@ -1416,8 +1408,9 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { t.Errorf("structured output mismatch (-want +got):\n%s", diff) } - if diff := gcmp.Diff(tc.wantStderr, strings.Split(strings.TrimSpace(stderr.String()), "\n")); diff != "" { - t.Errorf("stderr mismatch (-want +got):\n%s", diff) + // Errors are in the document only; the command prints the returned error. + if stderr.Len() != 0 { + t.Errorf("want nothing on stderr, got: %q", stderr.String()) } }) } diff --git a/internal/diff/renderer/diff_formatter.go b/internal/diff/renderer/diff_formatter.go index f204badb..fc130971 100644 --- a/internal/diff/renderer/diff_formatter.go +++ b/internal/diff/renderer/diff_formatter.go @@ -34,8 +34,8 @@ type DiffOptions struct { // Stdout is the writer for diff output (defaults to os.Stdout) Stdout io.Writer - // Stderr is the writer for error output (defaults to os.Stderr) - // Errors are written here following Unix conventions (errors to stderr, output to stdout) + // Stderr is the writer for warnings (defaults to os.Stderr). Errors are not + // written here: they are returned to the command, which prints them once. Stderr io.Writer // Format specifies the output format (diff, json, yaml) diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index 0fe19a86..08d20b07 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -30,10 +30,11 @@ func flattenGroups(groups []dt.XRDiffGroup) map[string]*dt.ResourceDiff { // DiffRenderer handles rendering diffs to output. type DiffRenderer interface { - // RenderDiffs formats and outputs diffs, grouped by input XR. - // Diff output goes to DiffOptions.Stdout, errors go to DiffOptions.Stderr. - // The errs parameter contains the union of resource processing errors to - // include in output (the top-level/global error list). + // RenderDiffs formats and outputs diffs, grouped by input XR, to + // DiffOptions.Stdout. The errs parameter contains the union of resource + // processing errors (the top-level/global error list). A structured + // renderer includes them in its output; no renderer writes them to stderr, + // because the processor returns them to the command, which prints them once. // // The warnings parameter carries non-fatal advisories for structured output only. Unlike errors, // warnings have ALREADY been written to stderr, at the moment they were raised (see @@ -203,8 +204,8 @@ func identitylessGroups(diffs map[string]*dt.ResourceDiff) []dt.XRDiffGroup { return []dt.XRDiffGroup{{Diffs: diffs}} } -// RenderDiffs formats and prints the diffs. -// Diff output goes to r.diffOpts.Stdout, errors go to r.diffOpts.Stderr. +// RenderDiffs formats and prints the diffs to r.diffOpts.Stdout. It does not +// print errs; the command prints the error the processor returns. // // Identity-bearing groups (the xr command) render as per-input-XR sections, // each with a header and per-section summary, followed by an aggregate footer @@ -248,18 +249,7 @@ func (r *DefaultDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.Out return err } - if err := writeWarnings(r.diffOpts.Stderr, xrDryRunWarnings(xrGroups)); err != nil { - return err - } - - // Write errors to stderr following Unix conventions - for _, e := range errs { - if _, err := fmt.Fprintln(r.diffOpts.Stderr, e.FormatError()); err != nil { - return errors.Wrap(err, "failed to write error to stderr") - } - } - - return nil + return writeWarnings(r.diffOpts.Stderr, xrDryRunWarnings(xrGroups)) } // renderFlat renders a single flat block of diffs plus a "Summary:" line. This diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index 4905e250..70ddb819 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -347,34 +347,26 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { } } +// TestDefaultDiffRenderer_RenderDiffs_WithErrors pins that the renderer does not print errors: the +// processor returns them to the command, which prints them once. func TestDefaultDiffRenderer_RenderDiffs_WithErrors(t *testing.T) { tests := map[string]struct { - errs []dt.OutputError - expected []string + errs []dt.OutputError }{ "ErrorsWithResourceID": { errs: []dt.OutputError{ {ResourceID: "Resource/my-resource", Message: "failed to render"}, {ResourceID: "OtherResource/other", Message: "connection refused"}, }, - expected: []string{ - "ERROR: Resource/my-resource: failed to render", - "ERROR: OtherResource/other: connection refused", - }, }, "ErrorsWithEmptyResourceID": { errs: []dt.OutputError{ {ResourceID: "", Message: "cluster connection timeout"}, {ResourceID: "Resource/has-id", Message: "specific error"}, }, - expected: []string{ - "ERROR: : cluster connection timeout", - "ERROR: Resource/has-id: specific error", - }, }, "NoErrors": { - errs: []dt.OutputError{}, - expected: []string{}, + errs: []dt.OutputError{}, }, } @@ -382,11 +374,10 @@ func TestDefaultDiffRenderer_RenderDiffs_WithErrors(t *testing.T) { t.Run(name, func(t *testing.T) { logger := tu.TestLogger(t, false) - // Errors go to stderr now, so capture stderr - var stderr bytes.Buffer + var stdout, stderr bytes.Buffer opts := DefaultDiffOptions() - opts.Stdout = &bytes.Buffer{} // discard stdout + opts.Stdout = &stdout opts.Stderr = &stderr renderer := NewDiffRenderer(logger, opts) @@ -396,11 +387,11 @@ func TestDefaultDiffRenderer_RenderDiffs_WithErrors(t *testing.T) { t.Fatalf("RenderDiffs() failed with error: %v", err) } - output := stderr.String() - - for _, expectedMsg := range tt.expected { - if !strings.Contains(output, expectedMsg) { - t.Errorf("Expected stderr to contain %q but it didn't\nStderr: %s", expectedMsg, output) + for _, e := range tt.errs { + for name, out := range map[string]string{"stdout": stdout.String(), "stderr": stderr.String()} { + if strings.Contains(out, e.Message) { + t.Errorf("Expected %s not to contain error %q, got: %q", name, e.Message, out) + } } } }) diff --git a/internal/diff/renderer/structured_renderer.go b/internal/diff/renderer/structured_renderer.go index db05088e..6e7baedc 100644 --- a/internal/diff/renderer/structured_renderer.go +++ b/internal/diff/renderer/structured_renderer.go @@ -3,7 +3,6 @@ package renderer import ( "cmp" "encoding/json" - "fmt" "maps" "reflect" "slices" @@ -590,20 +589,10 @@ func (r *StructuredDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt. return errors.Wrap(err, "failed to write newline") } - // Write errors to stderr for human visibility (they're also included in the structured output). - // Of the warnings, only the ones derived here are written: the rest already went to stderr when - // they were raised. - if err := writeWarnings(r.opts.Stderr, dryRun); err != nil { - return err - } - - for _, e := range errs { - if _, err := fmt.Fprintln(r.opts.Stderr, e.FormatError()); err != nil { - return errors.Wrap(err, "failed to write error to stderr") - } - } - - return nil + // Errors are in the structured output, and the command prints the error the processor returns, + // so they are not written to stderr here. Of the warnings, only the ones derived here are + // written: the rest already went to stderr when they were raised. + return writeWarnings(r.opts.Stderr, dryRun) } // buildChangeSet walks a diff map into the (summary, changes) pair shared by diff --git a/internal/diff/renderer/structured_renderer_test.go b/internal/diff/renderer/structured_renderer_test.go index 2bba915c..4da224a8 100644 --- a/internal/diff/renderer/structured_renderer_test.go +++ b/internal/diff/renderer/structured_renderer_test.go @@ -557,10 +557,10 @@ func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { } } -// TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr verifies that errors are -// written to stderr for human visibility in addition to being included in the -// structured output for machine parsing. -func TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr(t *testing.T) { +// TestStructuredDiffRenderer_RenderDiffs_ErrorsInOutputOnly verifies that errors +// are included in the structured output for machine parsing but not written to +// stderr: the processor returns them to the command, which prints them once. +func TestStructuredDiffRenderer_RenderDiffs_ErrorsInOutputOnly(t *testing.T) { errs := []dt.OutputError{ {ResourceID: "example.org/v1/XResource/my-xr", Message: "failed to render XR: missing composition"}, {Message: "cluster connection timeout"}, @@ -587,15 +587,14 @@ func TestStructuredDiffRenderer_RenderDiffs_ErrorsToStderr(t *testing.T) { t.Fatalf("RenderDiffs() failed: %v", err) } - // Verify errors are in stderr stderrOut := stderr.String() for _, e := range errs { - if !strings.Contains(stderrOut, e.FormatError()) { - t.Errorf("Expected stderr to contain %q, got: %q", e.FormatError(), stderrOut) + if strings.Contains(stderrOut, e.Message) { + t.Errorf("Expected stderr not to contain error %q, got: %q", e.Message, stderrOut) } } - // Verify errors are ALSO in structured output (stdout) + // Verify errors are in structured output (stdout) var output StructuredDiffOutput switch format { diff --git a/internal/diff/renderer/types/types.go b/internal/diff/renderer/types/types.go index 828a3160..e4f56dc4 100644 --- a/internal/diff/renderer/types/types.go +++ b/internal/diff/renderer/types/types.go @@ -408,22 +408,11 @@ type FieldValidationError struct { Value any `json:"value,omitempty"` } -// FormatError returns a human-readable error string. -// If ResourceID is empty, it uses "" to indicate a system-level error -// not tied to any specific resource (e.g., cluster connection issues). -func (e OutputError) FormatError() string { - resourceID := e.ResourceID - if resourceID == "" { - resourceID = "" - } - - return fmt.Sprintf("ERROR: %s: %s", resourceID, e.Message) -} - // OutputWarning is a non-fatal advisory: something the user should know that does not invalidate the -// diff or stop the run. Warnings follow the same dual-emission contract as OutputError — written to -// stderr for humans and included in structured output for machines — but deliberately do NOT affect -// the exit code. A consumer gating on failure checks errors[], not warnings[]. +// diff or stop the run. Warnings follow a dual-emission contract — written to stderr for humans when +// raised and included in structured output for machines — but deliberately do NOT affect the exit +// code. An OutputError instead goes into structured output and is returned to the command, which +// prints it once. A consumer gating on failure checks errors[], not warnings[]. // // Context carries the details of the occurrence from the raising call site (e.g. which composition, // which secrets are missing). It is a map rather than being flattened into Message so machine @@ -445,8 +434,8 @@ type Advisories interface { Raise(w OutputWarning) } -// FormatWarning renders the warning as a single human-readable stderr line, mirroring -// OutputError.FormatError. Context pairs are appended in sorted key order so output is stable +// FormatWarning renders the warning as a single human-readable stderr line. Context pairs are +// appended in sorted key order so output is stable // across runs (Go map iteration is not). func (w OutputWarning) FormatWarning() string { if len(w.Context) == 0 { From 0941a2ec180a6f5836b3602daccd473a11e90eb5 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 16:50:41 -0400 Subject: [PATCH 040/126] refactor(diff): move object comparison out of the renderer into compare 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/compare/compare.go | 477 +++++++++++++++++ internal/diff/compare/compare_test.go | 478 ++++++++++++++++++ internal/diff/diffprocessor/comp_processor.go | 18 +- .../diff/diffprocessor/diff_calculator.go | 16 +- .../diffprocessor/diff_calculator_test.go | 12 +- internal/diff/diffprocessor/diff_processor.go | 2 +- .../diff/diffprocessor/diff_processor_test.go | 7 +- .../diff/diffprocessor/processor_config.go | 11 +- internal/diff/integration/integration_test.go | 2 +- internal/diff/renderer/diff_formatter.go | 446 ---------------- internal/diff/renderer/diff_formatter_test.go | 388 -------------- internal/diff/renderer/diff_renderer_test.go | 67 --- internal/diff/renderer/structured_renderer.go | 2 +- .../diff/renderer/structured_renderer_test.go | 11 +- 14 files changed, 997 insertions(+), 940 deletions(-) create mode 100644 internal/diff/compare/compare.go create mode 100644 internal/diff/compare/compare_test.go diff --git a/internal/diff/compare/compare.go b/internal/diff/compare/compare.go new file mode 100644 index 00000000..5cc6f0a3 --- /dev/null +++ b/internal/diff/compare/compare.go @@ -0,0 +1,477 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package compare computes structured diffs between Kubernetes objects: what changed, independent of +// how it is presented. +package compare + +import ( + "context" + "fmt" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + "k8s.io/apimachinery/pkg/api/equality" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + t "github.com/crossplane/cli/v2/internal/diff/renderer/types" +) + +// PathLastAppliedConfiguration is the ignore-path form of the annotation a client-side `kubectl +// apply` stamps on the object it applies. Exported because two callers must name the same path and +// must not drift apart: cleanupForDiff suppresses it from every rendered diff (see +// displayOnlyIgnoredPaths), and comp's revision-name prediction has to ask whether it is the *only* +// thing a composition differs by — in which case the resulting revision's identity is not +// predictable, because the value kubectl writes depends on the user's apply mode rather than on the +// file. +const PathLastAppliedConfiguration = "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]" + +// Options configures how GenerateDiff compares two objects. +type Options struct { + // IgnorePaths is a list of paths to ignore when calculating diffs + // Supports both simple paths (e.g., "metadata.annotations") and + // map key paths (e.g., "metadata.annotations[key.name/value]") + IgnorePaths []string + + // ForVerdict marks a comparison whose result answers "did this object change?" rather than + // producing output a human reads. Such a comparison keeps the display-only fields *in* the + // objects being compared (see displayOnlyIgnoredPaths in cleanupForDiff), so a difference in one + // of them registers: a field hidden purely to keep the rendered diff readable must never decide + // whether a change exists. + // + // Callers should also clear IgnorePaths for such a comparison, for the same reason — the user's + // masks are a display preference too. + ForVerdict bool +} + +// GetLineDiff performs a proper line-by-line diff and returns the raw diffs. +func GetLineDiff(oldText, newText string) []diffmatchpatch.Diff { + patch := diffmatchpatch.New() + + // Use the line-to-char conversion to treat each line as an atomic unit + ch1, ch2, lines := patch.DiffLinesToChars(oldText, newText) + + diff := patch.DiffMain(ch1, ch2, false) + patch.DiffCleanupSemantic(diff) + + return patch.DiffCharsToLines(diff, lines) +} + +// logIdentity returns the kind/name and namespace to log for a diff, preferring +// the desired object over the current one. +func logIdentity(current, desired *un.Unstructured) (string, string) { + switch { + case desired != nil: + return fmt.Sprintf("%s/%s", desired.GetKind(), desired.GetName()), desired.GetNamespace() + case current != nil: + return fmt.Sprintf("%s/%s", current.GetKind(), current.GetName()), current.GetNamespace() + default: + return "unknown/unknown", "" + } +} + +// GenerateDiff produces a structured diff between two unstructured objects. +func GenerateDiff(_ context.Context, current, desired *un.Unstructured, logger logging.Logger, options Options) (*t.ResourceDiff, error) { + var diffType t.DiffType + + resourceKey, resourceNamespace := logIdentity(current, desired) + + logger.Debug("Generating diff", "resource", resourceKey, "namespace", resourceNamespace) + + // Determine diff type + switch { + case current == nil && desired != nil: + diffType = t.DiffTypeAdded + + logger.Debug("Diff type: Resource is being added", "resource", resourceKey, "namespace", resourceNamespace) + case current != nil && desired == nil: + diffType = t.DiffTypeRemoved + + logger.Debug("Diff type: Resource is being removed", "resource", resourceKey, "namespace", resourceNamespace) + case current != nil: // && desired != nil: + diffType = t.DiffTypeModified + + logger.Debug("Diff type: Resource is being modified", "resource", resourceKey, "namespace", resourceNamespace) + default: + logger.Debug("Error: both current and desired are nil") + return nil, errors.New("both current and desired cannot be nil") + } + + // Fast path for modifications: if the raw objects are already deeply equal + // there is nothing to clean or diff. + if diffType == t.DiffTypeModified && equality.Semantic.DeepEqual(current, desired) { + logger.Debug("Resources are semantically equal", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + // Clean each present object exactly once. The cleaned copies are reused for + // the cleaned-equality check, the YAML text diff, and are stored on the + // returned ResourceDiff for structured renderers to emit — so cleanup runs + // once per object rather than once per consumer. + var currentClean, desiredClean *un.Unstructured + + if current != nil { + currentClean = cleanupForDiff(current.DeepCopy(), logger.WithValues("resourceStage", "current", "before", current), options) + } + + if desired != nil { + desiredClean = cleanupForDiff(desired.DeepCopy(), logger.WithValues("resourceStage", "desired", "before", desired), options) + } + + // For modifications, if the cleaned objects are equal the only differences + // were in ignored / server-side fields. + if diffType == t.DiffTypeModified && equality.Semantic.DeepEqual(currentClean.Object, desiredClean.Object) { + logger.Debug("Resources are equal after cleanup (only metadata differences)", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + // Convert the already-cleaned objects to YAML for the text diff. + asString := func(clean *un.Unstructured) (string, error) { + if clean == nil { + return "", nil + } + + yaml, err := sigsyaml.Marshal(clean.Object) + if err != nil { + return "", err + } + + return string(yaml), nil + } + + currentStr, err := asString(currentClean) + if err != nil { + logger.Debug("Error marshaling current object to YAML", "error", err) + return nil, errors.Wrap(err, "cannot marshal current object to YAML") + } + + desiredStr, err := asString(desiredClean) + if err != nil { + logger.Debug("Error marshaling desired object to YAML", "error", err) + return nil, errors.Wrap(err, "cannot marshal desired object to YAML") + } + + // Return nil if content is identical + if desiredStr == currentStr { + logger.Debug("Resources have identical YAML representation", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + // Get the line by line diff + logger.Debug("Computing line-by-line diff", "resource", resourceKey, "namespace", resourceNamespace) + + lineDiffs := GetLineDiff(currentStr, desiredStr) + + if len(lineDiffs) == 0 { + logger.Debug("No differences found in line-by-line comparison", "resource", resourceKey, "namespace", resourceNamespace) + return equalDiff(current, desired), nil + } + + logger.Debug("Diff calculation complete", "resource", resourceKey, "namespace", resourceNamespace, "diff_chunks", len(lineDiffs)) + + // Extract resource kind, namespace, and name + var ( + name string + namespace string + gvk schema.GroupVersionKind + ) + // For removed resources, use current's kind, namespace, and name + + if diffType == t.DiffTypeRemoved { // current != nil + name = current.GetName() + namespace = current.GetNamespace() + gvk = current.GroupVersionKind() + } else { // desired != nil + // For added or modified resources, use desired's kind + gvk = desired.GroupVersionKind() + + // For namespace, prefer current (existing resource) over desired + if current != nil && current.GetNamespace() != "" { + namespace = current.GetNamespace() + } else { + namespace = desired.GetNamespace() + } + + // For name, prefer the current resource name if it exists (for generateName cases) + if current != nil && current.GetName() != "" { + name = current.GetName() + } else { + // If desired's metadata.name was produced by either our XR + // synthesis path or the binary's nameGenerator, substitute a + // "(generated)" display so the diff doesn't show a value the + // user can't predict. Bare generateName (no name yet) gets the + // same treatment. + switch { + case t.LooksLikeGeneratedName(desired.GetName(), desired.GetGenerateName()): + name = t.GeneratedDisplayName(desired.GetName(), desired.GetGenerateName()) + case desired.GetName() != "": + name = desired.GetName() + case desired.GetGenerateName() != "": + name = desired.GetGenerateName() + "(generated)" + } + } + } + + return &t.ResourceDiff{ + Gvk: gvk, + Namespace: namespace, + ResourceName: name, + DiffType: diffType, + LineDiffs: lineDiffs, + Current: t.ResourceViews{Raw: current, Clean: currentClean}, + Desired: t.ResourceViews{Raw: desired, Clean: desiredClean}, + }, nil +} + +// stripSyntheticName drops metadata.name from the diff body when the +// rendered name was produced by either our XR synthesis path or the binary's +// nameGenerator (see types.LooksLikeGeneratedName). Returns a list of +// modification messages for diagnostic logging. The composite label is left +// intact — downstream resources should still refer to their parent by the +// synthesized display name that appears at the diff header. +// +// Note we don't require generateName to be present: the embedded-suffix +// case (XR-synthesis suffix interpolated into a downstream resource's +// metadata.name via a composition template) typically has no generateName +// field on the rendered resource, and LooksLikeGeneratedName handles that +// path via Contains-on-suffix. +func stripSyntheticName(metadata map[string]any, name string, nameFound bool, generateName string) []string { + if !nameFound { + return nil + } + + if !t.LooksLikeGeneratedName(name, generateName) { + return nil + } + + delete(metadata, "name") + + return []string{fmt.Sprintf("removed display name %q", name)} +} + +func equalDiff(current *un.Unstructured, desired *un.Unstructured) *t.ResourceDiff { + return &t.ResourceDiff{ + Gvk: current.GroupVersionKind(), + Namespace: current.GetNamespace(), + ResourceName: current.GetName(), + DiffType: t.DiffTypeEqual, + LineDiffs: []diffmatchpatch.Diff{}, + // Equal diffs render nothing, so only the Raw views are populated + // (kept for identity/reference); Clean is intentionally left nil. + Current: t.ResourceViews{Raw: current}, + Desired: t.ResourceViews{Raw: desired}, + } +} + +// removeNestedPath removes a field from an object based on a path string. +// Supports both simple paths (e.g., "metadata.annotations") and +// map key paths (e.g., "metadata.annotations[key.name/value]"). +// Returns true if the field was found and removed, false otherwise. +func removeNestedPath(obj map[string]any, path string) bool { + if path == "" { + return false + } + + // Check if this is a map key path (contains brackets) + if strings.Contains(path, "[") && strings.HasSuffix(path, "]") { + // Parse path like "metadata.annotations[key]" + openBracket := strings.Index(path, "[") + closeBracket := strings.LastIndex(path, "]") + + if openBracket == -1 || closeBracket == -1 || closeBracket <= openBracket+1 { + return false // Invalid format + } + + // Extract the base path and the key + basePath := path[:openBracket] + key := path[openBracket+1 : closeBracket] + + // Split the base path into parts and use k8s helper to get the map reference + parts := strings.Split(basePath, ".") + + fieldValue, found, err := un.NestedFieldNoCopy(obj, parts...) + if !found || err != nil { + return false + } + + // Ensure it's a map + parentMap, ok := fieldValue.(map[string]any) + if !ok { + return false + } + + // Remove the specific key from the map + if _, keyExists := parentMap[key]; keyExists { + delete(parentMap, key) + + // If the parent map is now empty, remove the parent field entirely + if len(parentMap) == 0 { + un.RemoveNestedField(obj, parts...) + } + + return true + } + + return false + } + + // Simple path without brackets - use k8s unstructured helper + parts := strings.Split(path, ".") + + _, found, _ := un.NestedFieldNoCopy(obj, parts...) + if found { + un.RemoveNestedField(obj, parts...) + return true + } + + return false +} + +// cleanupForDiff removes fields that shouldn't be included in the diff. When options.ForVerdict is +// set the display-only paths are left in the object, because the caller is asking whether the objects +// differ rather than rendering them; see Options.ForVerdict. +func cleanupForDiff(obj *un.Unstructured, logger logging.Logger, options Options) *un.Unstructured { + resKind := obj.GetKind() + resName := obj.GetName() + resKey := fmt.Sprintf("%s/%s", resKind, resName) + + // Track all modifications for a single consolidated log message + var modifications []string + + // displayOnlyIgnoredPaths are suppressed from every rendered diff regardless of the caller's + // ignorePaths, because showing them is useless: last-applied-configuration is a multi-KB + // serialization of the object itself. + // + // They are suppressed from *display only*. Crossplane's Composition.Hash() + // (apis/apiextensions/v1/composition_hash.go) covers annotations as well as labels and spec, so a + // difference here does produce a new CompositionRevision — which composites re-point to, and + // which a composition template can observe via the XR's compositionRevisionRef. Letting a + // readability decision suppress that from a change verdict would elide a real, potentially + // render-affecting consequence, so verdict comparisons pass forVerdict and keep them. + displayOnlyIgnoredPaths := []string{ + PathLastAppliedConfiguration, + } + + // Remove display-only paths (unless this is a verdict), then the caller's. Duplicates between + // the two are harmless: removeNestedPath is a no-op once the path is gone. + stripPaths := func(paths []string) { + for _, path := range paths { + if removeNestedPath(obj.Object, path) { + modifications = append(modifications, fmt.Sprintf("ignored path: %s", path)) + } + } + } + + if !options.ForVerdict { + stripPaths(displayOnlyIgnoredPaths) + } + + stripPaths(options.IgnorePaths) + + // Remove server-side fields and metadata that we don't want to diff + metadata, found, _ := un.NestedMap(obj.Object, "metadata") + if found { + // Special handling for objects with both name and generateName + // If the name looks like a generated display name (ends with "(generated)") + // and generateName is also present, remove the name to avoid confusion + name, nameFound, _ := un.NestedString(metadata, "name") + generateName, _, _ := un.NestedString(metadata, "generateName") + + modifications = append(modifications, stripSyntheticName(metadata, name, nameFound, generateName)...) + + // Remove fields that change automatically or are server-side + fieldsToRemove := []string{ + "resourceVersion", + "uid", + "generation", + "creationTimestamp", + "managedFields", + "selfLink", + "ownerReferences", + } + + // Track which fields were actually removed for debugging + var removedFields []string + + for _, field := range fieldsToRemove { + if _, exists := metadata[field]; exists { + delete(metadata, field) + removedFields = append(removedFields, field) + } + } + + // Only record if some fields were actually removed + if len(removedFields) > 0 { + modifications = append(modifications, fmt.Sprintf("metadata fields: %s", strings.Join(removedFields, ", "))) + } + + _ = un.SetNestedMap(obj.Object, metadata, "metadata") + } + + // Remove resourceRefs field from spec if it exists + // TODO: remove spec.crossplane entirely, or only spec.crossplane.resourceRefs? + spec, found, _ := un.NestedMap(obj.Object, "spec") + if found && spec != nil { + if _, exists := spec["resourceRefs"]; exists { + delete(spec, "resourceRefs") + + modifications = append(modifications, "resourceRefs from spec") + } + + _ = un.SetNestedMap(obj.Object, spec, "spec") + } + + crossplane, found, _ := un.NestedMap(obj.Object, "spec", "crossplane") + if found && crossplane != nil { + if _, exists := crossplane["resourceRefs"]; exists { + delete(crossplane, "resourceRefs") + + modifications = append(modifications, "resourceRefs from spec.crossplane") + } + + _ = un.SetNestedMap(obj.Object, crossplane, "spec", "crossplane") + + if len(crossplane) == 0 { + // If spec.crossplane is empty after removing resourceRefs, remove the entire field + delete(spec, "crossplane") + _ = un.SetNestedMap(obj.Object, spec, "spec") + + modifications = append(modifications, "empty spec.crossplane field") + } + } + + // Remove status field as we're focused on spec changes + if _, exists := obj.Object["status"]; exists { + delete(obj.Object, "status") + + modifications = append(modifications, "status field") + } + + // Log a single consolidated message if any modifications were made + if len(modifications) > 0 { + logger.Debug("Cleaned object for diff", + "resource", resKey, + "removed", strings.Join(modifications, ", "), + "after", obj) + } + + return obj +} diff --git a/internal/diff/compare/compare_test.go b/internal/diff/compare/compare_test.go new file mode 100644 index 00000000..96682035 --- /dev/null +++ b/internal/diff/compare/compare_test.go @@ -0,0 +1,478 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package compare + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/sergi/go-diff/diffmatchpatch" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/cli/v2/internal/diff/renderer/types" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" +) + +func TestGenerateDiff(t *testing.T) { + // Create test resources for diffing + current := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "old-value"). + WithSpecField("field2", int64(123)). + Build() + + desired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + WithSpecField("field2", int64(456)). + WithSpecField("field4", "added-field"). + Build() + + // Identical to current, for no-change test + noChanges := current.DeepCopy() + + // Resources carrying content that cleanupForDiff strips: an ignored + // annotation (via options.IgnorePaths below) and a server-side field + // (resourceVersion). Used to assert the Raw/Clean split. + const ignoredAnnotation = "argocd.argoproj.io/tracking-id" + + noisyCurrent := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "old-value"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). + Build() + noisyCurrent.SetResourceVersion("100") + + noisyDesired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). + Build() + noisyDesired.SetResourceVersion("200") + + // The expected Clean views for the noisy pair: the ignored annotation (and + // the now-empty annotations map) and the server-side resourceVersion are + // stripped, while the non-ignored spec change survives. + noisyCurrentClean := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "old-value"). + Build() + + noisyDesiredClean := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + Build() + + ignoreOpts := Options{} + ignoreOpts.IgnorePaths = []string{"metadata.annotations[" + ignoredAnnotation + "]"} + + // A modified pair where the current (cluster) object is namespaced but the + // desired (rendered) object omits metadata.namespace — as manifests often + // do. Generation must prefer the current namespace so downstream (structured + // output, human diff) doesn't emit an empty namespace. Neither object has + // ignorable content, so Clean == Raw content. + nsCurrent := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + InNamespace("production"). + WithSpecField("field1", "old-value"). + Build() + + nsDesired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). + WithSpecField("field1", "new-value"). + Build() + + tests := map[string]struct { + current *un.Unstructured + desired *un.Unstructured + kind string + resName string + options Options + wantDiff *types.ResourceDiff + wantErr bool + }{ + "ModifiedResource": { + current: current, + desired: desired, + kind: "TestResource", + resName: "test-resource", + options: Options{}, + wantDiff: &types.ResourceDiff{ + Gvk: current.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeModified, + // Nothing to strip, so Clean is content-equal to Raw. + Current: types.ResourceViews{Raw: current, Clean: current}, + Desired: types.ResourceViews{Raw: desired, Clean: desired}, + // LineDiffs will be checked separately + }, + }, + "NoChanges": { + current: current, + desired: noChanges, + kind: "TestResource", + resName: "test-resource", + options: Options{}, + wantDiff: &types.ResourceDiff{ + Gvk: current.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeEqual, + // Equal diffs render nothing, so Clean is left nil by generation. + Current: types.ResourceViews{Raw: current}, + Desired: types.ResourceViews{Raw: noChanges}, + }, + }, + "NewResource": { + current: nil, + desired: desired, + kind: "TestResource", + resName: "test-resource", + options: Options{}, + wantDiff: &types.ResourceDiff{ + Gvk: desired.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeAdded, + Current: types.ResourceViews{}, + Desired: types.ResourceViews{Raw: desired, Clean: desired}, + // LineDiffs will be checked separately + }, + }, + "RemovedResource": { + current: current, + desired: nil, + kind: "TestResource", + resName: "test-resource", + options: Options{}, + wantDiff: &types.ResourceDiff{ + Gvk: current.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeRemoved, + Current: types.ResourceViews{Raw: current, Clean: current}, + Desired: types.ResourceViews{}, + // LineDiffs will be checked separately + }, + }, + "ModifiedResource_RawAndCleanViews": { + // Locks the Raw/Clean split: Raw retains the ignored annotation and + // server-side resourceVersion, while Clean has both stripped and + // keeps only the non-ignored spec change. + current: noisyCurrent, + desired: noisyDesired, + kind: "TestResource", + resName: "test-resource", + options: ignoreOpts, + wantDiff: &types.ResourceDiff{ + Gvk: noisyCurrent.GroupVersionKind(), + ResourceName: "test-resource", + DiffType: types.DiffTypeModified, + Current: types.ResourceViews{Raw: noisyCurrent, Clean: noisyCurrentClean}, + Desired: types.ResourceViews{Raw: noisyDesired, Clean: noisyDesiredClean}, + }, + }, + "ModifiedResource_PrefersCurrentNamespace": { + // Regression: desired omits metadata.namespace but current has one. + // diff.Namespace must be the current namespace, not empty. + current: nsCurrent, + desired: nsDesired, + kind: "TestResource", + resName: "test-resource", + options: Options{}, + wantDiff: &types.ResourceDiff{ + Gvk: nsCurrent.GroupVersionKind(), + ResourceName: "test-resource", + Namespace: "production", + DiffType: types.DiffTypeModified, + Current: types.ResourceViews{Raw: nsCurrent, Clean: nsCurrent}, + Desired: types.ResourceViews{Raw: nsDesired, Clean: nsDesired}, + }, + }, + "BothNil": { + current: nil, + desired: nil, + kind: "TestResource", + resName: "test-resource", + options: Options{}, + wantErr: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + diff, err := GenerateDiff(t.Context(), tt.current, tt.desired, tu.TestLogger(t, false), tt.options) + + if tt.wantErr { + if err == nil { + t.Errorf("GenerateDiff() expected error but got none") + } + + return + } + + if err != nil { + t.Fatalf("GenerateDiff() returned error: %v", err) + } + + if diff == nil { + t.Fatalf("GenerateDiff() returned nil, want non-nil") + } + + // Check the basic properties + if diffStr := cmp.Diff(tt.wantDiff.Gvk, diff.Gvk); diffStr != "" { + t.Errorf("Gvk mismatch (-want +got):\n%s", diffStr) + } + + if diffStr := cmp.Diff(tt.wantDiff.ResourceName, diff.ResourceName); diffStr != "" { + t.Errorf("ResourceName mismatch (-want +got):\n%s", diffStr) + } + + if diffStr := cmp.Diff(tt.wantDiff.DiffType, diff.DiffType); diffStr != "" { + t.Errorf("DiffType mismatch (-want +got):\n%s", diffStr) + } + + // Check for line diffs - should be non-empty for changed resources + if diff.DiffType != types.DiffTypeEqual && len(diff.LineDiffs) == 0 { + t.Errorf("LineDiffs is empty for %s", name) + } + + if diffStr := cmp.Diff(tt.wantDiff.Namespace, diff.Namespace); diffStr != "" { + t.Errorf("Namespace mismatch (-want +got):\n%s", diffStr) + } + + // Check the Current and Desired views in full: Raw is the original + // object, Clean is the post-cleanup object (nil for equal diffs, and + // content-equal to Raw when there is nothing to strip). + if diffStr := cmp.Diff(tt.wantDiff.Current, diff.Current); diffStr != "" { + t.Errorf("Current views mismatch (-want +got):\n%s", diffStr) + } + + if diffStr := cmp.Diff(tt.wantDiff.Desired, diff.Desired); diffStr != "" { + t.Errorf("Desired views mismatch (-want +got):\n%s", diffStr) + } + }) + } +} + +func TestRemoveNestedPath(t *testing.T) { + tests := map[string]struct { + obj map[string]any + path string + want bool + wantObj map[string]any + descr string + }{ + "SimplePath": { + obj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + "namespace": "default", + }, + }, + path: "metadata.namespace", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + descr: "removes a simple nested field", + }, + "MapKeyPath": { + obj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "kubectl.kubernetes.io/last-applied-configuration": "large-json", + "argocd.argoproj.io/tracking-id": "some-id", + "keep-this": "value", + }, + }, + }, + path: "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "argocd.argoproj.io/tracking-id": "some-id", + "keep-this": "value", + }, + }, + }, + descr: "removes a specific key from a map", + }, + "MapKeyPathWithSlash": { + obj: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "argocd.argoproj.io/instance": "some-instance", + "provider": "aws", + }, + }, + }, + path: "metadata.labels[argocd.argoproj.io/instance]", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{ + "labels": map[string]any{ + "provider": "aws", + }, + }, + }, + descr: "removes a label with slash in key", + }, + "NonExistentPath": { + obj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + path: "metadata.nonexistent", + want: false, + wantObj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + descr: "returns false for non-existent path", + }, + "NonExistentMapKey": { + obj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "keep-this": "value", + }, + }, + }, + path: "metadata.annotations[nonexistent-key]", + want: false, + wantObj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "keep-this": "value", + }, + }, + }, + descr: "returns false for non-existent map key", + }, + "EmptyPath": { + obj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + path: "", + want: false, + wantObj: map[string]any{ + "metadata": map[string]any{ + "name": "test", + }, + }, + descr: "returns false for empty path", + }, + "RemoveEntireSection": { + obj: map[string]any{ + "metadata": map[string]any{ + "annotations": map[string]any{ + "key": "value", + }, + }, + "spec": map[string]any{ + "field": "value", + }, + }, + path: "metadata.annotations", + want: true, + wantObj: map[string]any{ + "metadata": map[string]any{}, + "spec": map[string]any{ + "field": "value", + }, + }, + descr: "removes entire nested map", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := removeNestedPath(tc.obj, tc.path) + + if got != tc.want { + t.Errorf("removeNestedPath() = %v, want %v for %s", got, tc.want, tc.descr) + } + + if diff := cmp.Diff(tc.wantObj, tc.obj); diff != "" { + t.Errorf("removeNestedPath() object mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestGetLineDiff(t *testing.T) { + tests := map[string]struct { + oldText string + newText string + expected []diffmatchpatch.Operation + }{ + "NoChanges": { + oldText: "line1\nline2\nline3\n", + newText: "line1\nline2\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + }, + }, + "LineAdded": { + oldText: "line1\nline2\n", + newText: "line1\nline2\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + diffmatchpatch.DiffInsert, + }, + }, + "LineRemoved": { + oldText: "line1\nline2\nline3\n", + newText: "line1\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + diffmatchpatch.DiffDelete, + diffmatchpatch.DiffEqual, + }, + }, + "LineModified": { + oldText: "line1\nline2\nline3\n", + newText: "line1\nmodified2\nline3\n", + expected: []diffmatchpatch.Operation{ + diffmatchpatch.DiffEqual, + diffmatchpatch.DiffDelete, + diffmatchpatch.DiffInsert, + diffmatchpatch.DiffEqual, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := GetLineDiff(tt.oldText, tt.newText) + + // Check that we have the expected diff types + if len(result) != len(tt.expected) { + t.Errorf("GetLineDiff() returned %d diffs, want %d", len(result), len(tt.expected)) + + for i, diff := range result { + t.Logf("Diff %d: Type=%s, Text=%q", i, diff.Type, diff.Text) + } + + return + } + + // Verify the types match in sequence + for i, expectedType := range tt.expected { + if result[i].Type != expectedType { + t.Errorf("GetLineDiff() diff[%d] has type %s, want %s", i, result[i].Type, expectedType) + } + } + }) + } +} diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index bc2f0874..7e2d043d 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -36,6 +36,7 @@ import ( apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + "github.com/crossplane/cli/v2/internal/diff/compare" "github.com/crossplane/cli/v2/internal/diff/ref" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" @@ -704,7 +705,7 @@ func (p *DefaultCompDiffProcessor) cliCompositionRevision(res *un.Unstructured, // Both kinds of mask can hide a real difference, so neither may gate the analysis: // - the user's --ignore-paths, which could cover something load-bearing for rendering (anything // under spec.pipeline[].input, say); -// - the renderer's display-only suppressions, which cover fields Crossplane nonetheless hashes +// - the comparison's display-only suppressions, which cover fields Crossplane nonetheless hashes // into a composition's identity, and so into whether a new CompositionRevision is created. // // `scope` is therefore computed with every mask lifted. See issue #453. @@ -787,12 +788,9 @@ func (p *DefaultCompDiffProcessor) calculateCompositionDiff(ctx context.Context, // Calculate the composition diff directly without dry-run apply // (compositions are static YAML documents that don't need server-side processing) - diffOptions := renderer.DefaultDiffOptions() - diffOptions.UseColors = p.config.Colorize - diffOptions.Compact = p.config.Compact - diffOptions.IgnorePaths = p.config.IgnorePaths + diffOptions := p.config.GetCompareOptions() - compDiff, err := renderer.GenerateDiffWithOptions(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) + compDiff, err := compare.GenerateDiff(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) if err != nil { return compositionComparison{}, errors.Wrap(err, "cannot calculate composition diff") } @@ -814,7 +812,7 @@ func (p *DefaultCompDiffProcessor) calculateCompositionDiff(ctx context.Context, } // Nothing to display. That is not the same as unchanged: both the user's --ignore-paths and the - // renderer's display-only suppressions could be hiding a real difference. Re-compare with every + // comparison's display-only suppressions could be hiding a real difference. Re-compare with every // mask lifted (ForVerdict) to find out. // // The masks are display preferences; whether the composition changed is a fact about the object. @@ -825,7 +823,7 @@ func (p *DefaultCompDiffProcessor) calculateCompositionDiff(ctx context.Context, diffOptions.IgnorePaths = nil diffOptions.ForVerdict = true - unmasked, err := renderer.GenerateDiffWithOptions(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) + unmasked, err := compare.GenerateDiff(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) if err != nil { return compositionComparison{}, errors.Wrap(err, "cannot calculate composition diff with masks lifted") } @@ -842,9 +840,9 @@ func (p *DefaultCompDiffProcessor) calculateCompositionDiff(ctx context.Context, revisionNamePredictable := true if anyChange { - diffOptions.IgnorePaths = []string{renderer.PathLastAppliedConfiguration} + diffOptions.IgnorePaths = []string{compare.PathLastAppliedConfiguration} - withoutLastApplied, err := renderer.GenerateDiffWithOptions(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) + withoutLastApplied, err := compare.GenerateDiff(ctx, originalCompUnstructured, newCompUnstructured, p.config.Logger, diffOptions) if err != nil { return compositionComparison{}, errors.Wrap(err, "cannot calculate composition diff excluding last-applied-configuration") } diff --git a/internal/diff/diffprocessor/diff_calculator.go b/internal/diff/diffprocessor/diff_calculator.go index 5ed54741..e87e3dcc 100644 --- a/internal/diff/diffprocessor/diff_calculator.go +++ b/internal/diff/diffprocessor/diff_calculator.go @@ -17,7 +17,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - "github.com/crossplane/cli/v2/internal/diff/renderer" + "github.com/crossplane/cli/v2/internal/diff/compare" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" dtypes "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -49,20 +49,20 @@ type DefaultDiffCalculator struct { accessChecker k8.AccessChecker resourceManager ResourceManager logger logging.Logger - diffOptions renderer.DiffOptions + diffOptions compare.Options dryRunOn DryRunOn predictor Defaulter } // SetDiffOptions updates the diff options used by the calculator. -func (c *DefaultDiffCalculator) SetDiffOptions(options renderer.DiffOptions) { +func (c *DefaultDiffCalculator) SetDiffOptions(options compare.Options) { c.diffOptions = options } // NewDiffCalculator creates a new DefaultDiffCalculator. predictor predicts CRD defaults for the // additions that get no apiserver result; see dryRunCreateAddition. It should be lenient // (LenientDefaulting): a prediction is best effort, and must not fail an addition for want of a CRD. -func NewDiffCalculator(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, diffOptions renderer.DiffOptions, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator { +func NewDiffCalculator(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, diffOptions compare.Options, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator { return &DefaultDiffCalculator{ treeClient: tree, applyClient: apply, @@ -166,7 +166,7 @@ func (c *DefaultDiffCalculator) CalculateDiff(ctx context.Context, composite *un } // Generate diff with the configured options - diff, err := renderer.GenerateDiffWithOptions(ctx, current, wouldBeResult, c.logger, c.diffOptions) + diff, err := compare.GenerateDiff(ctx, current, wouldBeResult, c.logger, c.diffOptions) if err != nil { c.logger.Debug("Failed to generate diff", "resource", resourceID, "error", err) return nil, err @@ -445,7 +445,7 @@ func (c *DefaultDiffCalculator) CalculateRemovedResourceDiffs(ctx context.Contex c.logger.Debug("Resource will be removed", "resource", resourceID) - diff, err := renderer.GenerateDiffWithOptions(ctx, &node.Unstructured, nil, c.logger, c.diffOptions) + diff, err := compare.GenerateDiff(ctx, &node.Unstructured, nil, c.logger, c.diffOptions) if err != nil { c.logger.Debug("Cannot calculate removal diff (continuing)", "resource", resourceID, @@ -683,7 +683,7 @@ func (c *DefaultDiffCalculator) classifyApplyFailure(ctx context.Context, desire // create (it is a subresource) and hands back an empty one, so taking the server's would delete the // user's output under the banner of fidelity. // Latent because status never reaches any rendered diff in the first place: cleanupForDiff -// (renderer/diff_formatter.go) deletes metadata.status unconditionally from both sides, and Clean is +// (compare/compare.go) deletes metadata.status unconditionally from both sides, and Clean is // the only view renderers read. That makes this the same shape of mistake as the ownerReferences // comment this change deleted — a restoration justified by an output effect that does not exist — so // it is labelled rather than presented as load-bearing. Whether composition-authored status SHOULD be @@ -734,7 +734,7 @@ func apiserverMessage(err error) string { // ownerReferences is in this list, and a note on why, because the code it replaces claimed the // opposite. The previous comment here justified stripping ownerRefs on the update path only, on the // grounds that "for new resources we skip DryRunApply entirely so the rendered ownerRefs still -// surface in the diff output". They never surface: cleanupForDiff (renderer/diff_formatter.go) +// surface in the diff output". They never surface: cleanupForDiff (compare/compare.go) // removes metadata.ownerReferences unconditionally from both sides of every comparison, ungated by // forVerdict. So dropping them from the REQUEST costs nothing in displayed output, while sending them // risks two real failures — an empty owner UID, which the apiserver rejects outright, and diff --git a/internal/diff/diffprocessor/diff_calculator_test.go b/internal/diff/diffprocessor/diff_calculator_test.go index 88762876..3d6ff124 100644 --- a/internal/diff/diffprocessor/diff_calculator_test.go +++ b/internal/diff/diffprocessor/diff_calculator_test.go @@ -21,7 +21,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - "github.com/crossplane/cli/v2/internal/diff/renderer" + "github.com/crossplane/cli/v2/internal/diff/compare" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" tu "github.com/crossplane/cli/v2/internal/diff/testutils" dtypes "github.com/crossplane/cli/v2/internal/diff/types" @@ -978,7 +978,7 @@ func TestDefaultDiffCalculator_CalculateDiff(t *testing.T) { resourceTreeClient, resourceManager, logger, - renderer.DefaultDiffOptions(), + compare.Options{}, tt.dryRunOn, markerDefaulterStoringAt(tt.storedAt), ) @@ -1495,7 +1495,7 @@ func TestDefaultDiffCalculator_CalculateDiffs(t *testing.T) { resourceTreeClient, resourceManager, logger, - renderer.DefaultDiffOptions(), + compare.Options{}, DryRunOnAll, &tu.MockDefaulter{}, ) @@ -1741,7 +1741,7 @@ func TestDefaultDiffCalculator_CalculateRemovedResourceDiffs(t *testing.T) { tt.tree, NewResourceManager(tu.NewMockResourceClient().Build(), tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), logger, NewAdvisoryCollector(nil)), logger, - renderer.DefaultDiffOptions(), + compare.Options{}, DryRunOnAll, &tu.MockDefaulter{}, ) @@ -2302,7 +2302,7 @@ func TestDefaultDiffCalculator_DegradedDryRunDetail(t *testing.T) { tu.NewMockResourceTreeClient().Build(), tu.TestLogger(t, false), advisories), tu.TestLogger(t, false), - renderer.DefaultDiffOptions(), + compare.Options{}, DryRunOnAll, &tu.MockDefaulter{}, ) @@ -2452,7 +2452,7 @@ func TestDefaultDiffCalculator_CalculateNonRemovalDiffsRendered(t *testing.T) { tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), logger, NewAdvisoryCollector(nil)), logger, - renderer.DefaultDiffOptions(), + compare.Options{}, DryRunOnAll, &tu.MockDefaulter{}, ) diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 2f0a71cd..c6354b56 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -128,7 +128,7 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) requirementsProvider := config.Factories.RequirementsProvider(k8cs.Resource, xpcs.Environment, config.Logger) renderDefaulter := config.Factories.Defaulter(k8cs.Schema, xpcs.Definition, StrictDefaulting) predictor := config.Factories.Defaulter(k8cs.Schema, xpcs.Definition, LenientDefaulting) - diffCalculator := config.Factories.DiffCalculator(k8cs.Apply, k8cs.Access, xpcs.ResourceTree, resourceManager, config.Logger, diffOpts, config.DryRunOn, predictor) + diffCalculator := config.Factories.DiffCalculator(k8cs.Apply, k8cs.Access, xpcs.ResourceTree, resourceManager, config.Logger, config.GetCompareOptions(), config.DryRunOn, predictor) diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) functionProvider := config.Factories.FunctionProvider(xpcs.Function, config.Logger) diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index 50c86549..d16867d7 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -32,6 +32,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/compare" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" tu "github.com/crossplane/cli/v2/internal/diff/testutils" @@ -566,7 +567,7 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { } }), // Override the diff calculator factory to return actual diffs - WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator { + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter) DiffCalculator { return &tu.MockDiffCalculator{ CalculateNonRemovalDiffsFn: func(context.Context, *cmp.Unstructured, render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { diffs := make(map[string]*dt.ResourceDiff) @@ -1028,7 +1029,7 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { }, } }), - WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator { + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter) DiffCalculator { return &tu.MockDiffCalculator{ CalculateNonRemovalDiffsFn: func(_ context.Context, rendered *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { if rendered.GetName() == tt.failRender { @@ -3090,7 +3091,7 @@ func TestDefaultDiffProcessor_ProcessNestedXRs(t *testing.T) { }, } }), - WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator { + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter) DiffCalculator { return &tu.MockDiffCalculator{ CalculateNonRemovalDiffsFn: func(_ context.Context, xr *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { // Return a simple diff for the XR to make the test pass diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 3702273d..188a1f19 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -9,6 +9,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" + "github.com/crossplane/cli/v2/internal/diff/compare" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" ) @@ -221,7 +222,7 @@ type ComponentFactories struct { Defaulter func(schema k8.SchemaClient, def xp.DefinitionClient, policy DefaultingPolicy) Defaulter // DiffCalculator creates a DiffCalculator - DiffCalculator func(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, diffOptions renderer.DiffOptions, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator + DiffCalculator func(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, compareOptions compare.Options, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator // DiffRenderer creates a DiffRenderer DiffRenderer func(logger logging.Logger, diffOptions renderer.DiffOptions) renderer.DiffRenderer @@ -445,7 +446,7 @@ func WithDefaulterFactory(factory func(k8.SchemaClient, xp.DefinitionClient, Def } // WithDiffCalculatorFactory sets the DiffCalculator factory function. -func WithDiffCalculatorFactory(factory func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, renderer.DiffOptions, DryRunOn, Defaulter) DiffCalculator) ProcessorOption { +func WithDiffCalculatorFactory(factory func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter) DiffCalculator) ProcessorOption { return func(config *ProcessorConfig) { config.Factories.DiffCalculator = factory } @@ -479,6 +480,11 @@ func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) } } +// GetCompareOptions returns the compare.Options based on the ProcessorConfig. +func (c *ProcessorConfig) GetCompareOptions() compare.Options { + return compare.Options{IgnorePaths: c.IgnorePaths} +} + // GetDiffOptions returns DiffOptions based on the ProcessorConfig. func (c *ProcessorConfig) GetDiffOptions() renderer.DiffOptions { opts := renderer.DefaultDiffOptions() @@ -486,7 +492,6 @@ func (c *ProcessorConfig) GetDiffOptions() renderer.DiffOptions { opts.Compact = c.Compact opts.MinimizeComposition = c.MinimizeComposition - opts.IgnorePaths = c.IgnorePaths if c.OutputFormat != "" { opts.Format = c.OutputFormat } diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index c396fb31..4cd21ba6 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -836,7 +836,7 @@ func TestDiffIntegration(t *testing.T) { skip: true, skipReason: "The re-attached status cannot be observed in any output, so this assertion " + "cannot pass and could not be made to fail by breaking the production code it targets. " + - "cleanupForDiff (renderer/diff_formatter.go) deletes metadata.status unconditionally from " + + "cleanupForDiff (compare/compare.go) deletes metadata.status unconditionally from " + "both sides of every diff, in the same block that deletes ownerReferences — the strip whose " + "identical consequence for ownerReferences this feature's own spec documented (§1a) and then " + "missed for status. ResourceViews.Clean is the only view any renderer reads " + diff --git a/internal/diff/renderer/diff_formatter.go b/internal/diff/renderer/diff_formatter.go index fc130971..156345bc 100644 --- a/internal/diff/renderer/diff_formatter.go +++ b/internal/diff/renderer/diff_formatter.go @@ -2,33 +2,16 @@ package renderer import ( - "context" "fmt" "io" "os" "strings" "github.com/sergi/go-diff/diffmatchpatch" - "k8s.io/apimachinery/pkg/api/equality" - un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - sigsyaml "sigs.k8s.io/yaml" - - "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - "github.com/crossplane/crossplane-runtime/v2/pkg/logging" t "github.com/crossplane/cli/v2/internal/diff/renderer/types" ) -// PathLastAppliedConfiguration is the ignore-path form of the annotation a client-side `kubectl -// apply` stamps on the object it applies. Exported because two callers must name the same path and -// must not drift apart: cleanupForDiff suppresses it from every rendered diff (see -// displayOnlyIgnoredPaths), and comp's revision-name prediction has to ask whether it is the *only* -// thing a composition differs by — in which case the resulting revision's identity is not -// predictable, because the value kubectl writes depends on the user's apply mode rather than on the -// file. -const PathLastAppliedConfiguration = "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]" - // DiffOptions holds configuration options for the diff output. type DiffOptions struct { // Stdout is the writer for diff output (defaults to os.Stdout) @@ -62,21 +45,6 @@ type DiffOptions struct { // Compact determines whether to show a compact diff Compact bool - // IgnorePaths is a list of paths to ignore when calculating diffs - // Supports both simple paths (e.g., "metadata.annotations") and - // map key paths (e.g., "metadata.annotations[key.name/value]") - IgnorePaths []string - - // ForVerdict marks a comparison whose result answers "did this object change?" rather than - // producing output a human reads. Such a comparison keeps the display-only fields *in* the - // objects being compared (see displayOnlyIgnoredPaths in cleanupForDiff), so a difference in one - // of them registers: a field hidden purely to keep the rendered diff readable must never decide - // whether a change exists. - // - // Callers should also clear IgnorePaths for such a comparison, for the same reason — the user's - // masks are a display preference too. - ForVerdict bool - // MinimizeComposition collapses composition changes to a single marker line // per composition, omitting the full YAML diff body. Only consumed by the // human-readable composition diff renderer; structured output is unaffected. @@ -281,225 +249,6 @@ func (f *CompactDiffFormatter) stringBlocksWithContext(changes []changeBlock, li return builder.String() } -// GetLineDiff performs a proper line-by-line diff and returns the raw diffs. -func GetLineDiff(oldText, newText string) []diffmatchpatch.Diff { - patch := diffmatchpatch.New() - - // Use the line-to-char conversion to treat each line as an atomic unit - ch1, ch2, lines := patch.DiffLinesToChars(oldText, newText) - - diff := patch.DiffMain(ch1, ch2, false) - patch.DiffCleanupSemantic(diff) - - return patch.DiffCharsToLines(diff, lines) -} - -// logIdentity returns the kind/name and namespace to log for a diff, preferring -// the desired object over the current one. -func logIdentity(current, desired *un.Unstructured) (string, string) { - switch { - case desired != nil: - return fmt.Sprintf("%s/%s", desired.GetKind(), desired.GetName()), desired.GetNamespace() - case current != nil: - return fmt.Sprintf("%s/%s", current.GetKind(), current.GetName()), current.GetNamespace() - default: - return "unknown/unknown", "" - } -} - -// GenerateDiffWithOptions produces a structured diff between two unstructured objects. -func GenerateDiffWithOptions(_ context.Context, current, desired *un.Unstructured, logger logging.Logger, options DiffOptions) (*t.ResourceDiff, error) { - var diffType t.DiffType - - resourceKey, resourceNamespace := logIdentity(current, desired) - - logger.Debug("Generating diff", "resource", resourceKey, "namespace", resourceNamespace) - - // Determine diff type - switch { - case current == nil && desired != nil: - diffType = t.DiffTypeAdded - - logger.Debug("Diff type: Resource is being added", "resource", resourceKey, "namespace", resourceNamespace) - case current != nil && desired == nil: - diffType = t.DiffTypeRemoved - - logger.Debug("Diff type: Resource is being removed", "resource", resourceKey, "namespace", resourceNamespace) - case current != nil: // && desired != nil: - diffType = t.DiffTypeModified - - logger.Debug("Diff type: Resource is being modified", "resource", resourceKey, "namespace", resourceNamespace) - default: - logger.Debug("Error: both current and desired are nil") - return nil, errors.New("both current and desired cannot be nil") - } - - // Fast path for modifications: if the raw objects are already deeply equal - // there is nothing to clean or diff. - if diffType == t.DiffTypeModified && equality.Semantic.DeepEqual(current, desired) { - logger.Debug("Resources are semantically equal", "resource", resourceKey, "namespace", resourceNamespace) - return equalDiff(current, desired), nil - } - - // Clean each present object exactly once. The cleaned copies are reused for - // the cleaned-equality check, the YAML text diff, and are stored on the - // returned ResourceDiff for structured renderers to emit — so cleanup runs - // once per object rather than once per consumer. - var currentClean, desiredClean *un.Unstructured - - if current != nil { - currentClean = cleanupForDiff(current.DeepCopy(), logger.WithValues("resourceStage", "current", "before", current), options) - } - - if desired != nil { - desiredClean = cleanupForDiff(desired.DeepCopy(), logger.WithValues("resourceStage", "desired", "before", desired), options) - } - - // For modifications, if the cleaned objects are equal the only differences - // were in ignored / server-side fields. - if diffType == t.DiffTypeModified && equality.Semantic.DeepEqual(currentClean.Object, desiredClean.Object) { - logger.Debug("Resources are equal after cleanup (only metadata differences)", "resource", resourceKey, "namespace", resourceNamespace) - return equalDiff(current, desired), nil - } - - // Convert the already-cleaned objects to YAML for the text diff. - asString := func(clean *un.Unstructured) (string, error) { - if clean == nil { - return "", nil - } - - yaml, err := sigsyaml.Marshal(clean.Object) - if err != nil { - return "", err - } - - return string(yaml), nil - } - - currentStr, err := asString(currentClean) - if err != nil { - logger.Debug("Error marshaling current object to YAML", "error", err) - return nil, errors.Wrap(err, "cannot marshal current object to YAML") - } - - desiredStr, err := asString(desiredClean) - if err != nil { - logger.Debug("Error marshaling desired object to YAML", "error", err) - return nil, errors.Wrap(err, "cannot marshal desired object to YAML") - } - - // Return nil if content is identical - if desiredStr == currentStr { - logger.Debug("Resources have identical YAML representation", "resource", resourceKey, "namespace", resourceNamespace) - return equalDiff(current, desired), nil - } - - // Get the line by line diff - logger.Debug("Computing line-by-line diff", "resource", resourceKey, "namespace", resourceNamespace) - - lineDiffs := GetLineDiff(currentStr, desiredStr) - - if len(lineDiffs) == 0 { - logger.Debug("No differences found in line-by-line comparison", "resource", resourceKey, "namespace", resourceNamespace) - return equalDiff(current, desired), nil - } - - logger.Debug("Diff calculation complete", "resource", resourceKey, "namespace", resourceNamespace, "diff_chunks", len(lineDiffs)) - - // Extract resource kind, namespace, and name - var ( - name string - namespace string - gvk schema.GroupVersionKind - ) - // For removed resources, use current's kind, namespace, and name - - if diffType == t.DiffTypeRemoved { // current != nil - name = current.GetName() - namespace = current.GetNamespace() - gvk = current.GroupVersionKind() - } else { // desired != nil - // For added or modified resources, use desired's kind - gvk = desired.GroupVersionKind() - - // For namespace, prefer current (existing resource) over desired - if current != nil && current.GetNamespace() != "" { - namespace = current.GetNamespace() - } else { - namespace = desired.GetNamespace() - } - - // For name, prefer the current resource name if it exists (for generateName cases) - if current != nil && current.GetName() != "" { - name = current.GetName() - } else { - // If desired's metadata.name was produced by either our XR - // synthesis path or the binary's nameGenerator, substitute a - // "(generated)" display so the diff doesn't show a value the - // user can't predict. Bare generateName (no name yet) gets the - // same treatment. - switch { - case t.LooksLikeGeneratedName(desired.GetName(), desired.GetGenerateName()): - name = t.GeneratedDisplayName(desired.GetName(), desired.GetGenerateName()) - case desired.GetName() != "": - name = desired.GetName() - case desired.GetGenerateName() != "": - name = desired.GetGenerateName() + "(generated)" - } - } - } - - return &t.ResourceDiff{ - Gvk: gvk, - Namespace: namespace, - ResourceName: name, - DiffType: diffType, - LineDiffs: lineDiffs, - Current: t.ResourceViews{Raw: current, Clean: currentClean}, - Desired: t.ResourceViews{Raw: desired, Clean: desiredClean}, - }, nil -} - -// stripSyntheticName drops metadata.name from the diff body when the -// rendered name was produced by either our XR synthesis path or the binary's -// nameGenerator (see types.LooksLikeGeneratedName). Returns a list of -// modification messages for diagnostic logging. The composite label is left -// intact — downstream resources should still refer to their parent by the -// synthesized display name that appears at the diff header. -// -// Note we don't require generateName to be present: the embedded-suffix -// case (XR-synthesis suffix interpolated into a downstream resource's -// metadata.name via a composition template) typically has no generateName -// field on the rendered resource, and LooksLikeGeneratedName handles that -// path via Contains-on-suffix. -func stripSyntheticName(metadata map[string]any, name string, nameFound bool, generateName string) []string { - if !nameFound { - return nil - } - - if !t.LooksLikeGeneratedName(name, generateName) { - return nil - } - - delete(metadata, "name") - - return []string{fmt.Sprintf("removed display name %q", name)} -} - -func equalDiff(current *un.Unstructured, desired *un.Unstructured) *t.ResourceDiff { - return &t.ResourceDiff{ - Gvk: current.GroupVersionKind(), - Namespace: current.GetNamespace(), - ResourceName: current.GetName(), - DiffType: t.DiffTypeEqual, - LineDiffs: []diffmatchpatch.Diff{}, - // Equal diffs render nothing, so only the Raw views are populated - // (kept for identity/reference); Clean is intentionally left nil. - Current: t.ResourceViews{Raw: current}, - Desired: t.ResourceViews{Raw: desired}, - } -} - // processLines extracts lines from a diff and processes them into a standardized format // Returns the processed lines and whether there was a trailing newline. func processLines(diff diffmatchpatch.Diff, options DiffOptions) ([]string, bool) { @@ -556,198 +305,3 @@ func formatLine(line string, diffType diffmatchpatch.Operation, options DiffOpti return fmt.Sprintf("%s%s", prefix, line) } - -// removeNestedPath removes a field from an object based on a path string. -// Supports both simple paths (e.g., "metadata.annotations") and -// map key paths (e.g., "metadata.annotations[key.name/value]"). -// Returns true if the field was found and removed, false otherwise. -func removeNestedPath(obj map[string]any, path string) bool { - if path == "" { - return false - } - - // Check if this is a map key path (contains brackets) - if strings.Contains(path, "[") && strings.HasSuffix(path, "]") { - // Parse path like "metadata.annotations[key]" - openBracket := strings.Index(path, "[") - closeBracket := strings.LastIndex(path, "]") - - if openBracket == -1 || closeBracket == -1 || closeBracket <= openBracket+1 { - return false // Invalid format - } - - // Extract the base path and the key - basePath := path[:openBracket] - key := path[openBracket+1 : closeBracket] - - // Split the base path into parts and use k8s helper to get the map reference - parts := strings.Split(basePath, ".") - - fieldValue, found, err := un.NestedFieldNoCopy(obj, parts...) - if !found || err != nil { - return false - } - - // Ensure it's a map - parentMap, ok := fieldValue.(map[string]any) - if !ok { - return false - } - - // Remove the specific key from the map - if _, keyExists := parentMap[key]; keyExists { - delete(parentMap, key) - - // If the parent map is now empty, remove the parent field entirely - if len(parentMap) == 0 { - un.RemoveNestedField(obj, parts...) - } - - return true - } - - return false - } - - // Simple path without brackets - use k8s unstructured helper - parts := strings.Split(path, ".") - - _, found, _ := un.NestedFieldNoCopy(obj, parts...) - if found { - un.RemoveNestedField(obj, parts...) - return true - } - - return false -} - -// cleanupForDiff removes fields that shouldn't be included in the diff. When options.ForVerdict is -// set the display-only paths are left in the object, because the caller is asking whether the objects -// differ rather than rendering them; see DiffOptions.ForVerdict. -func cleanupForDiff(obj *un.Unstructured, logger logging.Logger, options DiffOptions) *un.Unstructured { - resKind := obj.GetKind() - resName := obj.GetName() - resKey := fmt.Sprintf("%s/%s", resKind, resName) - - // Track all modifications for a single consolidated log message - var modifications []string - - // displayOnlyIgnoredPaths are suppressed from every rendered diff regardless of the caller's - // ignorePaths, because showing them is useless: last-applied-configuration is a multi-KB - // serialization of the object itself. - // - // They are suppressed from *display only*. Crossplane's Composition.Hash() - // (apis/apiextensions/v1/composition_hash.go) covers annotations as well as labels and spec, so a - // difference here does produce a new CompositionRevision — which composites re-point to, and - // which a composition template can observe via the XR's compositionRevisionRef. Letting a - // readability decision suppress that from a change verdict would elide a real, potentially - // render-affecting consequence, so verdict comparisons pass forVerdict and keep them. - displayOnlyIgnoredPaths := []string{ - PathLastAppliedConfiguration, - } - - // Remove display-only paths (unless this is a verdict), then the caller's. Duplicates between - // the two are harmless: removeNestedPath is a no-op once the path is gone. - stripPaths := func(paths []string) { - for _, path := range paths { - if removeNestedPath(obj.Object, path) { - modifications = append(modifications, fmt.Sprintf("ignored path: %s", path)) - } - } - } - - if !options.ForVerdict { - stripPaths(displayOnlyIgnoredPaths) - } - - stripPaths(options.IgnorePaths) - - // Remove server-side fields and metadata that we don't want to diff - metadata, found, _ := un.NestedMap(obj.Object, "metadata") - if found { - // Special handling for objects with both name and generateName - // If the name looks like a generated display name (ends with "(generated)") - // and generateName is also present, remove the name to avoid confusion - name, nameFound, _ := un.NestedString(metadata, "name") - generateName, _, _ := un.NestedString(metadata, "generateName") - - modifications = append(modifications, stripSyntheticName(metadata, name, nameFound, generateName)...) - - // Remove fields that change automatically or are server-side - fieldsToRemove := []string{ - "resourceVersion", - "uid", - "generation", - "creationTimestamp", - "managedFields", - "selfLink", - "ownerReferences", - } - - // Track which fields were actually removed for debugging - var removedFields []string - - for _, field := range fieldsToRemove { - if _, exists := metadata[field]; exists { - delete(metadata, field) - removedFields = append(removedFields, field) - } - } - - // Only record if some fields were actually removed - if len(removedFields) > 0 { - modifications = append(modifications, fmt.Sprintf("metadata fields: %s", strings.Join(removedFields, ", "))) - } - - _ = un.SetNestedMap(obj.Object, metadata, "metadata") - } - - // Remove resourceRefs field from spec if it exists - // TODO: remove spec.crossplane entirely, or only spec.crossplane.resourceRefs? - spec, found, _ := un.NestedMap(obj.Object, "spec") - if found && spec != nil { - if _, exists := spec["resourceRefs"]; exists { - delete(spec, "resourceRefs") - - modifications = append(modifications, "resourceRefs from spec") - } - - _ = un.SetNestedMap(obj.Object, spec, "spec") - } - - crossplane, found, _ := un.NestedMap(obj.Object, "spec", "crossplane") - if found && crossplane != nil { - if _, exists := crossplane["resourceRefs"]; exists { - delete(crossplane, "resourceRefs") - - modifications = append(modifications, "resourceRefs from spec.crossplane") - } - - _ = un.SetNestedMap(obj.Object, crossplane, "spec", "crossplane") - - if len(crossplane) == 0 { - // If spec.crossplane is empty after removing resourceRefs, remove the entire field - delete(spec, "crossplane") - _ = un.SetNestedMap(obj.Object, spec, "spec") - - modifications = append(modifications, "empty spec.crossplane field") - } - } - - // Remove status field as we're focused on spec changes - if _, exists := obj.Object["status"]; exists { - delete(obj.Object, "status") - - modifications = append(modifications, "status field") - } - - // Log a single consolidated message if any modifications were made - if len(modifications) > 0 { - logger.Debug("Cleaned object for diff", - "resource", resKey, - "removed", strings.Join(modifications, ", "), - "after", obj) - } - - return obj -} diff --git a/internal/diff/renderer/diff_formatter_test.go b/internal/diff/renderer/diff_formatter_test.go index 682ec7bd..7c24a55c 100644 --- a/internal/diff/renderer/diff_formatter_test.go +++ b/internal/diff/renderer/diff_formatter_test.go @@ -4,244 +4,9 @@ import ( "strings" "testing" - "github.com/google/go-cmp/cmp" "github.com/sergi/go-diff/diffmatchpatch" - un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - - "github.com/crossplane/cli/v2/internal/diff/renderer/types" - tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) -func TestGenerateDiffWithOptions(t *testing.T) { - // Create test resources for diffing - current := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "old-value"). - WithSpecField("field2", int64(123)). - Build() - - desired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "new-value"). - WithSpecField("field2", int64(456)). - WithSpecField("field4", "added-field"). - Build() - - // Identical to current, for no-change test - noChanges := current.DeepCopy() - - // Resources carrying content that cleanupForDiff strips: an ignored - // annotation (via options.IgnorePaths below) and a server-side field - // (resourceVersion). Used to assert the Raw/Clean split. - const ignoredAnnotation = "argocd.argoproj.io/tracking-id" - - noisyCurrent := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "old-value"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). - Build() - noisyCurrent.SetResourceVersion("100") - - noisyDesired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "new-value"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). - Build() - noisyDesired.SetResourceVersion("200") - - // The expected Clean views for the noisy pair: the ignored annotation (and - // the now-empty annotations map) and the server-side resourceVersion are - // stripped, while the non-ignored spec change survives. - noisyCurrentClean := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "old-value"). - Build() - - noisyDesiredClean := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "new-value"). - Build() - - ignoreOpts := DefaultDiffOptions() - ignoreOpts.IgnorePaths = []string{"metadata.annotations[" + ignoredAnnotation + "]"} - - // A modified pair where the current (cluster) object is namespaced but the - // desired (rendered) object omits metadata.namespace — as manifests often - // do. Generation must prefer the current namespace so downstream (structured - // output, human diff) doesn't emit an empty namespace. Neither object has - // ignorable content, so Clean == Raw content. - nsCurrent := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - InNamespace("production"). - WithSpecField("field1", "old-value"). - Build() - - nsDesired := tu.NewResource("example.org/v1", "TestResource", "test-resource"). - WithSpecField("field1", "new-value"). - Build() - - tests := map[string]struct { - current *un.Unstructured - desired *un.Unstructured - kind string - resName string - options DiffOptions - wantDiff *types.ResourceDiff - wantErr bool - }{ - "ModifiedResource": { - current: current, - desired: desired, - kind: "TestResource", - resName: "test-resource", - options: DefaultDiffOptions(), - wantDiff: &types.ResourceDiff{ - Gvk: current.GroupVersionKind(), - ResourceName: "test-resource", - DiffType: types.DiffTypeModified, - // Nothing to strip, so Clean is content-equal to Raw. - Current: types.ResourceViews{Raw: current, Clean: current}, - Desired: types.ResourceViews{Raw: desired, Clean: desired}, - // LineDiffs will be checked separately - }, - }, - "NoChanges": { - current: current, - desired: noChanges, - kind: "TestResource", - resName: "test-resource", - options: DefaultDiffOptions(), - wantDiff: &types.ResourceDiff{ - Gvk: current.GroupVersionKind(), - ResourceName: "test-resource", - DiffType: types.DiffTypeEqual, - // Equal diffs render nothing, so Clean is left nil by generation. - Current: types.ResourceViews{Raw: current}, - Desired: types.ResourceViews{Raw: noChanges}, - }, - }, - "NewResource": { - current: nil, - desired: desired, - kind: "TestResource", - resName: "test-resource", - options: DefaultDiffOptions(), - wantDiff: &types.ResourceDiff{ - Gvk: desired.GroupVersionKind(), - ResourceName: "test-resource", - DiffType: types.DiffTypeAdded, - Current: types.ResourceViews{}, - Desired: types.ResourceViews{Raw: desired, Clean: desired}, - // LineDiffs will be checked separately - }, - }, - "RemovedResource": { - current: current, - desired: nil, - kind: "TestResource", - resName: "test-resource", - options: DefaultDiffOptions(), - wantDiff: &types.ResourceDiff{ - Gvk: current.GroupVersionKind(), - ResourceName: "test-resource", - DiffType: types.DiffTypeRemoved, - Current: types.ResourceViews{Raw: current, Clean: current}, - Desired: types.ResourceViews{}, - // LineDiffs will be checked separately - }, - }, - "ModifiedResource_RawAndCleanViews": { - // Locks the Raw/Clean split: Raw retains the ignored annotation and - // server-side resourceVersion, while Clean has both stripped and - // keeps only the non-ignored spec change. - current: noisyCurrent, - desired: noisyDesired, - kind: "TestResource", - resName: "test-resource", - options: ignoreOpts, - wantDiff: &types.ResourceDiff{ - Gvk: noisyCurrent.GroupVersionKind(), - ResourceName: "test-resource", - DiffType: types.DiffTypeModified, - Current: types.ResourceViews{Raw: noisyCurrent, Clean: noisyCurrentClean}, - Desired: types.ResourceViews{Raw: noisyDesired, Clean: noisyDesiredClean}, - }, - }, - "ModifiedResource_PrefersCurrentNamespace": { - // Regression: desired omits metadata.namespace but current has one. - // diff.Namespace must be the current namespace, not empty. - current: nsCurrent, - desired: nsDesired, - kind: "TestResource", - resName: "test-resource", - options: DefaultDiffOptions(), - wantDiff: &types.ResourceDiff{ - Gvk: nsCurrent.GroupVersionKind(), - ResourceName: "test-resource", - Namespace: "production", - DiffType: types.DiffTypeModified, - Current: types.ResourceViews{Raw: nsCurrent, Clean: nsCurrent}, - Desired: types.ResourceViews{Raw: nsDesired, Clean: nsDesired}, - }, - }, - "BothNil": { - current: nil, - desired: nil, - kind: "TestResource", - resName: "test-resource", - options: DefaultDiffOptions(), - wantErr: true, - }, - } - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - diff, err := GenerateDiffWithOptions(t.Context(), tt.current, tt.desired, tu.TestLogger(t, false), tt.options) - - if tt.wantErr { - if err == nil { - t.Errorf("GenerateDiffWithOptions() expected error but got none") - } - - return - } - - if err != nil { - t.Fatalf("GenerateDiffWithOptions() returned error: %v", err) - } - - if diff == nil { - t.Fatalf("GenerateDiffWithOptions() returned nil, want non-nil") - } - - // Check the basic properties - if diffStr := cmp.Diff(tt.wantDiff.Gvk, diff.Gvk); diffStr != "" { - t.Errorf("Gvk mismatch (-want +got):\n%s", diffStr) - } - - if diffStr := cmp.Diff(tt.wantDiff.ResourceName, diff.ResourceName); diffStr != "" { - t.Errorf("ResourceName mismatch (-want +got):\n%s", diffStr) - } - - if diffStr := cmp.Diff(tt.wantDiff.DiffType, diff.DiffType); diffStr != "" { - t.Errorf("DiffType mismatch (-want +got):\n%s", diffStr) - } - - // Check for line diffs - should be non-empty for changed resources - if diff.DiffType != types.DiffTypeEqual && len(diff.LineDiffs) == 0 { - t.Errorf("LineDiffs is empty for %s", name) - } - - if diffStr := cmp.Diff(tt.wantDiff.Namespace, diff.Namespace); diffStr != "" { - t.Errorf("Namespace mismatch (-want +got):\n%s", diffStr) - } - - // Check the Current and Desired views in full: Raw is the original - // object, Clean is the post-cleanup object (nil for equal diffs, and - // content-equal to Raw when there is nothing to strip). - if diffStr := cmp.Diff(tt.wantDiff.Current, diff.Current); diffStr != "" { - t.Errorf("Current views mismatch (-want +got):\n%s", diffStr) - } - - if diffStr := cmp.Diff(tt.wantDiff.Desired, diff.Desired); diffStr != "" { - t.Errorf("Desired views mismatch (-want +got):\n%s", diffStr) - } - }) - } -} - func TestFormatDiff(t *testing.T) { // Create test diffs simpleDiffs := []diffmatchpatch.Diff{ @@ -384,156 +149,3 @@ func TestFormatDiff(t *testing.T) { }) } } - -func TestRemoveNestedPath(t *testing.T) { - tests := map[string]struct { - obj map[string]any - path string - want bool - wantObj map[string]any - descr string - }{ - "SimplePath": { - obj: map[string]any{ - "metadata": map[string]any{ - "name": "test", - "namespace": "default", - }, - }, - path: "metadata.namespace", - want: true, - wantObj: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - descr: "removes a simple nested field", - }, - "MapKeyPath": { - obj: map[string]any{ - "metadata": map[string]any{ - "annotations": map[string]any{ - "kubectl.kubernetes.io/last-applied-configuration": "large-json", - "argocd.argoproj.io/tracking-id": "some-id", - "keep-this": "value", - }, - }, - }, - path: "metadata.annotations[kubectl.kubernetes.io/last-applied-configuration]", - want: true, - wantObj: map[string]any{ - "metadata": map[string]any{ - "annotations": map[string]any{ - "argocd.argoproj.io/tracking-id": "some-id", - "keep-this": "value", - }, - }, - }, - descr: "removes a specific key from a map", - }, - "MapKeyPathWithSlash": { - obj: map[string]any{ - "metadata": map[string]any{ - "labels": map[string]any{ - "argocd.argoproj.io/instance": "some-instance", - "provider": "aws", - }, - }, - }, - path: "metadata.labels[argocd.argoproj.io/instance]", - want: true, - wantObj: map[string]any{ - "metadata": map[string]any{ - "labels": map[string]any{ - "provider": "aws", - }, - }, - }, - descr: "removes a label with slash in key", - }, - "NonExistentPath": { - obj: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - path: "metadata.nonexistent", - want: false, - wantObj: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - descr: "returns false for non-existent path", - }, - "NonExistentMapKey": { - obj: map[string]any{ - "metadata": map[string]any{ - "annotations": map[string]any{ - "keep-this": "value", - }, - }, - }, - path: "metadata.annotations[nonexistent-key]", - want: false, - wantObj: map[string]any{ - "metadata": map[string]any{ - "annotations": map[string]any{ - "keep-this": "value", - }, - }, - }, - descr: "returns false for non-existent map key", - }, - "EmptyPath": { - obj: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - path: "", - want: false, - wantObj: map[string]any{ - "metadata": map[string]any{ - "name": "test", - }, - }, - descr: "returns false for empty path", - }, - "RemoveEntireSection": { - obj: map[string]any{ - "metadata": map[string]any{ - "annotations": map[string]any{ - "key": "value", - }, - }, - "spec": map[string]any{ - "field": "value", - }, - }, - path: "metadata.annotations", - want: true, - wantObj: map[string]any{ - "metadata": map[string]any{}, - "spec": map[string]any{ - "field": "value", - }, - }, - descr: "removes entire nested map", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - got := removeNestedPath(tc.obj, tc.path) - - if got != tc.want { - t.Errorf("removeNestedPath() = %v, want %v for %s", got, tc.want, tc.descr) - } - - if diff := cmp.Diff(tc.wantObj, tc.obj); diff != "" { - t.Errorf("removeNestedPath() object mismatch (-want +got):\n%s", diff) - } - }) - } -} diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index 70ddb819..8cbb65a0 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -419,70 +419,3 @@ func xrGroup(kind, name string, diffs map[string]*dt.ResourceDiff) dt.XRDiffGrou Diffs: diffs, } } - -func TestGetLineDiff(t *testing.T) { - tests := map[string]struct { - oldText string - newText string - expected []diffmatchpatch.Operation - }{ - "NoChanges": { - oldText: "line1\nline2\nline3\n", - newText: "line1\nline2\nline3\n", - expected: []diffmatchpatch.Operation{ - diffmatchpatch.DiffEqual, - }, - }, - "LineAdded": { - oldText: "line1\nline2\n", - newText: "line1\nline2\nline3\n", - expected: []diffmatchpatch.Operation{ - diffmatchpatch.DiffEqual, - diffmatchpatch.DiffInsert, - }, - }, - "LineRemoved": { - oldText: "line1\nline2\nline3\n", - newText: "line1\nline3\n", - expected: []diffmatchpatch.Operation{ - diffmatchpatch.DiffEqual, - diffmatchpatch.DiffDelete, - diffmatchpatch.DiffEqual, - }, - }, - "LineModified": { - oldText: "line1\nline2\nline3\n", - newText: "line1\nmodified2\nline3\n", - expected: []diffmatchpatch.Operation{ - diffmatchpatch.DiffEqual, - diffmatchpatch.DiffDelete, - diffmatchpatch.DiffInsert, - diffmatchpatch.DiffEqual, - }, - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - result := GetLineDiff(tt.oldText, tt.newText) - - // Check that we have the expected diff types - if len(result) != len(tt.expected) { - t.Errorf("GetLineDiff() returned %d diffs, want %d", len(result), len(tt.expected)) - - for i, diff := range result { - t.Logf("Diff %d: Type=%s, Text=%q", i, diff.Type, diff.Text) - } - - return - } - - // Verify the types match in sequence - for i, expectedType := range tt.expected { - if result[i].Type != expectedType { - t.Errorf("GetLineDiff() diff[%d] has type %s, want %s", i, result[i].Type, expectedType) - } - } - }) - } -} diff --git a/internal/diff/renderer/structured_renderer.go b/internal/diff/renderer/structured_renderer.go index 6e7baedc..f97d5e18 100644 --- a/internal/diff/renderer/structured_renderer.go +++ b/internal/diff/renderer/structured_renderer.go @@ -308,7 +308,7 @@ type CompositionDiff struct { // evaluated. ImpactAnalysisSkipped bool // MaskedChangesOnly records that the composition differs from the cluster's only in fields - // excluded from the rendered diff — the user's --ignore-paths, or the renderer's display-only + // excluded from the rendered diff — the user's --ignore-paths, or the comparison's display-only // suppressions. CompositionDiff is nil in that case, but the composition did change, so the // renderer must not report it as unchanged. MaskedChangesOnly bool diff --git a/internal/diff/renderer/structured_renderer_test.go b/internal/diff/renderer/structured_renderer_test.go index 4da224a8..d9e6901c 100644 --- a/internal/diff/renderer/structured_renderer_test.go +++ b/internal/diff/renderer/structured_renderer_test.go @@ -2,7 +2,6 @@ package renderer import ( "bytes" - "context" "encoding/json" "maps" "reflect" @@ -16,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" sigsyaml "sigs.k8s.io/yaml" + "github.com/crossplane/cli/v2/internal/diff/compare" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -621,7 +621,7 @@ func TestStructuredDiffRenderer_RenderDiffs_ErrorsInOutputOnly(t *testing.T) { // and the unconditional cleanup performed for the human diff are also honored // when rendering structured (JSON/YAML) output. // -// Each case runs GenerateDiffWithOptions to classify the diff (matching real +// Each case runs compare.GenerateDiff to classify the diff (matching real // CLI flow) and then renders through the structured renderer, then asserts on // the parsed structured output. func TestStructuredDiffRenderer_RespectsIgnorePaths(t *testing.T) { @@ -785,12 +785,11 @@ func TestStructuredDiffRenderer_RespectsIgnorePaths(t *testing.T) { // added in defaultProcessorOptions, cmd_utils.go). That default // wiring is covered end-to-end by the IgnorePathsArgoCD // integration test in diff_integration_test.go. - diffOpts := DefaultDiffOptions() - diffOpts.IgnorePaths = tc.ignorePaths + diffOpts := compare.Options{IgnorePaths: tc.ignorePaths} - rd, err := GenerateDiffWithOptions(context.Background(), tc.current, tc.desired, logger, diffOpts) + rd, err := compare.GenerateDiff(t.Context(), tc.current, tc.desired, logger, diffOpts) if err != nil { - t.Fatalf("GenerateDiffWithOptions: %v", err) + t.Fatalf("compare.GenerateDiff: %v", err) } var buf bytes.Buffer From 83cfc83273bbae14a063309d0df23bd4f702cbce Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 16:47:37 -0400 Subject: [PATCH 041/126] feat(plan): carry composite grouping, errors and revision impact in plan.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) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/simulate/simulate.go | 2 +- internal/plan/fallback.go | 13 ++ internal/plan/fallback_test.go | 31 ++++ internal/plan/output.go | 250 ++++++++++++++++++++++++---- internal/plan/output_test.go | 105 +++++++++++- internal/plan/types.go | 34 ++++ 6 files changed, 403 insertions(+), 32 deletions(-) diff --git a/cmd/crossplane/simulate/simulate.go b/cmd/crossplane/simulate/simulate.go index a623b354..490b6444 100644 --- a/cmd/crossplane/simulate/simulate.go +++ b/cmd/crossplane/simulate/simulate.go @@ -234,7 +234,7 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s plans = append(plans, c.simulateOne(ctx, kubeClient, rmapper, defaultNamespace, mgr, stores, crds, desired)) } - return renderer.Render(k.Stdout, plans) + return renderer.Render(k.Stdout, plan.NewResult(plans)) } // imageConfigs returns the ImageConfigs to rewrite provider image paths with: diff --git a/internal/plan/fallback.go b/internal/plan/fallback.go index 5827a53e..35fe4622 100644 --- a/internal/plan/fallback.go +++ b/internal/plan/fallback.go @@ -22,6 +22,7 @@ import ( "fmt" "maps" "slices" + "strings" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/structured-merge-diff/v6/fieldpath" @@ -103,6 +104,18 @@ func (c cursor) removable(owned *fieldpath.Set) bool { return owned == nil || ownedLeaf(owned, c.fp) } +// FieldChanges returns the changes that turn actual into planned, with paths +// rooted at the top level (e.g. metadata.labels.team). A nil planned yields +// actual-only changes, as for a delete; a nil actual yields planned-only +// changes, as for a create. +func FieldChanges(actual, planned map[string]any) []FieldChange { + out := diffMaps(cursor{}, actual, planned, nil) + for i := range out { + out[i].Path = strings.TrimPrefix(out[i].Path, ".") + } + return out +} + // createChanges flattens a desired map into planned-only field changes, so a // create lists what it would set. func createChanges(path string, m map[string]any) []FieldChange { diff --git a/internal/plan/fallback_test.go b/internal/plan/fallback_test.go index 530d5854..85982d3c 100644 --- a/internal/plan/fallback_test.go +++ b/internal/plan/fallback_test.go @@ -276,3 +276,34 @@ func TestFallback(t *testing.T) { }) } } + +func TestFieldChanges(t *testing.T) { + cases := map[string]struct { + actual, planned map[string]any + want []FieldChange + }{ + "DeleteIsActualOnly": { + actual: map[string]any{"metadata": map[string]any{"labels": map[string]any{"team": "a"}}, "spec": map[string]any{"size": 1}}, + want: []FieldChange{ + {Path: "metadata.labels.team", Actual: &Value{Value: "a"}}, + {Path: "spec.size", Actual: &Value{Value: 1}}, + }, + }, + "CreateIsPlannedOnly": { + planned: map[string]any{"spec": map[string]any{"size": 1}}, + want: []FieldChange{{Path: "spec.size", Planned: &Value{Value: 1}}}, + }, + "Update": { + actual: map[string]any{"spec": map[string]any{"size": 1}}, + planned: map[string]any{"spec": map[string]any{"size": 2}}, + want: []FieldChange{{Path: "spec.size", Actual: &Value{Value: 1}, Planned: &Value{Value: 2}}}, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if diff := cmp.Diff(tc.want, FieldChanges(tc.actual, tc.planned)); diff != "" { + t.Errorf("FieldChanges(...): -want, +got:\n%s", diff) + } + }) + } +} diff --git a/internal/plan/output.go b/internal/plan/output.go index 8391645c..1fa9e82a 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -25,6 +25,7 @@ import ( "sort" "strings" + "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/yaml" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -50,9 +51,13 @@ type Summary struct { NoOp int `json:"noop"` Approximate int `json:"approximate"` Errored int `json:"errored"` + // Deferred counts composites pinned to a composition revision, which + // applying changes nothing for. It does not count as a change. + Deferred int `json:"deferred,omitempty"` } -// Summarize counts the given plans. +// Summarize counts the given plans. It cannot know about deferred +// composites; whoever builds Composites sets Summary.Deferred. func Summarize(plans []*ResourcePlan) Summary { var s Summary for _, p := range plans { @@ -82,16 +87,97 @@ func Summarize(plans []*ResourcePlan) Summary { // ResultFormatVersion versions the serialized result schema so automation // consuming it can detect incompatible changes. The schema is alpha; the // version moves to 1.0 when it stabilizes. -const ResultFormatVersion = "0.1" +const ResultFormatVersion = "0.2" + +// CompositeStatus is the outcome of simulating one composite resource. +type CompositeStatus string + +// The statuses a composite can have. +const ( + CompositeChanged CompositeStatus = "changed" + CompositeUnchanged CompositeStatus = "unchanged" + CompositeError CompositeStatus = "error" + // CompositeDeferred means the composite is pinned to a composition + // revision, so applying changes nothing for it yet. + CompositeDeferred CompositeStatus = "deferred" + // CompositeSkipped means the composite was not simulated, for Reason. + CompositeSkipped CompositeStatus = "skipped" +) + +// CompositeResult is the per-composite outcome of a project simulate run. +type CompositeResult struct { + Ref ResourceRef `json:"ref"` + Status CompositeStatus `json:"status"` + // Reason is the machine-readable reason a composite was deferred or + // skipped. + Reason string `json:"reason,omitempty"` + // Detail elaborates on Reason, e.g. the pinned revision. + Detail string `json:"detail,omitempty"` +} + +// Error is an error from a simulate run, optionally anchored to a resource. +type Error struct { + Resource *ResourceRef `json:"resource,omitempty"` + Message string `json:"message"` + ValidationFailures []ValidationFailure `json:"validationFailures,omitempty"` +} + +// ValidationFailure is one resource that failed schema validation. +type ValidationFailure struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name,omitempty"` + Namespace string `json:"namespace,omitempty"` + // Status is the validator's outcome, e.g. invalid or missingSchema. + Status string `json:"status"` + Errors []FieldValidationError `json:"errors,omitempty"` +} + +// FieldValidationError is one field-level validation error. +type FieldValidationError struct { + // Type categorizes the error: schema, cel, unknownField or defaulting. + Type string `json:"type"` + Field string `json:"field,omitempty"` + Message string `json:"message"` + Value any `json:"value,omitempty"` +} + +// Warning is an advisory that did not fail the run. +type Warning struct { + Message string `json:"message"` + Context map[string]string `json:"context,omitempty"` +} + +// CompositionResult is the outcome for one changed Composition. +type CompositionResult struct { + Ref ResourceRef `json:"ref"` + RevisionImpact *RevisionImpact `json:"revisionImpact,omitempty"` +} + +// RevisionImpact says whether applying a Composition creates a revision, and +// which composites would adopt it. +type RevisionImpact struct { + // ChangeScope is none, metadata or spec. + ChangeScope string `json:"changeScope"` + CreatesRevision bool `json:"createsRevision"` + RepointedComposites int `json:"repointedComposites"` + // PredictedRevision is the revision's predicted name; empty when it + // cannot be predicted. + PredictedRevision string `json:"predictedRevision,omitempty"` +} // Result is the serialized form of a simulate run, for -o json|yaml. Errored // is the primary gate for wrapping automation: when true, at least one // resource could not be planned and the run's answer is incomplete. type Result struct { - FormatVersion string `json:"formatVersion"` - Errored bool `json:"errored"` - Summary Summary `json:"summary"` - Plans []*ResourcePlan `json:"plans"` + FormatVersion string `json:"formatVersion"` + Errored bool `json:"errored"` + Summary Summary `json:"summary"` + Composites []CompositeResult `json:"composites,omitempty"` + Compositions []CompositionResult `json:"compositions,omitempty"` + Plans []*ResourcePlan `json:"plans"` + Errors []Error `json:"errors,omitempty"` + Warnings []Warning `json:"warnings,omitempty"` } // NewResult assembles the serializable Result for the given plans. @@ -105,9 +191,9 @@ func NewResult(plans []*ResourcePlan) Result { } } -// A Renderer writes resource plans to w in one output format. +// A Renderer writes a simulate result to w in one output format. type Renderer interface { - Render(w io.Writer, plans []*ResourcePlan) error + Render(w io.Writer, r Result) error } // NewRenderer returns the Renderer for the given output format. @@ -126,9 +212,9 @@ func NewRenderer(format string) (Renderer, error) { // jsonRenderer renders the serialized Result as indented JSON. type jsonRenderer struct{} -// Render writes the plans to w as JSON. -func (jsonRenderer) Render(w io.Writer, plans []*ResourcePlan) error { - out, err := json.MarshalIndent(NewResult(plans), "", " ") +// Render writes the result to w as JSON. +func (jsonRenderer) Render(w io.Writer, r Result) error { + out, err := json.MarshalIndent(r, "", " ") if err != nil { return errors.Wrap(err, "cannot marshal plans to JSON") } @@ -139,9 +225,9 @@ func (jsonRenderer) Render(w io.Writer, plans []*ResourcePlan) error { // yamlRenderer renders the serialized Result as YAML. type yamlRenderer struct{} -// Render writes the plans to w as YAML. -func (yamlRenderer) Render(w io.Writer, plans []*ResourcePlan) error { - out, err := yaml.Marshal(NewResult(plans)) +// Render writes the result to w as YAML. +func (yamlRenderer) Render(w io.Writer, r Result) error { + out, err := yaml.Marshal(r) if err != nil { return errors.Wrap(err, "cannot marshal plans to YAML") } @@ -150,26 +236,131 @@ func (yamlRenderer) Render(w io.Writer, plans []*ResourcePlan) error { } // planRenderer renders the human-readable plan format: a header and change -// tree per resource, then a summary with warnings. +// tree per resource, then a summary with warnings. When any plan or +// composite names a composite, plans print grouped under an +// "XR: Kind.group name" header. Run-level Errors and Warnings are not +// printed; the command reports them on stderr. type planRenderer struct{} -// Render writes the plans to w in the human-readable plan format. -func (planRenderer) Render(w io.Writer, plans []*ResourcePlan) error { +// Render writes the result to w in the human-readable plan format. +func (planRenderer) Render(w io.Writer, r Result) error { + if len(r.Composites) == 0 && !slices.ContainsFunc(r.Plans, func(p *ResourcePlan) bool { return p.Composite != nil }) { + for _, p := range r.Plans { + renderPlan(w, p) + } + } else { + renderGrouped(w, r) + } + renderSummary(w, r.Plans, r.Summary) + return nil +} + +// renderGrouped prints ungrouped plans first, then each composite (in +// Composites order, then any only plans name) with its plans indented +// beneath. A composite's own plan is omitted when it is a no-op. +func renderGrouped(w io.Writer, r Result) { + var refs []ResourceRef + byRef := map[ResourceRef][]*ResourcePlan{} + status := map[ResourceRef]CompositeResult{} + for _, c := range r.Composites { + if _, ok := status[c.Ref]; !ok { + refs = append(refs, c.Ref) + } + status[c.Ref] = c + } + for _, p := range r.Plans { + if p.Composite == nil { + renderPlan(w, p) + continue + } + ref := *p.Composite + if _, ok := status[ref]; !ok { + if _, seen := byRef[ref]; !seen { + refs = append(refs, ref) + } + } + byRef[ref] = append(byRef[ref], p) + } + + for _, ref := range refs { + renderComposite(w, status[ref], ref, byRef[ref]) + } +} + +// renderComposite prints one composite's header, its deferral or skip note, +// and its plans indented beneath. +func renderComposite(w io.Writer, c CompositeResult, ref ResourceRef, plans []*ResourcePlan) { + _, _ = fmt.Fprintln(w, compositeHeader(c, ref)) + switch c.Status { + case CompositeDeferred: + pinned := "Pinned to a composition revision" + if c.Detail != "" { + pinned += " (" + c.Detail + ")" + } + _, _ = fmt.Fprintf(w, " %s; apply changes nothing until the\n", pinned) + _, _ = fmt.Fprintln(w, " revision advances. Rerun with --include-pinned to preview that moment.") + case CompositeSkipped: + if c.Detail != "" { + _, _ = fmt.Fprintf(w, " %s\n", c.Detail) + } + case CompositeChanged, CompositeUnchanged, CompositeError: + } + var block strings.Builder for _, p := range plans { - _, _ = fmt.Fprintln(w, header(p)) - if p.Error != "" { - _, _ = fmt.Fprintf(w, " cannot plan: %s\n", p.Error) - _, _ = fmt.Fprintln(w) + if p.Action == ActionNoOp && p.Error == "" && p.ResourceRef() == ref { continue } - if p.Approximate { - _, _ = fmt.Fprintf(w, " (approximate: %s)\n", p.FallbackReason) + renderPlan(&block, p) + } + for line := range strings.Lines(block.String()) { + if line != "\n" { + line = " " + line } - renderChangeTree(w, p.Changes) + _, _ = fmt.Fprint(w, line) + } + if block.Len() == 0 { _, _ = fmt.Fprintln(w) } - renderSummary(w, plans) - return nil +} + +// compositeHeader returns e.g. "XR: XNetwork.example.org prod-network". +func compositeHeader(c CompositeResult, ref ResourceRef) string { + gvk := schema.FromAPIVersionAndKind(ref.APIVersion, ref.Kind) + name := ref.Name + if ref.Namespace != "" { + name = ref.Namespace + "/" + name + } + h := fmt.Sprintf("XR: %s.%s %s", gvk.Kind, gvk.Group, name) + switch c.Status { + case CompositeDeferred: + h += " (deferred)" + case CompositeSkipped: + h += " (skipped" + if c.Reason != "" { + h += ": " + c.Reason + } + h += ")" + case CompositeChanged, CompositeUnchanged, CompositeError: + } + return h +} + +// renderPlan prints one resource's header and change tree. A delete prints +// only its header, Terraform-style, though it carries actual-only changes. +func renderPlan(w io.Writer, p *ResourcePlan) { + _, _ = fmt.Fprintln(w, header(p)) + if p.Error != "" { + _, _ = fmt.Fprintf(w, " cannot plan: %s\n", p.Error) + _, _ = fmt.Fprintln(w) + return + } + if p.Approximate { + _, _ = fmt.Fprintf(w, " (approximate: %s)\n", p.FallbackReason) + } + if p.Action != ActionDelete { + renderChangeTree(w, p.Changes) + } + _, _ = fmt.Fprintln(w) } // header returns the per-resource line, e.g. @@ -255,9 +446,7 @@ func renderChangeTree(w io.Writer, changes []FieldChange) { } } -func renderSummary(w io.Writer, plans []*ResourcePlan) { - s := Summarize(plans) - +func renderSummary(w io.Writer, plans []*ResourcePlan, s Summary) { line := fmt.Sprintf("Plan: %d to add, %d to change, %d to destroy", s.Create, s.Update, s.Destroy) if s.Replace > 0 { verb := "requires" @@ -267,6 +456,9 @@ func renderSummary(w io.Writer, plans []*ResourcePlan) { line += fmt.Sprintf(", %d %s replacement", s.Replace, verb) } _, _ = fmt.Fprintln(w, line+".") + if s.Deferred > 0 { + _, _ = fmt.Fprintf(w, "%s deferred (pinned to a composition revision).\n", pluralize(s.Deferred, "composite resource", "composite resources")) + } if s.Replace > 0 { named := 0 diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 1ece5f76..34793cfa 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -109,7 +109,7 @@ Warning: 1 resource could not be planned. if err != nil { t.Fatalf("NewRenderer(plan): unexpected error: %v", err) } - if err := r.Render(&buf, testPlans()); err != nil { + if err := r.Render(&buf, NewResult(testPlans())); err != nil { t.Fatalf("Render(plan): unexpected error: %v", err) } if diff := cmp.Diff(want, buf.String()); diff != "" { @@ -195,7 +195,7 @@ func TestRenderJSON(t *testing.T) { if err != nil { t.Fatalf("NewRenderer(json): unexpected error: %v", err) } - if err := r.Render(&buf, testPlans()); err != nil { + if err := r.Render(&buf, NewResult(testPlans())); err != nil { t.Fatalf("Render(json): unexpected error: %v", err) } @@ -226,3 +226,104 @@ func TestSummarize(t *testing.T) { t.Errorf("Summarize(...): -want, +got:\n%s", diff) } } + +func TestRenderPlanGrouped(t *testing.T) { + xr := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} + pinned := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "staging-network"} + r := Result{ + Summary: Summary{Update: 1, Destroy: 1, NoOp: 1, Deferred: 1}, + Composites: []CompositeResult{ + {Ref: xr, Status: CompositeChanged}, + {Ref: pinned, Status: CompositeDeferred, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6"}, + }, + Plans: []*ResourcePlan{ + // The XR's own no-op plan is not printed. + {APIVersion: xr.APIVersion, Kind: xr.Kind, Name: xr.Name, Action: ActionNoOp, Composite: &xr}, + { + APIVersion: "ec2.aws.upbound.io/v1beta1", Kind: "SecurityGroupRule", Name: "prod-network-ssh", + Action: ActionUpdate, Composite: &xr, + Changes: []FieldChange{{Path: "spec.forProvider.fromPort", Actual: &Value{Value: 22}, Planned: &Value{Value: 2222}}}, + }, + { + APIVersion: "ec2.aws.upbound.io/v1beta1", Kind: "Route", Name: "prod-network-default", + Action: ActionDelete, Composite: &xr, + Changes: FieldChanges(map[string]any{"spec": map[string]any{"region": "us-west-1"}}, nil), + }, + }, + } + want := `XR: XNetwork.example.org prod-network + [~] SecurityGroupRule.ec2.aws.upbound.io prod-network-ssh (update) + spec: + forProvider: + ~ fromPort: 22 -> 2222 + + [-] Route.ec2.aws.upbound.io prod-network-default (delete) + +XR: XNetwork.example.org staging-network (deferred) + Pinned to a composition revision (compositionRevisionRef: net-6); apply changes nothing until the + revision advances. Rerun with --include-pinned to preview that moment. + +Plan: 0 to add, 1 to change, 1 to destroy. +1 composite resource deferred (pinned to a composition revision). +` + var buf bytes.Buffer + r2, _ := NewRenderer(FormatPlan) + if err := r2.Render(&buf, r); err != nil { + t.Fatalf("Render(plan): unexpected error: %v", err) + } + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("Render(plan): -want, +got:\n%s", diff) + } +} + +func TestRenderJSONProjectFields(t *testing.T) { + xr := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} + in := Result{ + FormatVersion: ResultFormatVersion, + Errored: true, + Summary: Summary{Destroy: 1, Deferred: 1}, + Composites: []CompositeResult{{Ref: xr, Status: CompositeDeferred, Reason: "manual_policy"}}, + Compositions: []CompositionResult{{ + Ref: ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"}, + RevisionImpact: &RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2, PredictedRevision: "net-abc1234"}, + }}, + Plans: []*ResourcePlan{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Action: ActionDelete, Composite: &xr, + Changes: []FieldChange{{Path: "data.k", Actual: &Value{Value: "v"}}}, + DryRun: &Verification{SkipReason: "forbidden", Detail: "no create permission"}, + }}, + Errors: []Error{{ + Resource: &xr, Message: "invalid", + ValidationFailures: []ValidationFailure{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Status: "invalid", + Errors: []FieldValidationError{{Type: "schema", Field: "data", Message: "bad"}}, + }}, + }}, + Warnings: []Warning{{Message: "heads up", Context: map[string]string{"k": "v"}}}, + } + var buf bytes.Buffer + r, _ := NewRenderer(FormatJSON) + if err := r.Render(&buf, in); err != nil { + t.Fatalf("Render(json): unexpected error: %v", err) + } + got := Result{} + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatalf("Render(json): output is not valid JSON: %v", err) + } + if diff := cmp.Diff(in, got); diff != "" { + t.Errorf("Render(json): -want, +got:\n%s", diff) + } +} + +func TestRenderJSONOmitsProjectFields(t *testing.T) { + var buf bytes.Buffer + r, _ := NewRenderer(FormatJSON) + if err := r.Render(&buf, NewResult(testPlans())); err != nil { + t.Fatalf("Render(json): unexpected error: %v", err) + } + for _, k := range []string{`"composites"`, `"compositions"`, `"errors"`, `"warnings"`, `"deferred"`, `"composite"`, `"dryRun"`} { + if bytes.Contains(buf.Bytes(), []byte(k)) { + t.Errorf("Render(json): resource simulate output should omit %s", k) + } + } +} diff --git a/internal/plan/types.go b/internal/plan/types.go index 29ab7fdd..0ca376d1 100644 --- a/internal/plan/types.go +++ b/internal/plan/types.go @@ -100,6 +100,13 @@ type ResourcePlan struct { FallbackReason string `json:"fallbackReason,omitempty"` // Error is set when no plan could be computed for this resource at all. Error string `json:"error,omitempty"` + // Composite is the composite resource (XR or claim) this resource belongs + // to, set by project simulate. Plans with a Composite render grouped + // under it. + Composite *ResourceRef `json:"composite,omitempty"` + // DryRun is set when the resource's desired state could not be verified + // against the API server; nil means it was verified (or not applicable). + DryRun *Verification `json:"dryRun,omitempty"` // Desired is the resource the plan is for. It is carried for rendering // (the plan output shows the user's own manifest, annotated) and is not @@ -107,11 +114,38 @@ type ResourcePlan struct { Desired *unstructured.Unstructured `json:"-"` } +// ResourceRef identifies a resource. +type ResourceRef struct { + APIVersion string `json:"apiVersion"` + Kind string `json:"kind"` + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` +} + +// Verification records a server-side dry run that did not happen. +type Verification struct { + // Performed is false whenever a Verification is present; it is kept so + // the serialized object is self-describing. + Performed bool `json:"performed"` + // SkipReason says why the dry run was not performed. + SkipReason string `json:"skipReason,omitempty"` + // Detail is the underlying cause as reported by the cluster. + Detail string `json:"detail,omitempty"` + // StorageVersion is the CRD's storage version, set when it differs from + // the version the resource was requested at. + StorageVersion string `json:"storageVersion,omitempty"` +} + // GroupVersionKind returns the plan's resource type. func (p *ResourcePlan) GroupVersionKind() schema.GroupVersionKind { return schema.FromAPIVersionAndKind(p.APIVersion, p.Kind) } +// ResourceRef returns the plan's resource identity. +func (p *ResourcePlan) ResourceRef() ResourceRef { + return ResourceRef{APIVersion: p.APIVersion, Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} +} + // NewResourcePlan returns a ResourcePlan identifying the given desired // resource, with no action decided yet. func NewResourcePlan(desired *unstructured.Unstructured) *ResourcePlan { From dee0633ece6fafb369d7de8a61d4a3a5454a76ae Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 17:38:38 -0400 Subject: [PATCH 042/126] refactor(simulate): move per-resource planning into internal/simulate Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/simulate/simulate.go | 278 +----------- cmd/crossplane/simulate/simulate_test.go | 400 ----------------- internal/plan/output_test.go | 4 +- .../simulate/defaults.go | 0 internal/simulate/simulate.go | 341 ++++++++++++++ internal/simulate/simulate_test.go | 422 ++++++++++++++++++ 6 files changed, 769 insertions(+), 676 deletions(-) rename {cmd/crossplane => internal}/simulate/defaults.go (100%) create mode 100644 internal/simulate/simulate.go create mode 100644 internal/simulate/simulate_test.go diff --git a/cmd/crossplane/simulate/simulate.go b/cmd/crossplane/simulate/simulate.go index 490b6444..659d2e3e 100644 --- a/cmd/crossplane/simulate/simulate.go +++ b/cmd/crossplane/simulate/simulate.go @@ -29,14 +29,10 @@ import ( "time" "github.com/alecthomas/kong" - diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" "github.com/spf13/afero" - "google.golang.org/protobuf/types/known/structpb" - apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/kubernetes/scheme" @@ -47,7 +43,6 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/crossplane/apis/v2/pkg" - pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" "github.com/crossplane/cli/v2/cmd/crossplane/common/load" @@ -58,6 +53,7 @@ import ( "github.com/crossplane/cli/v2/internal/kube" "github.com/crossplane/cli/v2/internal/plan" "github.com/crossplane/cli/v2/internal/project/projectfile" + isimulate "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" @@ -189,7 +185,7 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s // With local ImageConfigs the rewrites apply to original package // references, so discovery must hand those out; without them the // cluster's resolved reference is the exact artifact it runs. - images, err := relevantProviderImages(ctx, kubeClient, rmapper, resources, len(imageConfigs) > 0) + images, err := isimulate.RelevantProviderImages(ctx, kubeClient, rmapper, resources, len(imageConfigs) > 0) if err != nil { return errors.Wrap(err, "cannot discover the providers serving the given resources") } @@ -228,10 +224,9 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s } plans := make([]*plan.ResourcePlan, 0, len(resources)) - stores := newObjectStoreCache() - crds := newCRDDefaults() + planner := isimulate.NewPlanner(kubeClient, rmapper, defaultNamespace, mgr, c.Timeout) for _, desired := range resources { - plans = append(plans, c.simulateOne(ctx, kubeClient, rmapper, defaultNamespace, mgr, stores, crds, desired)) + plans = append(plans, planner.Plan(ctx, desired)) } return renderer.Render(k.Stdout, plan.NewResult(plans)) @@ -414,271 +409,6 @@ func loadImageConfigs(path string) ([]pkgv1beta1.ImageConfig, error) { return ics, nil } -// router routes a resource's group and kind to a diff server client. It is -// the slice of diffserver.Manager the per-resource flow needs, extracted so -// tests can fake routing. -type router interface { - Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) -} - -// simulateOne plans a single resource: fetch its live counterpart, route it -// to a diff server, and fall back to the approximate client-side diff when no -// server can plan it. Failures become per-resource errors so one resource -// never fails the whole run. -func (c *Cmd) simulateOne(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, mgr router, stores *objectStoreCache, crds *crdDefaults, desired *unstructured.Unstructured) *plan.ResourcePlan { - if desired.GetName() == "" { - p := plan.NewResourcePlan(desired) - p.Error = "resource has no name" - return p - } - - live, err := fetchLive(ctx, kubeClient, rmapper, defaultNamespace, desired) - if err != nil { - p := plan.NewResourcePlan(desired) - p.Error = fmt.Sprintf("cannot fetch live resource: %s", err) - return p - } - - // Crossplane's external tags survive every apply (the provider's - // initializer re-adds them), so a preview must never claim they would be - // removed. - plan.MergeExternalTags(desired, live) - - gk := desired.GroupVersionKind().GroupKind() - planClient, ok := mgr.Route(gk) - if !ok { - // The fallback diffs desired against live verbatim, but live was - // defaulted by the API server on admission and applying the manifest - // would default it again. Apply the CRD's defaults to desired first, - // so defaulted fields the manifest omits do not plan as removals. - return plan.Fallback(crds.apply(ctx, kubeClient, rmapper, desired), live, fmt.Sprintf("no diff server for %s", gk)) - } - - pctx, cancel := context.WithTimeout(ctx, c.Timeout) - defer cancel() - - store := stores.gather(ctx, kubeClient, rmapper, defaultNamespace, desired, live) - - p, err := plan.Plan(pctx, planClient, desired, live, store) - if err != nil { - if plan.IsNotSupported(err) { - return plan.Fallback(crds.apply(ctx, kubeClient, rmapper, desired), live, "the provider cannot compute a diff for this resource") - } - p := plan.NewResourcePlan(desired) - p.Error = fmt.Sprintf("diff server cannot plan: %s", err) - return p - } - return p -} - -// fetchLive returns the live counterpart of the desired resource, or nil when -// it does not exist (including when its kind is not installed on the -// cluster), in which case the resource plans as a create. -func fetchLive(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, desired *unstructured.Unstructured) (*unstructured.Unstructured, error) { - gvk := desired.GroupVersionKind() - - mapping, err := rmapper.RESTMapping(gvk.GroupKind(), gvk.Version) - if err != nil { - // The kind is not served by the cluster: nothing can exist yet. The - // diff server can still plan the resource as a create. - return nil, nil //nolint:nilerr // Intentional: an unserved kind means no live counterpart, not a failure. - } - - key := client.ObjectKey{Name: desired.GetName()} - if mapping.Scope.Name() == meta.RESTScopeNameNamespace { - key.Namespace = desired.GetNamespace() - if key.Namespace == "" { - key.Namespace = defaultNamespace - } - } - - live := &unstructured.Unstructured{} - live.SetGroupVersionKind(gvk) - if err := kubeClient.Get(ctx, key, live); err != nil { - if apierrors.IsNotFound(err) { - return nil, nil - } - return nil, err - } - return live, nil -} - -// objectStoreCache caches gathered object stores by the resolved -// ProviderConfig identity, so a run over many resources referencing the same -// ProviderConfig fetches and converts it once rather than per resource. -// Missing objects are cached too: a reference the cluster cannot resolve is -// not refetched for every resource that carries it. -type objectStoreCache struct { - cache map[objectStoreKey][]*structpb.Struct -} - -type objectStoreKey struct { - gvk schema.GroupVersionKind - key client.ObjectKey -} - -func newObjectStoreCache() *objectStoreCache { - return &objectStoreCache{cache: map[objectStoreKey][]*structpb.Struct{}} -} - -// gather returns the cluster objects the diff server needs to resolve -// the desired resource: today the ProviderConfig it references. The diff -// server is offline and only sees what travels in the request, so anything it -// must resolve is fetched here, with the user's own kubeconfig and RBAC. The -// ProviderConfig's credentials Secret deliberately stays out: the server runs -// offline with placeholder credentials and never needs the real ones. -// Gathering is best effort: a missing object simply stays out of the store, -// and the server reports precisely what it could not resolve. -func (c *objectStoreCache) gather(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, desired, live *unstructured.Unstructured) []*structpb.Struct { - // Read the reference from the live resource when one exists: the API - // server has already applied the CRD's defaults there, so its kind and - // name are authoritative. The desired manifest may omit both. - src := desired - if live != nil { - src = live - } - name, _, _ := unstructured.NestedString(src.Object, "spec", "providerConfigRef", "name") - if name == "" { - name = "default" - } - kind, _, _ := unstructured.NestedString(src.Object, "spec", "providerConfigRef", "kind") - if kind == "" { - // Legacy cluster-scoped resources have no kind in the reference and - // always mean ProviderConfig. Namespaced resources default to - // ClusterProviderConfig, per the CRD default the API server applies. - kind = "ProviderConfig" - gvk := desired.GroupVersionKind() - if m, err := rmapper.RESTMapping(gvk.GroupKind(), gvk.Version); err == nil && m.Scope.Name() == meta.RESTScopeNameNamespace { - kind = "ClusterProviderConfig" - } - } - - mapping := providerConfigMapping(rmapper, desired.GroupVersionKind().Group, kind) - if mapping == nil { - return nil - } - - key := client.ObjectKey{Name: name} - if mapping.Scope.Name() == meta.RESTScopeNameNamespace { - key.Namespace = desired.GetNamespace() - if key.Namespace == "" { - key.Namespace = defaultNamespace - } - } - - ck := objectStoreKey{gvk: mapping.GroupVersionKind, key: key} - if store, ok := c.cache[ck]; ok { - return store - } - - var store []*structpb.Struct - pc := &unstructured.Unstructured{} - pc.SetGroupVersionKind(mapping.GroupVersionKind) - if err := kubeClient.Get(ctx, key, pc); err == nil { - if s, err := structpb.NewStruct(pc.Object); err == nil { - store = []*structpb.Struct{s} - } - } - c.cache[ck] = store - return store -} - -// providerConfigMapping returns the REST mapping for the ProviderConfig kind -// the given resource group's provider serves, or nil when no group serves it. -// -// The ProviderConfig's group relates to the resource's group, but not -// uniformly: most providers serve it one level up (s3.aws.upbound.io → -// aws.upbound.io), others in the resource's own group -// (kubernetes.crossplane.io). Probe from the resource's group toward the root -// and take the first group that serves the kind. -func providerConfigMapping(rmapper meta.RESTMapper, group, kind string) *meta.RESTMapping { - for { - if m, err := rmapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind}); err == nil { - return m - } - i := strings.Index(group, ".") - if i < 0 { - return nil - } - group = group[i+1:] - } -} - -// relevantProviderImages returns the package images of exactly the providers -// that serve the given resources, so simulate never starts a diff server it -// will not route to. Discovery follows the ownership chain on the cluster: -// each resource's kind maps to a CRD, the CRD is controlled by the active -// ProviderRevision, and the revision carries the resolved package image. -// Kinds that resolve to no provider are skipped; their resources degrade to -// the approximate client-side diff. -func relevantProviderImages(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, resources []*unstructured.Unstructured, preferOriginal bool) ([]string, error) { - seenGK := map[schema.GroupKind]bool{} - seenImage := map[string]bool{} - var images []string - - for _, r := range resources { - gvk := r.GroupVersionKind() - gk := gvk.GroupKind() - if seenGK[gk] { - continue - } - seenGK[gk] = true - - mapping, err := rmapper.RESTMapping(gk, gvk.Version) - if err != nil { - continue // The kind is not installed; nothing serves it. - } - - crd := &unstructured.Unstructured{} - crd.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) - crdName := mapping.Resource.Resource + "." + gk.Group - if err := kubeClient.Get(ctx, client.ObjectKey{Name: crdName}, crd); err != nil { - if apierrors.IsNotFound(err) { - continue - } - return nil, errors.Wrapf(err, "cannot get CRD %s", crdName) - } - - // After an upgrade every revision of the provider still owns the CRD; - // only the active revision holds the controller reference, and that is - // the one serving the resources. - revision := "" - for _, ref := range crd.GetOwnerReferences() { - if ref.Kind == "ProviderRevision" && strings.HasPrefix(ref.APIVersion, "pkg.crossplane.io/") && ref.Controller != nil && *ref.Controller { - revision = ref.Name - break - } - } - if revision == "" { - continue // Not provider-installed (e.g. a hand-applied CRD). - } - - prv := &pkgv1.ProviderRevision{} - if err := kubeClient.Get(ctx, client.ObjectKey{Name: revision}, prv); err != nil { - return nil, errors.Wrapf(err, "cannot get provider revision %s owning CRD %s", revision, crdName) - } - // Prefer the resolved package: after the cluster's ImageConfig - // rewriting, it is the identity of the artifact the cluster actually - // runs, which is what the preview promises to use. If that reference - // is not pullable from this machine (e.g. a cluster-only mirror), the - // manager reports the image as skipped, its resources degrade to the - // approximate diff, and --provider-images remains the override for - // picking a locally reachable image instead. With local ImageConfigs - // in play (preferOriginal) the original reference is handed out - // instead, because that is what local rewrite rules are written - // against. - img := prv.Status.ResolvedPackage - if preferOriginal || img == "" { - img = prv.Spec.Package - } - if img != "" && !seenImage[img] { - seenImage[img] = true - images = append(images, img) - } - } - return images, nil -} - func (c *Cmd) setupKubeClient(logger logging.Logger) (client.Client, meta.RESTMapper, string, error) { kubeconfig, clientconfig, err := kube.RESTConfig(c.Context, c.Impersonation) if err != nil { diff --git a/cmd/crossplane/simulate/simulate_test.go b/cmd/crossplane/simulate/simulate_test.go index 2c5618ae..6a48c7bc 100644 --- a/cmd/crossplane/simulate/simulate_test.go +++ b/cmd/crossplane/simulate/simulate_test.go @@ -17,86 +17,17 @@ limitations under the License. package simulate import ( - "context" "net/netip" "os" "path/filepath" "strings" "testing" - "time" - diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" "github.com/google/go-cmp/cmp" - "google.golang.org/genproto/googleapis/rpc/errdetails" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" - "google.golang.org/protobuf/types/known/structpb" - "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/client/fake" - "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - - "github.com/crossplane/crossplane/apis/v2/pkg" - pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" "github.com/crossplane/cli/v2/internal/diffserver" - "github.com/crossplane/cli/v2/internal/plan" ) -var vpcGVK = schema.GroupVersionKind{Group: "ec2.aws.upbound.io", Version: "v1beta1", Kind: "VPC"} - -func fieldValue(v string) *diffv1alpha1.FieldValue { - return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Value{Value: structpb.NewStringValue(v)}} -} - -func vpc(name, cidr string) *unstructured.Unstructured { - return &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "ec2.aws.upbound.io/v1beta1", - "kind": "VPC", - "metadata": map[string]any{"name": name}, - "spec": map[string]any{"forProvider": map[string]any{"cidrBlock": cidr}}, - }} -} - -// fakeRouter routes every resource to the given client, or nowhere. -type fakeRouter struct { - client diffv1alpha1.PlanServiceClient -} - -func (f *fakeRouter) Route(schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { - if f.client == nil { - return nil, false - } - return f.client, true -} - -// fakePlanClient answers Plan in-process. -type fakePlanClient struct { - rsp *diffv1alpha1.PlanResponse - err error - got *diffv1alpha1.PlanRequest -} - -func (f *fakePlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { - f.got = in - return f.rsp, f.err -} - -func notSupportedErr(t *testing.T) error { - t.Helper() - st, err := status.New(codes.FailedPrecondition, "cannot compute diff").WithDetails(&errdetails.PreconditionFailure{ - Violations: []*errdetails.PreconditionFailure_Violation{{Type: "DIFF_COMPUTATION_NOT_SUPPORTED"}}, - }) - if err != nil { - t.Fatalf("cannot build status: %v", err) - } - return st.Err() -} - func TestLoadProviderInputs(t *testing.T) { const ec2 = "xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0" @@ -296,334 +227,3 @@ func TestNeedsDocker(t *testing.T) { }) } } - -func TestSimulateOne(t *testing.T) { - ctx := context.Background() - - s := runtime.NewScheme() - s.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) - s.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) - - rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) - rm.Add(vpcGVK, meta.RESTScopeRoot) - - cmd := &Cmd{Timeout: time.Second} - - t.Run("RoutedUpdate", func(t *testing.T) { - live := vpc("my-vpc", "10.0.0.0/16") - kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() - pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{ - Action: diffv1alpha1.Action_ACTION_UPDATE, - Changes: []*diffv1alpha1.FieldChange{{ - Field: "spec.forProvider.cidrBlock", - Actual: fieldValue("10.0.0.0/16"), - Planned: fieldValue("10.1.0.0/16"), - }}, - }} - - got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16")) - - if got.Action != plan.ActionUpdate || got.Approximate || got.Error != "" { - t.Errorf("simulateOne(...): want a provider-computed update, got %+v", got) - } - if pc.got.GetActualResource() == nil { - t.Errorf("simulateOne(...): want the live resource sent as actual state") - } - }) - - t.Run("NoLiveCounterpartPlansCreate", func(t *testing.T) { - kube := fake.NewClientBuilder().WithScheme(s).Build() - pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} - - got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("new-vpc", "10.0.0.0/16")) - - if got.Action != plan.ActionCreate || got.Error != "" { - t.Errorf("simulateOne(...): want a create, got %+v", got) - } - if pc.got.GetActualResource() != nil { - t.Errorf("simulateOne(...): want no actual state for a missing live resource") - } - }) - - t.Run("NoRouteFallsBack", func(t *testing.T) { - live := vpc("my-vpc", "10.0.0.0/16") - kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() - - got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16")) - - if !got.Approximate || got.Action != plan.ActionUpdate { - t.Errorf("simulateOne(...): want an approximate update fallback, got %+v", got) - } - }) - - t.Run("NotSupportedFallsBack", func(t *testing.T) { - live := vpc("my-vpc", "10.0.0.0/16") - kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() - pc := &fakePlanClient{err: notSupportedErr(t)} - - got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16")) - - if !got.Approximate || got.Action != plan.ActionUpdate { - t.Errorf("simulateOne(...): want an approximate fallback on DIFF_COMPUTATION_NOT_SUPPORTED, got %+v", got) - } - }) - - t.Run("FallbackAppliesCRDDefaults", func(t *testing.T) { - // The live resource carries fields the API server defaulted on - // admission (deletionPolicy, managementPolicies) that the manifest - // omits. Applying the manifest would default them right back, so the - // fallback must not plan them as removals. - crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} - - defScheme := runtime.NewScheme() - defScheme.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) - defScheme.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) - defScheme.AddKnownTypeWithName(crdGVK, &unstructured.Unstructured{}) - defScheme.AddKnownTypeWithName(crdGVK.GroupVersion().WithKind("CustomResourceDefinitionList"), &unstructured.UnstructuredList{}) - - crd := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": map[string]any{"name": "vpcs.ec2.aws.upbound.io"}, - "spec": map[string]any{ - "group": "ec2.aws.upbound.io", - "names": map[string]any{"kind": "VPC", "listKind": "VPCList", "plural": "vpcs", "singular": "vpc"}, - "scope": "Cluster", - "versions": []any{map[string]any{ - "name": "v1beta1", - "served": true, "storage": true, - "schema": map[string]any{"openAPIV3Schema": map[string]any{ - "type": "object", - "properties": map[string]any{ - "spec": map[string]any{ - "type": "object", - "properties": map[string]any{ - "deletionPolicy": map[string]any{"type": "string", "default": "Delete"}, - "managementPolicies": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "default": []any{"*"}}, - "forProvider": map[string]any{"type": "object", "properties": map[string]any{"cidrBlock": map[string]any{"type": "string"}}}, - }, - }, - }, - }}, - }}, - }, - }} - - live := vpc("my-vpc", "10.0.0.0/16") - _ = unstructured.SetNestedField(live.Object, "Delete", "spec", "deletionPolicy") - _ = unstructured.SetNestedStringSlice(live.Object, []string{"*"}, "spec", "managementPolicies") - - crdGets := 0 - kube := fake.NewClientBuilder().WithScheme(defScheme).WithObjects(live, crd). - WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - if obj.GetObjectKind().GroupVersionKind().Kind == "CustomResourceDefinition" { - crdGets++ - } - return c.Get(ctx, key, obj, opts...) - }}).Build() - - crds := newCRDDefaults() - got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), crds, vpc("my-vpc", "10.1.0.0/16")) - - if !got.Approximate || got.Action != plan.ActionUpdate { - t.Fatalf("simulateOne(...): want an approximate update fallback, got %+v", got) - } - want := []plan.FieldChange{{ - Path: "spec.forProvider.cidrBlock", - Actual: &plan.Value{Value: "10.0.0.0/16"}, - Planned: &plan.Value{Value: "10.1.0.0/16"}, - }} - if diff := cmp.Diff(want, got.Changes); diff != "" { - t.Errorf("simulateOne(...): want only the real change, no removals of defaulted fields, -want, +got:\n%s", diff) - } - - // An identical manifest plans as a no-op, and the CRD is served from - // the cache rather than fetched again. - if again := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{}, newObjectStoreCache(), crds, vpc("my-vpc", "10.0.0.0/16")); again.Action != plan.ActionNoOp { - t.Errorf("simulateOne(...): want a no-op for a manifest omitting only defaulted fields, got %+v", again) - } - if crdGets != 1 { - t.Errorf("simulateOne(...): want 1 CRD fetch for 2 resources of the same kind, got %d", crdGets) - } - }) - - t.Run("ServerErrorBecomesResourceError", func(t *testing.T) { - kube := fake.NewClientBuilder().WithScheme(s).Build() - pc := &fakePlanClient{err: status.Error(codes.Internal, "boom")} - - got := cmd.simulateOne(ctx, kube, rm, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.1.0.0/16")) - - if got.Error == "" { - t.Errorf("simulateOne(...): want a per-resource error, got %+v", got) - } - }) - - t.Run("SeedsProviderConfigIntoObjectStore", func(t *testing.T) { - pcGVK := schema.GroupVersionKind{Group: "aws.upbound.io", Version: "v1beta1", Kind: "ProviderConfig"} - secretGVK := schema.GroupVersionKind{Version: "v1", Kind: "Secret"} - - seedScheme := runtime.NewScheme() - seedScheme.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) - seedScheme.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) - seedScheme.AddKnownTypeWithName(pcGVK, &unstructured.Unstructured{}) - seedScheme.AddKnownTypeWithName(pcGVK.GroupVersion().WithKind("ProviderConfigList"), &unstructured.UnstructuredList{}) - seedScheme.AddKnownTypeWithName(secretGVK, &unstructured.Unstructured{}) - seedScheme.AddKnownTypeWithName(secretGVK.GroupVersion().WithKind("SecretList"), &unstructured.UnstructuredList{}) - - seedRM := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion(), pcGVK.GroupVersion()}) - seedRM.Add(vpcGVK, meta.RESTScopeRoot) - seedRM.Add(pcGVK, meta.RESTScopeRoot) - - pc := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "aws.upbound.io/v1beta1", - "kind": "ProviderConfig", - "metadata": map[string]any{"name": "default"}, - "spec": map[string]any{"credentials": map[string]any{ - "source": "Secret", - "secretRef": map[string]any{"name": "aws-creds", "namespace": "crossplane-system", "key": "creds"}, - }}, - }} - secret := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "v1", - "kind": "Secret", - "metadata": map[string]any{"name": "aws-creds", "namespace": "crossplane-system"}, - }} - - kube := fake.NewClientBuilder().WithScheme(seedScheme).WithObjects(pc, secret).Build() - pc2 := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} - - got := cmd.simulateOne(ctx, kube, seedRM, "default", &fakeRouter{client: pc2}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.0.0.0/16")) - - if got.Error != "" { - t.Fatalf("simulateOne(...): unexpected error: %s", got.Error) - } - if n := len(pc2.got.GetKubernetesObjectStore()); n != 1 { - t.Errorf("simulateOne(...): want the ProviderConfig in the object store, got %d objects", n) - } - if k := pc2.got.GetKubernetesObjectStore()[0].GetFields()["kind"].GetStringValue(); k != "ProviderConfig" { - t.Errorf("simulateOne(...): want a ProviderConfig in the object store, got %q", k) - } - }) - - t.Run("FindsProviderConfigInResourceGroup", func(t *testing.T) { - // provider-kubernetes serves its ProviderConfig in the same group as - // its resources, not one level up like upjet-based providers. - objGVK := schema.GroupVersionKind{Group: "kubernetes.crossplane.io", Version: "v1alpha2", Kind: "Object"} - pcGVK := schema.GroupVersionKind{Group: "kubernetes.crossplane.io", Version: "v1alpha1", Kind: "ProviderConfig"} - - scheme := runtime.NewScheme() - scheme.AddKnownTypeWithName(objGVK, &unstructured.Unstructured{}) - scheme.AddKnownTypeWithName(objGVK.GroupVersion().WithKind("ObjectList"), &unstructured.UnstructuredList{}) - scheme.AddKnownTypeWithName(pcGVK, &unstructured.Unstructured{}) - scheme.AddKnownTypeWithName(pcGVK.GroupVersion().WithKind("ProviderConfigList"), &unstructured.UnstructuredList{}) - - mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{objGVK.GroupVersion(), pcGVK.GroupVersion()}) - mapper.Add(objGVK, meta.RESTScopeRoot) - mapper.Add(pcGVK, meta.RESTScopeRoot) - - pc := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "kubernetes.crossplane.io/v1alpha1", - "kind": "ProviderConfig", - "metadata": map[string]any{"name": "default"}, - }} - desired := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "kubernetes.crossplane.io/v1alpha2", - "kind": "Object", - "metadata": map[string]any{"name": "my-object"}, - }} - - gets := 0 - kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pc). - WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - gets++ - return c.Get(ctx, key, obj, opts...) - }}).Build() - - stores := newObjectStoreCache() - got := stores.gather(ctx, kube, mapper, "default", desired, nil) - - if len(got) != 1 || got[0].GetFields()["kind"].GetStringValue() != "ProviderConfig" { - t.Errorf("gather(...): want the same-group ProviderConfig, got %v", got) - } - - // A second resource referencing the same ProviderConfig is served from - // the cache without another fetch. - if again := stores.gather(ctx, kube, mapper, "default", desired, nil); len(again) != 1 { - t.Errorf("gather(...): want the cached ProviderConfig, got %v", again) - } - if gets != 1 { - t.Errorf("gather(...): want 1 fetch for 2 gathers of the same ProviderConfig, got %d", gets) - } - }) - - t.Run("DiscoversOnlyRelevantProviders", func(t *testing.T) { - crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} - - discScheme := runtime.NewScheme() - discScheme.AddKnownTypeWithName(crdGVK, &unstructured.Unstructured{}) - discScheme.AddKnownTypeWithName(crdGVK.GroupVersion().WithKind("CustomResourceDefinitionList"), &unstructured.UnstructuredList{}) - if err := pkg.AddToScheme(discScheme); err != nil { - t.Fatalf("cannot add pkg scheme: %v", err) - } - - // After an upgrade the inactive old revision still owns the CRD; only - // the active revision is its controller, and discovery must follow - // the controller reference. - crd := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "apiextensions.k8s.io/v1", - "kind": "CustomResourceDefinition", - "metadata": map[string]any{ - "name": "vpcs.ec2.aws.upbound.io", - "ownerReferences": []any{ - map[string]any{ - "apiVersion": "pkg.crossplane.io/v1", - "kind": "ProviderRevision", - "name": "provider-aws-ec2-old999", - "uid": "00000000-0000-0000-0000-000000000001", - }, - map[string]any{ - "apiVersion": "pkg.crossplane.io/v1", - "kind": "ProviderRevision", - "name": "provider-aws-ec2-abc123", - "uid": "00000000-0000-0000-0000-000000000000", - "controller": true, - }, - }, - }, - }} - prv := &pkgv1.ProviderRevision{} - prv.SetName("provider-aws-ec2-abc123") - prv.Status.ResolvedPackage = "xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0" - - kube := fake.NewClientBuilder().WithScheme(discScheme).WithObjects(crd, prv).Build() - - // Two VPCs (same kind) plus a kind with no CRD on the cluster: exactly - // one provider image, once. - unserved := &unstructured.Unstructured{Object: map[string]any{ - "apiVersion": "kms.aws.upbound.io/v1beta1", - "kind": "Key", - "metadata": map[string]any{"name": "k"}, - }} - got, err := relevantProviderImages(ctx, kube, rm, []*unstructured.Unstructured{vpc("a", "10.0.0.0/16"), vpc("b", "10.1.0.0/16"), unserved}, false) - if err != nil { - t.Fatalf("relevantProviderImages(...): unexpected error: %v", err) - } - want := []string{"xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0"} - if len(got) != 1 || got[0] != want[0] { - t.Errorf("relevantProviderImages(...): want %v, got %v", want, got) - } - }) - - t.Run("UnservedKindPlansCreate", func(t *testing.T) { - // A kind the cluster does not serve: no RESTMapping, no live fetch. - kube := fake.NewClientBuilder().WithScheme(s).Build() - emptyRM := meta.NewDefaultRESTMapper(nil) - pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} - - got := cmd.simulateOne(ctx, kube, emptyRM, "default", &fakeRouter{client: pc}, newObjectStoreCache(), newCRDDefaults(), vpc("my-vpc", "10.0.0.0/16")) - - if got.Action != plan.ActionCreate || got.Error != "" { - t.Errorf("simulateOne(...): want a create for an unserved kind, got %+v", got) - } - }) -} diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 34793cfa..5b4a358d 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -164,7 +164,7 @@ Applying this leaves the resource unsynced until it is replaced manually. var buf bytes.Buffer r, _ := NewRenderer(FormatPlan) - if err := r.Render(&buf, plans); err != nil { + if err := r.Render(&buf, NewResult(plans)); err != nil { t.Fatalf("Render(plan): unexpected error: %v", err) } if diff := cmp.Diff(want, buf.String()); diff != "" { @@ -174,7 +174,7 @@ Applying this leaves the resource unsynced until it is replaced manually. // With no replace path reported anywhere, the warning says the provider // did not name the forcing field rather than listing an empty set. var buf2 bytes.Buffer - if err := r.Render(&buf2, plans[1:]); err != nil { + if err := r.Render(&buf2, NewResult(plans[1:])); err != nil { t.Fatalf("Render(plan): unexpected error: %v", err) } wantWarning := "Warning: 1 resource requires replacement, but the provider did not report which field forces it.\n" diff --git a/cmd/crossplane/simulate/defaults.go b/internal/simulate/defaults.go similarity index 100% rename from cmd/crossplane/simulate/defaults.go rename to internal/simulate/defaults.go diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go new file mode 100644 index 00000000..105faef0 --- /dev/null +++ b/internal/simulate/simulate.go @@ -0,0 +1,341 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package simulate plans managed resources against the providers' own diff +// servers, one resource at a time, falling back to an approximate client-side +// diff when no server can plan a resource. +package simulate + +import ( + "context" + "fmt" + "strings" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/protobuf/types/known/structpb" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/plan" +) + +// Router routes a resource's group and kind to a diff server client. It is +// the slice of diffserver.Manager the per-resource flow needs, extracted so +// tests can fake routing. +type Router interface { + Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) +} + +// A Planner plans desired resources one at a time against the diff servers +// its Router routes to. It caches the object stores it gathers, so reuse one +// Planner across a run. +type Planner struct { + kube client.Client + rmapper meta.RESTMapper + defaultNamespace string + router Router + timeout time.Duration + stores *objectStoreCache + crds *crdDefaults +} + +// NewPlanner returns a Planner that reads live state with kubeClient and +// rmapper, resolves unset namespaces to defaultNamespace, and bounds each +// diff server plan by timeout. +func NewPlanner(kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, r Router, timeout time.Duration) *Planner { + return &Planner{ + kube: kubeClient, + rmapper: rmapper, + defaultNamespace: defaultNamespace, + router: r, + timeout: timeout, + stores: newObjectStoreCache(), + crds: newCRDDefaults(), + } +} + +// Plan plans a single resource: fetch its live counterpart, then plan it +// with PlanWithLive. Failures become per-resource errors so one resource +// never fails the whole run. +func (p *Planner) Plan(ctx context.Context, desired *unstructured.Unstructured) *plan.ResourcePlan { + if desired.GetName() == "" { + rp := plan.NewResourcePlan(desired) + rp.Error = "resource has no name" + return rp + } + + live, err := p.fetchLive(ctx, desired) + if err != nil { + rp := plan.NewResourcePlan(desired) + rp.Error = fmt.Sprintf("cannot fetch live resource: %s", err) + return rp + } + return p.PlanWithLive(ctx, desired, live) +} + +// PlanWithLive plans a single resource against an already-fetched live +// counterpart, or nil when none exists: route it to a diff server, and fall +// back to the approximate client-side diff when no server can plan it. +// Failures become per-resource errors so one resource never fails the whole +// run. +func (p *Planner) PlanWithLive(ctx context.Context, desired, live *unstructured.Unstructured) *plan.ResourcePlan { + // Crossplane's external tags survive every apply (the provider's + // initializer re-adds them), so a preview must never claim they would be + // removed. + plan.MergeExternalTags(desired, live) + + gk := desired.GroupVersionKind().GroupKind() + planClient, ok := p.router.Route(gk) + if !ok { + // The fallback diffs desired against live verbatim, but live was + // defaulted by the API server on admission and applying the manifest + // would default it again. Apply the CRD's defaults to desired first, + // so defaulted fields the manifest omits do not plan as removals. + return plan.Fallback(p.crds.apply(ctx, p.kube, p.rmapper, desired), live, fmt.Sprintf("no diff server for %s", gk)) + } + + pctx, cancel := context.WithTimeout(ctx, p.timeout) + defer cancel() + + store := p.stores.gather(ctx, p.kube, p.rmapper, p.defaultNamespace, desired, live) + + rp, err := plan.Plan(pctx, planClient, desired, live, store) + if err != nil { + if plan.IsNotSupported(err) { + return plan.Fallback(p.crds.apply(ctx, p.kube, p.rmapper, desired), live, "the provider cannot compute a diff for this resource") + } + rp := plan.NewResourcePlan(desired) + rp.Error = fmt.Sprintf("diff server cannot plan: %s", err) + return rp + } + return rp +} + +// fetchLive returns the live counterpart of the desired resource, or nil when +// it does not exist (including when its kind is not installed on the +// cluster), in which case the resource plans as a create. +func (p *Planner) fetchLive(ctx context.Context, desired *unstructured.Unstructured) (*unstructured.Unstructured, error) { + gvk := desired.GroupVersionKind() + + mapping, err := p.rmapper.RESTMapping(gvk.GroupKind(), gvk.Version) + if err != nil { + // The kind is not served by the cluster: nothing can exist yet. The + // diff server can still plan the resource as a create. + return nil, nil //nolint:nilerr // Intentional: an unserved kind means no live counterpart, not a failure. + } + + key := client.ObjectKey{Name: desired.GetName()} + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + key.Namespace = desired.GetNamespace() + if key.Namespace == "" { + key.Namespace = p.defaultNamespace + } + } + + live := &unstructured.Unstructured{} + live.SetGroupVersionKind(gvk) + if err := p.kube.Get(ctx, key, live); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil + } + return nil, err + } + return live, nil +} + +// objectStoreCache caches gathered object stores by the resolved +// ProviderConfig identity, so a run over many resources referencing the same +// ProviderConfig fetches and converts it once rather than per resource. +// Missing objects are cached too: a reference the cluster cannot resolve is +// not refetched for every resource that carries it. +type objectStoreCache struct { + cache map[objectStoreKey][]*structpb.Struct +} + +type objectStoreKey struct { + gvk schema.GroupVersionKind + key client.ObjectKey +} + +func newObjectStoreCache() *objectStoreCache { + return &objectStoreCache{cache: map[objectStoreKey][]*structpb.Struct{}} +} + +// gather returns the cluster objects the diff server needs to resolve +// the desired resource: today the ProviderConfig it references. The diff +// server is offline and only sees what travels in the request, so anything it +// must resolve is fetched here, with the user's own kubeconfig and RBAC. The +// ProviderConfig's credentials Secret deliberately stays out: the server runs +// offline with placeholder credentials and never needs the real ones. +// Gathering is best effort: a missing object simply stays out of the store, +// and the server reports precisely what it could not resolve. +func (c *objectStoreCache) gather(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, desired, live *unstructured.Unstructured) []*structpb.Struct { + // Read the reference from the live resource when one exists: the API + // server has already applied the CRD's defaults there, so its kind and + // name are authoritative. The desired manifest may omit both. + src := desired + if live != nil { + src = live + } + name, _, _ := unstructured.NestedString(src.Object, "spec", "providerConfigRef", "name") + if name == "" { + name = "default" + } + kind, _, _ := unstructured.NestedString(src.Object, "spec", "providerConfigRef", "kind") + if kind == "" { + // Legacy cluster-scoped resources have no kind in the reference and + // always mean ProviderConfig. Namespaced resources default to + // ClusterProviderConfig, per the CRD default the API server applies. + kind = "ProviderConfig" + gvk := desired.GroupVersionKind() + if m, err := rmapper.RESTMapping(gvk.GroupKind(), gvk.Version); err == nil && m.Scope.Name() == meta.RESTScopeNameNamespace { + kind = "ClusterProviderConfig" + } + } + + mapping := providerConfigMapping(rmapper, desired.GroupVersionKind().Group, kind) + if mapping == nil { + return nil + } + + key := client.ObjectKey{Name: name} + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + key.Namespace = desired.GetNamespace() + if key.Namespace == "" { + key.Namespace = defaultNamespace + } + } + + ck := objectStoreKey{gvk: mapping.GroupVersionKind, key: key} + if store, ok := c.cache[ck]; ok { + return store + } + + var store []*structpb.Struct + pc := &unstructured.Unstructured{} + pc.SetGroupVersionKind(mapping.GroupVersionKind) + if err := kubeClient.Get(ctx, key, pc); err == nil { + if s, err := structpb.NewStruct(pc.Object); err == nil { + store = []*structpb.Struct{s} + } + } + c.cache[ck] = store + return store +} + +// providerConfigMapping returns the REST mapping for the ProviderConfig kind +// the given resource group's provider serves, or nil when no group serves it. +// +// The ProviderConfig's group relates to the resource's group, but not +// uniformly: most providers serve it one level up (s3.aws.upbound.io → +// aws.upbound.io), others in the resource's own group +// (kubernetes.crossplane.io). Probe from the resource's group toward the root +// and take the first group that serves the kind. +func providerConfigMapping(rmapper meta.RESTMapper, group, kind string) *meta.RESTMapping { + for { + if m, err := rmapper.RESTMapping(schema.GroupKind{Group: group, Kind: kind}); err == nil { + return m + } + i := strings.Index(group, ".") + if i < 0 { + return nil + } + group = group[i+1:] + } +} + +// RelevantProviderImages returns the package images of exactly the providers +// that serve the given resources, so simulate never starts a diff server it +// will not route to. Discovery follows the ownership chain on the cluster: +// each resource's kind maps to a CRD, the CRD is controlled by the active +// ProviderRevision, and the revision carries the resolved package image. +// Kinds that resolve to no provider are skipped; their resources degrade to +// the approximate client-side diff. +func RelevantProviderImages(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, resources []*unstructured.Unstructured, preferOriginal bool) ([]string, error) { + seenGK := map[schema.GroupKind]bool{} + seenImage := map[string]bool{} + var images []string + + for _, r := range resources { + gvk := r.GroupVersionKind() + gk := gvk.GroupKind() + if seenGK[gk] { + continue + } + seenGK[gk] = true + + mapping, err := rmapper.RESTMapping(gk, gvk.Version) + if err != nil { + continue // The kind is not installed; nothing serves it. + } + + crd := &unstructured.Unstructured{} + crd.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) + crdName := mapping.Resource.Resource + "." + gk.Group + if err := kubeClient.Get(ctx, client.ObjectKey{Name: crdName}, crd); err != nil { + if apierrors.IsNotFound(err) { + continue + } + return nil, errors.Wrapf(err, "cannot get CRD %s", crdName) + } + + // After an upgrade every revision of the provider still owns the CRD; + // only the active revision holds the controller reference, and that is + // the one serving the resources. + revision := "" + for _, ref := range crd.GetOwnerReferences() { + if ref.Kind == "ProviderRevision" && strings.HasPrefix(ref.APIVersion, "pkg.crossplane.io/") && ref.Controller != nil && *ref.Controller { + revision = ref.Name + break + } + } + if revision == "" { + continue // Not provider-installed (e.g. a hand-applied CRD). + } + + prv := &pkgv1.ProviderRevision{} + if err := kubeClient.Get(ctx, client.ObjectKey{Name: revision}, prv); err != nil { + return nil, errors.Wrapf(err, "cannot get provider revision %s owning CRD %s", revision, crdName) + } + // Prefer the resolved package: after the cluster's ImageConfig + // rewriting, it is the identity of the artifact the cluster actually + // runs, which is what the preview promises to use. If that reference + // is not pullable from this machine (e.g. a cluster-only mirror), the + // manager reports the image as skipped, its resources degrade to the + // approximate diff, and --provider-images remains the override for + // picking a locally reachable image instead. With local ImageConfigs + // in play (preferOriginal) the original reference is handed out + // instead, because that is what local rewrite rules are written + // against. + img := prv.Status.ResolvedPackage + if preferOriginal || img == "" { + img = prv.Spec.Package + } + if img != "" && !seenImage[img] { + seenImage[img] = true + images = append(images, img) + } + } + return images, nil +} diff --git a/internal/simulate/simulate_test.go b/internal/simulate/simulate_test.go new file mode 100644 index 00000000..76dca721 --- /dev/null +++ b/internal/simulate/simulate_test.go @@ -0,0 +1,422 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "context" + "testing" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "github.com/google/go-cmp/cmp" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + + "github.com/crossplane/crossplane/apis/v2/pkg" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/plan" +) + +var vpcGVK = schema.GroupVersionKind{Group: "ec2.aws.upbound.io", Version: "v1beta1", Kind: "VPC"} + +func fieldValue(v string) *diffv1alpha1.FieldValue { + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Value{Value: structpb.NewStringValue(v)}} +} + +func vpc(name, cidr string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "ec2.aws.upbound.io/v1beta1", + "kind": "VPC", + "metadata": map[string]any{"name": name}, + "spec": map[string]any{"forProvider": map[string]any{"cidrBlock": cidr}}, + }} +} + +// fakeRouter routes every resource to the given client, or nowhere. +type fakeRouter struct { + client diffv1alpha1.PlanServiceClient +} + +func (f *fakeRouter) Route(schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + if f.client == nil { + return nil, false + } + return f.client, true +} + +// fakePlanClient answers Plan in-process. +type fakePlanClient struct { + rsp *diffv1alpha1.PlanResponse + err error + got *diffv1alpha1.PlanRequest +} + +func (f *fakePlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + f.got = in + return f.rsp, f.err +} + +func notSupportedErr(t *testing.T) error { + t.Helper() + st, err := status.New(codes.FailedPrecondition, "cannot compute diff").WithDetails(&errdetails.PreconditionFailure{ + Violations: []*errdetails.PreconditionFailure_Violation{{Type: "DIFF_COMPUTATION_NOT_SUPPORTED"}}, + }) + if err != nil { + t.Fatalf("cannot build status: %v", err) + } + return st.Err() +} + +func TestPlanner(t *testing.T) { + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) + rm.Add(vpcGVK, meta.RESTScopeRoot) + + t.Run("RoutedUpdate", func(t *testing.T) { + live := vpc("my-vpc", "10.0.0.0/16") + kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() + pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{ + Action: diffv1alpha1.Action_ACTION_UPDATE, + Changes: []*diffv1alpha1.FieldChange{{ + Field: "spec.forProvider.cidrBlock", + Actual: fieldValue("10.0.0.0/16"), + Planned: fieldValue("10.1.0.0/16"), + }}, + }} + + got := NewPlanner(kube, rm, "default", &fakeRouter{client: pc}, time.Second).Plan(ctx, vpc("my-vpc", "10.1.0.0/16")) + + if got.Action != plan.ActionUpdate || got.Approximate || got.Error != "" { + t.Errorf("Plan(...): want a provider-computed update, got %+v", got) + } + if pc.got.GetActualResource() == nil { + t.Errorf("Plan(...): want the live resource sent as actual state") + } + }) + + t.Run("NoLiveCounterpartPlansCreate", func(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(s).Build() + pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} + + got := NewPlanner(kube, rm, "default", &fakeRouter{client: pc}, time.Second).Plan(ctx, vpc("new-vpc", "10.0.0.0/16")) + + if got.Action != plan.ActionCreate || got.Error != "" { + t.Errorf("Plan(...): want a create, got %+v", got) + } + if pc.got.GetActualResource() != nil { + t.Errorf("Plan(...): want no actual state for a missing live resource") + } + }) + + t.Run("NoRouteFallsBack", func(t *testing.T) { + live := vpc("my-vpc", "10.0.0.0/16") + kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() + + got := NewPlanner(kube, rm, "default", &fakeRouter{}, time.Second).Plan(ctx, vpc("my-vpc", "10.1.0.0/16")) + + if !got.Approximate || got.Action != plan.ActionUpdate { + t.Errorf("Plan(...): want an approximate update fallback, got %+v", got) + } + }) + + t.Run("NotSupportedFallsBack", func(t *testing.T) { + live := vpc("my-vpc", "10.0.0.0/16") + kube := fake.NewClientBuilder().WithScheme(s).WithObjects(live).Build() + pc := &fakePlanClient{err: notSupportedErr(t)} + + got := NewPlanner(kube, rm, "default", &fakeRouter{client: pc}, time.Second).Plan(ctx, vpc("my-vpc", "10.1.0.0/16")) + + if !got.Approximate || got.Action != plan.ActionUpdate { + t.Errorf("Plan(...): want an approximate fallback on DIFF_COMPUTATION_NOT_SUPPORTED, got %+v", got) + } + }) + + t.Run("FallbackAppliesCRDDefaults", func(t *testing.T) { + // The live resource carries fields the API server defaulted on + // admission (deletionPolicy, managementPolicies) that the manifest + // omits. Applying the manifest would default them right back, so the + // fallback must not plan them as removals. + crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} + + defScheme := runtime.NewScheme() + defScheme.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + defScheme.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + defScheme.AddKnownTypeWithName(crdGVK, &unstructured.Unstructured{}) + defScheme.AddKnownTypeWithName(crdGVK.GroupVersion().WithKind("CustomResourceDefinitionList"), &unstructured.UnstructuredList{}) + + crd := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{"name": "vpcs.ec2.aws.upbound.io"}, + "spec": map[string]any{ + "group": "ec2.aws.upbound.io", + "names": map[string]any{"kind": "VPC", "listKind": "VPCList", "plural": "vpcs", "singular": "vpc"}, + "scope": "Cluster", + "versions": []any{map[string]any{ + "name": "v1beta1", + "served": true, "storage": true, + "schema": map[string]any{"openAPIV3Schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "spec": map[string]any{ + "type": "object", + "properties": map[string]any{ + "deletionPolicy": map[string]any{"type": "string", "default": "Delete"}, + "managementPolicies": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "default": []any{"*"}}, + "forProvider": map[string]any{"type": "object", "properties": map[string]any{"cidrBlock": map[string]any{"type": "string"}}}, + }, + }, + }, + }}, + }}, + }, + }} + + live := vpc("my-vpc", "10.0.0.0/16") + _ = unstructured.SetNestedField(live.Object, "Delete", "spec", "deletionPolicy") + _ = unstructured.SetNestedStringSlice(live.Object, []string{"*"}, "spec", "managementPolicies") + + crdGets := 0 + kube := fake.NewClientBuilder().WithScheme(defScheme).WithObjects(live, crd). + WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if obj.GetObjectKind().GroupVersionKind().Kind == "CustomResourceDefinition" { + crdGets++ + } + return c.Get(ctx, key, obj, opts...) + }}).Build() + + planner := NewPlanner(kube, rm, "default", &fakeRouter{}, time.Second) + got := planner.Plan(ctx, vpc("my-vpc", "10.1.0.0/16")) + + if !got.Approximate || got.Action != plan.ActionUpdate { + t.Fatalf("Plan(...): want an approximate update fallback, got %+v", got) + } + want := []plan.FieldChange{{ + Path: "spec.forProvider.cidrBlock", + Actual: &plan.Value{Value: "10.0.0.0/16"}, + Planned: &plan.Value{Value: "10.1.0.0/16"}, + }} + if diff := cmp.Diff(want, got.Changes); diff != "" { + t.Errorf("Plan(...): want only the real change, no removals of defaulted fields, -want, +got:\n%s", diff) + } + + // An identical manifest plans as a no-op, and the CRD is served from + // the cache rather than fetched again. + if again := planner.Plan(ctx, vpc("my-vpc", "10.0.0.0/16")); again.Action != plan.ActionNoOp { + t.Errorf("Plan(...): want a no-op for a manifest omitting only defaulted fields, got %+v", again) + } + if crdGets != 1 { + t.Errorf("Plan(...): want 1 CRD fetch for 2 resources of the same kind, got %d", crdGets) + } + }) + + t.Run("ServerErrorBecomesResourceError", func(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(s).Build() + pc := &fakePlanClient{err: status.Error(codes.Internal, "boom")} + + got := NewPlanner(kube, rm, "default", &fakeRouter{client: pc}, time.Second).Plan(ctx, vpc("my-vpc", "10.1.0.0/16")) + + if got.Error == "" { + t.Errorf("Plan(...): want a per-resource error, got %+v", got) + } + }) + + t.Run("SeedsProviderConfigIntoObjectStore", func(t *testing.T) { + pcGVK := schema.GroupVersionKind{Group: "aws.upbound.io", Version: "v1beta1", Kind: "ProviderConfig"} + secretGVK := schema.GroupVersionKind{Version: "v1", Kind: "Secret"} + + seedScheme := runtime.NewScheme() + seedScheme.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + seedScheme.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + seedScheme.AddKnownTypeWithName(pcGVK, &unstructured.Unstructured{}) + seedScheme.AddKnownTypeWithName(pcGVK.GroupVersion().WithKind("ProviderConfigList"), &unstructured.UnstructuredList{}) + seedScheme.AddKnownTypeWithName(secretGVK, &unstructured.Unstructured{}) + seedScheme.AddKnownTypeWithName(secretGVK.GroupVersion().WithKind("SecretList"), &unstructured.UnstructuredList{}) + + seedRM := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion(), pcGVK.GroupVersion()}) + seedRM.Add(vpcGVK, meta.RESTScopeRoot) + seedRM.Add(pcGVK, meta.RESTScopeRoot) + + pc := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "aws.upbound.io/v1beta1", + "kind": "ProviderConfig", + "metadata": map[string]any{"name": "default"}, + "spec": map[string]any{"credentials": map[string]any{ + "source": "Secret", + "secretRef": map[string]any{"name": "aws-creds", "namespace": "crossplane-system", "key": "creds"}, + }}, + }} + secret := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{"name": "aws-creds", "namespace": "crossplane-system"}, + }} + + kube := fake.NewClientBuilder().WithScheme(seedScheme).WithObjects(pc, secret).Build() + pc2 := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} + + got := NewPlanner(kube, seedRM, "default", &fakeRouter{client: pc2}, time.Second).Plan(ctx, vpc("my-vpc", "10.0.0.0/16")) + + if got.Error != "" { + t.Fatalf("Plan(...): unexpected error: %s", got.Error) + } + if n := len(pc2.got.GetKubernetesObjectStore()); n != 1 { + t.Errorf("Plan(...): want the ProviderConfig in the object store, got %d objects", n) + } + if k := pc2.got.GetKubernetesObjectStore()[0].GetFields()["kind"].GetStringValue(); k != "ProviderConfig" { + t.Errorf("Plan(...): want a ProviderConfig in the object store, got %q", k) + } + }) + + t.Run("FindsProviderConfigInResourceGroup", func(t *testing.T) { + // provider-kubernetes serves its ProviderConfig in the same group as + // its resources, not one level up like upjet-based providers. + objGVK := schema.GroupVersionKind{Group: "kubernetes.crossplane.io", Version: "v1alpha2", Kind: "Object"} + pcGVK := schema.GroupVersionKind{Group: "kubernetes.crossplane.io", Version: "v1alpha1", Kind: "ProviderConfig"} + + scheme := runtime.NewScheme() + scheme.AddKnownTypeWithName(objGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(objGVK.GroupVersion().WithKind("ObjectList"), &unstructured.UnstructuredList{}) + scheme.AddKnownTypeWithName(pcGVK, &unstructured.Unstructured{}) + scheme.AddKnownTypeWithName(pcGVK.GroupVersion().WithKind("ProviderConfigList"), &unstructured.UnstructuredList{}) + + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{objGVK.GroupVersion(), pcGVK.GroupVersion()}) + mapper.Add(objGVK, meta.RESTScopeRoot) + mapper.Add(pcGVK, meta.RESTScopeRoot) + + pc := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "kubernetes.crossplane.io/v1alpha1", + "kind": "ProviderConfig", + "metadata": map[string]any{"name": "default"}, + }} + desired := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "kubernetes.crossplane.io/v1alpha2", + "kind": "Object", + "metadata": map[string]any{"name": "my-object"}, + }} + + gets := 0 + kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(pc). + WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + gets++ + return c.Get(ctx, key, obj, opts...) + }}).Build() + + stores := newObjectStoreCache() + got := stores.gather(ctx, kube, mapper, "default", desired, nil) + + if len(got) != 1 || got[0].GetFields()["kind"].GetStringValue() != "ProviderConfig" { + t.Errorf("gather(...): want the same-group ProviderConfig, got %v", got) + } + + // A second resource referencing the same ProviderConfig is served from + // the cache without another fetch. + if again := stores.gather(ctx, kube, mapper, "default", desired, nil); len(again) != 1 { + t.Errorf("gather(...): want the cached ProviderConfig, got %v", again) + } + if gets != 1 { + t.Errorf("gather(...): want 1 fetch for 2 gathers of the same ProviderConfig, got %d", gets) + } + }) + + t.Run("DiscoversOnlyRelevantProviders", func(t *testing.T) { + crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} + + discScheme := runtime.NewScheme() + discScheme.AddKnownTypeWithName(crdGVK, &unstructured.Unstructured{}) + discScheme.AddKnownTypeWithName(crdGVK.GroupVersion().WithKind("CustomResourceDefinitionList"), &unstructured.UnstructuredList{}) + if err := pkg.AddToScheme(discScheme); err != nil { + t.Fatalf("cannot add pkg scheme: %v", err) + } + + // After an upgrade the inactive old revision still owns the CRD; only + // the active revision is its controller, and discovery must follow + // the controller reference. + crd := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{ + "name": "vpcs.ec2.aws.upbound.io", + "ownerReferences": []any{ + map[string]any{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "ProviderRevision", + "name": "provider-aws-ec2-old999", + "uid": "00000000-0000-0000-0000-000000000001", + }, + map[string]any{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "ProviderRevision", + "name": "provider-aws-ec2-abc123", + "uid": "00000000-0000-0000-0000-000000000000", + "controller": true, + }, + }, + }, + }} + prv := &pkgv1.ProviderRevision{} + prv.SetName("provider-aws-ec2-abc123") + prv.Status.ResolvedPackage = "xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0" + + kube := fake.NewClientBuilder().WithScheme(discScheme).WithObjects(crd, prv).Build() + + // Two VPCs (same kind) plus a kind with no CRD on the cluster: exactly + // one provider image, once. + unserved := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "kms.aws.upbound.io/v1beta1", + "kind": "Key", + "metadata": map[string]any{"name": "k"}, + }} + got, err := RelevantProviderImages(ctx, kube, rm, []*unstructured.Unstructured{vpc("a", "10.0.0.0/16"), vpc("b", "10.1.0.0/16"), unserved}, false) + if err != nil { + t.Fatalf("RelevantProviderImages(...): unexpected error: %v", err) + } + want := []string{"xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0"} + if len(got) != 1 || got[0] != want[0] { + t.Errorf("RelevantProviderImages(...): want %v, got %v", want, got) + } + }) + + t.Run("UnservedKindPlansCreate", func(t *testing.T) { + // A kind the cluster does not serve: no RESTMapping, no live fetch. + kube := fake.NewClientBuilder().WithScheme(s).Build() + emptyRM := meta.NewDefaultRESTMapper(nil) + pc := &fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}} + + got := NewPlanner(kube, emptyRM, "default", &fakeRouter{client: pc}, time.Second).Plan(ctx, vpc("my-vpc", "10.0.0.0/16")) + + if got.Action != plan.ActionCreate || got.Error != "" { + t.Errorf("Plan(...): want a create for an unserved kind, got %+v", got) + } + }) +} From bfc400f55a1f664bcbd43e110402926565dbe281 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 20:56:23 -0400 Subject: [PATCH 043/126] refactor(diff): processors return reports; the command renders them 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) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 110 ++++++++-- cmd/crossplane/project/simulate_test.go | 165 ++++++++++----- internal/diff/diffprocessor/comp_processor.go | 70 +++---- .../diff/diffprocessor/comp_processor_test.go | 155 +++++++------- internal/diff/diffprocessor/diff_processor.go | 81 +++---- .../diff/diffprocessor/diff_processor_test.go | 160 ++++---------- .../diff/diffprocessor/processor_config.go | 153 +------------- .../diffprocessor/processor_config_test.go | 198 +----------------- internal/diff/integration/integration_test.go | 1 - internal/diff/renderer/structured_renderer.go | 11 + internal/diff/renderer/types/types.go | 25 +++ internal/diff/testutils/mock_builder.go | 25 ++- internal/diff/testutils/mocks.go | 6 +- 13 files changed, 452 insertions(+), 708 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index eff7c9f5..f645223f 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -255,13 +255,24 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin if err := checkComposites(ctx, clients.XpClients.Definition, in.resources); err != nil { return false, err } - hasDiffs, err := xrProc.PerformDiff(ctx, in.resources, clients.XpClients.Composition.FindMatchingComposition) - return hasDiffs, errors.Wrap(err, "cannot simulate composite resources") + report, err := xrProc.PerformDiff(ctx, in.resources, clients.XpClients.Composition.FindMatchingComposition) + + // Render the report even when there are errors, so successful XRs + // are shown and structured output stays valid. The renderer + // includes the errors in structured output. + if report != nil { + diffRenderer := newDiffRenderer(logger, c.diffOptions(k)) + if renderErr := diffRenderer.RenderDiffs(report.Groups, report.Errors, report.Warnings); renderErr != nil { + logger.Debug("Failed to render diffs", "error", renderErr) + err = withRenderError(err, errors.Wrap(renderErr, "failed to render diffs")) + } + } + return report != nil && report.HasChanges(), errors.Wrap(err, "cannot simulate composite resources") }) } proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) - return c.simulate(ctx, logger, status, clients.Initialize, proc, c.diffCompositions(proc, comps, in.mode)) + return c.simulate(ctx, logger, status, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, in.mode)) } // restConfig returns the cluster to simulate against: the hooks' when set, @@ -305,16 +316,28 @@ func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, s } // diffCompositions returns the diff simulate runs for Compositions: their -// impact on the composite resources in scope. -func (c *simulateCmd) diffCompositions(proc dp.CompDiffProcessor, comps []*un.Unstructured, mode inputMode) func(context.Context) (bool, error) { +// impact on the composite resources in scope, rendered to k's writers. +func (c *simulateCmd) diffCompositions(k *kong.Context, logger logging.Logger, proc dp.CompDiffProcessor, comps []*un.Unstructured, mode inputMode) func(context.Context) (bool, error) { msg := "cannot simulate Compositions" if mode == projectMode { msg = "cannot simulate project" } return func(ctx context.Context) (bool, error) { ns, refs := c.scope() - hasDiffs, err := proc.DiffComposition(ctx, comps, ns, refs) - return hasDiffs, errors.Wrap(err, msg) + output, err := proc.DiffComposition(ctx, comps, ns, refs) + + // Render the report even when there are errors (e.g. every + // composition failed), so structured output stays valid. A render + // failure takes precedence over the processing error. + if output != nil { + opts := c.diffOptions(k) + opts.MinimizeComposition = c.MinimizeComposition + + if renderErr := newCompDiffRenderer(logger, opts).RenderCompDiff(output); renderErr != nil { + err = errors.Wrap(renderErr, "failed to render composition diff") + } + } + return output != nil && output.HasChanges(), errors.Wrap(err, msg) } } @@ -566,18 +589,11 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f opts := []dp.ProcessorOption{ dp.WithLogger(logger), dp.WithAdvisories(dp.NewAdvisoryCollector(k.Stderr)), - dp.WithStdout(k.Stdout), - dp.WithStderr(k.Stderr), - dp.WithColorize(!c.NoColor && (c.Hooks != nil && c.Hooks.Terminal || isTerminal(k.Stdout))), - dp.WithCompact(c.Compact), dp.WithMaxNestedDepth(c.MaxNestedDepth), dp.WithEventualState(c.EventualState), - dp.WithMinimizeComposition(c.MinimizeComposition), dp.WithAnalyzeOn(dp.AnalyzeOn(c.AnalyzeOn)), dp.WithDryRunOn(dp.DryRunOn(c.DryRunOn)), dp.WithIgnorePaths(c.IgnorePaths), - dp.WithOutputFormat(renderer.OutputFormat(c.Output)), - dp.WithOutputVersion(renderer.OutputVersion(c.OutputVersion)), dp.WithIncludeManual(c.IncludePinned), dp.WithMaxRenderIterations(c.MaxIterations), } @@ -610,6 +626,72 @@ func (c *simulateCmd) processorOptions(k *kong.Context, logger logging.Logger, f return opts } +// diffOptions returns the renderer options the command's flags select, +// writing to k's stdout and stderr. The processors return reports; the command +// renders them with these options. Diffs are colorized only when a person +// reads them: on a terminal, or as if on one under the test hooks. +func (c *simulateCmd) diffOptions(k *kong.Context) renderer.DiffOptions { + opts := renderer.DefaultDiffOptions() + opts.UseColors = !c.NoColor && (c.Hooks != nil && c.Hooks.Terminal || isTerminal(k.Stdout)) + opts.Compact = c.Compact + opts.Stdout = k.Stdout + opts.Stderr = k.Stderr + + switch format := renderer.OutputFormat(c.Output); format { + case renderer.OutputFormatJSON, renderer.OutputFormatYAML, renderer.OutputFormatDiff: + opts.Format = format + default: + // Empty string or unrecognized values fall back to the human-readable diff. + opts.Format = renderer.OutputFormatDiff + } + + if c.OutputVersion != "" { + opts.OutputVersion = renderer.OutputVersion(c.OutputVersion) + } + + return opts +} + +// newDiffRenderer returns the renderer for an xr report in the format opts select. +func newDiffRenderer(log logging.Logger, opts renderer.DiffOptions) renderer.DiffRenderer { + switch opts.Format { + case renderer.OutputFormatJSON, renderer.OutputFormatYAML: + return renderer.NewStructuredDiffRenderer(log, opts) + case renderer.OutputFormatDiff: + return renderer.NewDiffRenderer(log, opts) + default: + return renderer.NewDiffRenderer(log, opts) + } +} + +// newCompDiffRenderer returns the renderer for a comp report in the format opts select. +func newCompDiffRenderer(log logging.Logger, opts renderer.DiffOptions) renderer.CompDiffRenderer { + switch opts.Format { + case renderer.OutputFormatJSON, renderer.OutputFormatYAML: + return renderer.NewStructuredCompDiffRenderer(log, opts) + case renderer.OutputFormatDiff: + fallthrough + default: + return renderer.NewDefaultCompDiffRenderer(log, renderer.NewDiffRenderer(log, opts), opts) + } +} + +// withRenderError adds a render failure to a run's error. A joined run error is flattened first, so +// the result reads as one list, the way it did when the processor rendered and joined it itself. +func withRenderError(err, renderErr error) error { + var errs []error + + switch e := err.(type) { //nolint:errorlint // Flattens only the top-level join, not wrapped errors. + case nil: + case interface{ Unwrap() []error }: + errs = e.Unwrap() + default: + errs = []error{err} + } + + return errors.Join(append(errs, renderErr)...) +} + func (c *simulateCmd) newClientAndResolver(extraOpts ...clixpkg.ClientOption) (runtimexpkg.Client, *clixpkg.Resolver, error) { cacheDir := c.CacheDir if cacheDir == "" { diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index a5c31449..a2ff54fd 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -35,6 +35,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" + xperrors "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" @@ -307,20 +308,16 @@ func TestSimulateProcessorOptions(t *testing.T) { stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: stderr}} c := &simulateCmd{ - Output: "yaml", - OutputVersion: "v2", IncludePinned: true, MaxIterations: 7, CrossplaneVersion: "v2.3.4", - Compact: true, MaxNestedDepth: 4, IgnorePaths: []string{"a.b"}, FunctionCredentials: FunctionCredentials{Secrets: []corev1.Secret{{ObjectMeta: metav1.ObjectMeta{Name: "creds"}}}}, FunctionRegistryOverride: "reg.example.com", EventualState: true, MaxRecvMessageSize: 16, - MinimizeComposition: true, AnalyzeOn: "spec-change", DryRunOn: "existing", } @@ -331,71 +328,49 @@ func TestSimulateProcessorOptions(t *testing.T) { } type got struct { - OutputFormat renderer.OutputFormat - OutputVersion renderer.OutputVersion IncludeManual bool MaxRenderIterations int CrossplaneVersion string CrossplaneImage string - Colorize bool - StdoutIsK bool - StderrIsK bool HasAdvisories bool HasFunctionProvider bool - Compact bool MaxNestedDepth int IgnorePaths []string Credentials []corev1.Secret RegistryOverride string EventualState bool MaxRecvMessageSize int - MinimizeComposition bool AnalyzeOn dp.AnalyzeOn DryRunOn dp.DryRunOn } want := got{ - OutputFormat: renderer.OutputFormatYAML, - OutputVersion: renderer.OutputVersionV2, IncludeManual: true, MaxRenderIterations: 7, CrossplaneVersion: "v2.3.4", - // A buffer is not a terminal. - Colorize: false, - StdoutIsK: true, - StderrIsK: true, HasAdvisories: true, HasFunctionProvider: true, - Compact: true, MaxNestedDepth: 4, IgnorePaths: []string{"a.b"}, Credentials: c.FunctionCredentials.Secrets, RegistryOverride: "reg.example.com", EventualState: true, MaxRecvMessageSize: 16, - MinimizeComposition: true, AnalyzeOn: dp.AnalyzeOnSpecChange, DryRunOn: dp.DryRunOnExisting, } g := got{ - OutputFormat: cfg.OutputFormat, - OutputVersion: cfg.OutputVersion, IncludeManual: cfg.IncludeManual, MaxRenderIterations: cfg.MaxRenderIterations, CrossplaneVersion: cfg.CrossplaneVersion, CrossplaneImage: cfg.CrossplaneImage, - Colorize: cfg.Colorize, - StdoutIsK: cfg.Stdout == stdout, - StderrIsK: cfg.Stderr == stderr, HasAdvisories: cfg.Advisories != nil, HasFunctionProvider: cfg.Factories.FunctionProvider != nil, - Compact: cfg.Compact, MaxNestedDepth: cfg.MaxNestedDepth, IgnorePaths: cfg.IgnorePaths, Credentials: cfg.FunctionCredentials, RegistryOverride: cfg.FunctionRegistryOverride, EventualState: cfg.EventualState, MaxRecvMessageSize: cfg.MaxRecvMessageSize, - MinimizeComposition: cfg.MinimizeComposition, AnalyzeOn: cfg.AnalyzeOn, DryRunOn: cfg.DryRunOn, } @@ -413,16 +388,103 @@ func TestSimulateProcessorOptions(t *testing.T) { t.Errorf("processorOptions(..., nil): want no function provider overlay, got one") } - // The test hooks render with a local binary and colorize as for a - // terminal. - c.Hooks = &SimulateHooks{RenderBinary: "/bin/crossplane", Terminal: true} + // The test hooks render with a local binary. + c.Hooks = &SimulateHooks{RenderBinary: "/bin/crossplane"} c.CrossplaneVersion = "" cfg = dp.ProcessorConfig{} for _, o := range c.processorOptions(k, logging.NewNopLogger(), nil) { o(&cfg) } - if cfg.CrossplaneRenderBinary != "/bin/crossplane" || !cfg.Colorize { - t.Errorf("processorOptions(...) with hooks: want render binary %q and colorize, got %q and %t", "/bin/crossplane", cfg.CrossplaneRenderBinary, cfg.Colorize) + if cfg.CrossplaneRenderBinary != "/bin/crossplane" { + t.Errorf("processorOptions(...) with hooks: want render binary %q, got %q", "/bin/crossplane", cfg.CrossplaneRenderBinary) + } +} + +func TestSimulateDiffOptions(t *testing.T) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: stderr}} + + cases := map[string]struct { + reason string + cmd simulateCmd + want func(o *renderer.DiffOptions) + }{ + "UnsetFlagsSelectHumanDiff": { + reason: "Unset flags should select the human-readable diff and the renderer's default output version, uncolorized off a terminal.", + want: func(o *renderer.DiffOptions) { + o.UseColors = false + }, + }, + "StructuredFlagsCarryThrough": { + reason: "The format, version and compact flags should reach the renderer.", + cmd: simulateCmd{Output: "json", OutputVersion: "v2", NoColor: true, Compact: true}, + want: func(o *renderer.DiffOptions) { + o.UseColors = false + o.Compact = true + o.Format = renderer.OutputFormatJSON + o.OutputVersion = renderer.OutputVersionV2 + }, + }, + "UnknownFormatFallsBackToHumanDiff": { + reason: "An unrecognized format should fall back to the human-readable diff.", + cmd: simulateCmd{Output: "xml"}, + want: func(o *renderer.DiffOptions) { + o.UseColors = false + }, + }, + "HooksColorizeAsTerminal": { + reason: "The test hooks should colorize as for a terminal.", + cmd: simulateCmd{Hooks: &SimulateHooks{Terminal: true}}, + want: func(*renderer.DiffOptions) {}, + }, + "NoColorWinsOverHooks": { + reason: "--no-color should disable colors even under the test hooks.", + cmd: simulateCmd{NoColor: true, Hooks: &SimulateHooks{Terminal: true}}, + want: func(o *renderer.DiffOptions) { + o.UseColors = false + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + want := renderer.DefaultDiffOptions() + tc.want(&want) + + got := tc.cmd.diffOptions(k) + // Writers are interfaces holding the kong context's streams; compare them by identity. + if got.Stdout != stdout || got.Stderr != stderr { + t.Errorf("\n%s\ndiffOptions(...) writers: got (%v, %v), want the kong context's", tc.reason, got.Stdout, got.Stderr) + } + + got.Stdout, got.Stderr, want.Stdout, want.Stderr = nil, nil, nil, nil + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("\n%s\ndiffOptions(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// TestWithRenderError pins that a render failure reads exactly as it did when the processor joined it +// with the run's own errors: one flat list, not a list nested in a list. +func TestWithRenderError(t *testing.T) { + a, b, render := errors.New("a"), errors.New("b"), errors.New("failed to render diffs: boom") + + tests := map[string]struct { + err error + want string + }{ + "NoRunError": {err: nil, want: xperrors.Join(render).Error()}, + "SingleRunError": {err: a, want: xperrors.Join(a, render).Error()}, + "JoinedRunError": {err: xperrors.Join(a, b), want: xperrors.Join(a, b, render).Error()}, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := withRenderError(tt.err, render).Error(); got != tt.want { + t.Errorf("withRenderError() = %q, want %q", got, tt.want) + } + }) } } @@ -431,7 +493,7 @@ var _ dp.CompDiffProcessor = &fakeCompProc{} // fakeCompProc is a CompDiffProcessor that records its calls. type fakeCompProc struct { initErr error - diffFn func(ctx context.Context) (bool, error) + diffFn func(ctx context.Context) (*renderer.CompDiffOutput, error) gotNamespace string gotRefs []k8stypes.NamespacedName @@ -441,10 +503,10 @@ type fakeCompProc struct { func (f *fakeCompProc) Initialize(context.Context) error { return f.initErr } -func (f *fakeCompProc) DiffComposition(ctx context.Context, comps []*un.Unstructured, ns string, refs []k8stypes.NamespacedName) (bool, error) { +func (f *fakeCompProc) DiffComposition(ctx context.Context, comps []*un.Unstructured, ns string, refs []k8stypes.NamespacedName) (*renderer.CompDiffOutput, error) { f.gotNamespace, f.gotRefs, f.gotComps = ns, refs, len(comps) if f.diffFn == nil { - return false, nil + return &renderer.CompDiffOutput{}, nil } return f.diffFn(ctx) } @@ -469,6 +531,14 @@ func exitCode(err error, status *common.ExitStatus) int { func TestSimulateRun(t *testing.T) { errBoom := errors.New("boom") noInit := func(context.Context, logging.Logger) error { return nil } + // changed is a report of one Composition with an affected composite + // resource. + changed := func() *renderer.CompDiffOutput { + return &renderer.CompDiffOutput{Compositions: []renderer.CompositionDiff{{ + Name: "comp", + ImpactAnalysis: []renderer.XRImpact{{Status: renderer.XRStatusChanged}}, + }}} + } type args struct { cmd simulateCmd @@ -496,22 +566,22 @@ func TestSimulateRun(t *testing.T) { }, "Changes": { reason: "Changes should exit 3, as a result rather than an error.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { - return true, nil + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + return changed(), nil }}}, want: want{code: dp.ExitCodeDiffDetected, diffed: true}, }, "ErrorWithChanges": { reason: "A tool error should exit 1, not 3, even when there are changes.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { - return true, errBoom + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + return changed(), errBoom }}}, want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, }, "SchemaValidationErrorWithChanges": { reason: "A schema validation failure should exit 2, not 3, even when there are changes.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { - return true, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + return changed(), dp.NewSchemaValidationError("XR/db", "invalid", errBoom) }}}, want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, }, @@ -540,22 +610,22 @@ func TestSimulateRun(t *testing.T) { }, "DiffError": { reason: "A failed simulation is a tool error.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { - return true, errBoom + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + return changed(), errBoom }}}, want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, }, "SchemaValidationError": { reason: "A simulation that failed only schema validation should exit 2.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (bool, error) { - return false, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + return &renderer.CompDiffOutput{}, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) }}}, want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, }, "Interrupted": { reason: "An interrupted run should return the interruption, whatever it returned, which main exits 130 for.", - args: args{cancel: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(ctx context.Context) (bool, error) { - return true, ctx.Err() + args: args{cancel: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(ctx context.Context) (*renderer.CompDiffOutput, error) { + return changed(), ctx.Err() }}}, want: want{code: dp.ExitCodeToolError, err: dp.ErrInterrupted, diffed: true}, }, @@ -569,7 +639,8 @@ func TestSimulateRun(t *testing.T) { cancel() } - diff := tc.args.cmd.diffCompositions(tc.args.proc, []*un.Unstructured{{}}, compositionMode) + k := &kong.Context{Kong: &kong.Kong{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}} + diff := tc.args.cmd.diffCompositions(k, logging.NewNopLogger(), tc.args.proc, []*un.Unstructured{{}}, compositionMode) status := &common.ExitStatus{} err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), status, tc.args.initClients, tc.args.proc, diff) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 7e2d043d..b0ac73eb 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -20,7 +20,6 @@ import ( "context" "fmt" "maps" - "os" "time" corev1 "k8s.io/api/core/v1" @@ -73,13 +72,15 @@ func (r *XRDiffResult) HasError() bool { // CompDiffProcessor defines the interface for composition diffing. type CompDiffProcessor interface { - // DiffComposition processes composition changes and shows impact on existing XRs. - // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. + // DiffComposition processes composition changes and their impact on existing XRs, and returns + // the report for the caller to render. The report is non-nil whenever compositions were + // processed, even with an error, so it can still be rendered. // When `resources` is non-empty, impact analysis is restricted to the named composites: // each ref is resolved against every supplied composition's (XR GVK, claim GVK) pair via a // preflight pass. If any ref is relevant to no supplied composition, the call fails before - // rendering any diffs (CLI input error). When `resources` is empty, behavior is unchanged. - DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (bool, error) + // processing any composition, with a nil report (CLI input error). When `resources` is empty, + // behavior is unchanged. + DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (*renderer.CompDiffOutput, error) Initialize(ctx context.Context) error // Cleaner releases any resources held by the processor (e.g., Docker containers). Cleaner @@ -90,18 +91,14 @@ type DefaultCompDiffProcessor struct { compositionClient xp.CompositionClient config ProcessorConfig xrProc DiffProcessor - compDiffRenderer renderer.CompDiffRenderer } // NewCompDiffProcessor creates a new DefaultCompDiffProcessor. func NewCompDiffProcessor(xrProc DiffProcessor, compositionClient xp.CompositionClient, opts ...ProcessorOption) CompDiffProcessor { // Create default configuration config := ProcessorConfig{ - Colorize: true, - Compact: false, MaxNestedDepth: DefaultMaxNestedDepth, MaxRenderIterations: DefaultMaxRenderIterations, - Stderr: os.Stderr, Logger: logging.NewNopLogger(), } @@ -110,8 +107,9 @@ func NewCompDiffProcessor(xrProc DiffProcessor, compositionClient xp.Composition option(&config) } + // Unset, advisories are only collected into the report; see NewDiffProcessor. if config.Advisories == nil { - config.Advisories = NewAdvisoryCollector(config.Stderr) + config.Advisories = NewAdvisoryCollector(nil) } // NOTE: We intentionally do NOT default config.RenderFunc here. @@ -132,18 +130,10 @@ func NewCompDiffProcessor(xrProc DiffProcessor, compositionClient xp.Composition // Set default factories if not provided config.SetDefaultFactories() - // Create diff renderer first (needed by DefaultCompDiffRenderer for human-readable output) - diffOpts := config.GetDiffOptions() - diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) - - // Create comp diff renderer using factory - compDiffRenderer := config.Factories.CompDiffRenderer(config.Logger, diffRenderer, diffOpts) - return &DefaultCompDiffProcessor{ compositionClient: compositionClient, config: config, xrProc: xrProc, - compDiffRenderer: compDiffRenderer, } } @@ -167,24 +157,25 @@ func (p *DefaultCompDiffProcessor) Cleanup(ctx context.Context) error { return p.xrProc.Cleanup(ctx) } -// DiffComposition processes composition changes and shows impact on existing XRs. -// Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. -func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (bool, error) { +// DiffComposition processes composition changes and their impact on existing XRs, and returns the +// report for the caller to render. The report is non-nil whenever compositions were processed, even +// with an error. +func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (*renderer.CompDiffOutput, error) { p.config.Logger.Debug("Processing composition diff", "compositionCount", len(compositions), "namespace", namespace, "resourceCount", len(resources)) if len(compositions) == 0 { - return false, errors.New("no compositions provided") + return nil, errors.New("no compositions provided") } // When --resource is set, run a preflight pass that resolves every ref against every supplied - // composition. If any ref is relevant to no supplied composition, fail loudly BEFORE rendering - // any diffs (this is a CLI input error, not a downstream processing failure). + // composition. If any ref is relevant to no supplied composition, fail loudly BEFORE processing + // any composition (this is a CLI input error, not a downstream processing failure). preflightMatches, err := p.preflightResourceRefs(ctx, compositions, resources) if err != nil { - return false, err + return nil, err } output := &renderer.CompDiffOutput{ @@ -194,8 +185,6 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit var compositionErrors int - hasDiffs := false - // Process each composition, filtering out non-Composition objects for _, comp := range compositions { // Skip non-Composition objects (e.g., GoTemplate objects extracted from pipeline steps) @@ -258,10 +247,6 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit ImpactAnalysis: []renderer.XRImpact{}, }) } else { - if compResult.HasChanges() { - hasDiffs = true - } - output.Compositions = append(output.Compositions, *compResult) } } @@ -278,28 +263,21 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit } // Release resources before draining advisories, so one raised during teardown (leftover function - // containers) is collected rather than arriving after the renderer has already read the slice. + // containers) is collected rather than arriving after the report has been returned and rendered. CleanupDetached(ctx, p, p.config.Logger) - // Attach any advisories raised during the run. They have already reached stderr when raised; this - // carries them into structured output. + // Attach any advisories raised during the run. An injected collector has already echoed them to + // stderr when raised; this carries them into structured output. output.Warnings = p.config.Advisories.Advisories() // Counted before the interruption entry is added: it is not an XR failure. totalXRErrors := len(output.Errors) output.Errors = withInterruption(ctx, output.Errors) - // Always render output (even if all compositions failed) to ensure valid structured output. The - // renderer includes errors in structured output but does not print them: they are returned, below, - // to the command, which prints them once. - if err := p.compDiffRenderer.RenderCompDiff(output); err != nil { - return hasDiffs, errors.Wrap(err, "failed to render composition diff") - } - - // Check for failures after rendering (so users see the output first), and return an error so - // CI/CD pipelines get a non-zero exit code. The renderer does not print errors, so the returned - // error names each failing resource. A run in which only some compositions failed still - // succeeds: each failure is in the output, under its composition. + // Return an error so CI/CD pipelines get a non-zero exit code. The report is returned with it, so + // the caller still renders it (structured output stays valid). The renderer does not print errors, + // so the returned error names each failing resource. A run in which only some compositions failed + // still succeeds: each failure is in the report, under its composition. var errs []error if totalXRErrors > 0 { @@ -315,7 +293,7 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit errs = append(errs, errors.Wrap(outputErrorsJoined(failed), "failed to process all compositions")) } - return hasDiffs, errors.Join(errs...) + return output, errors.Join(errs...) } // outputErrorsJoined returns errs as one error that names each one's resource, the way PerformDiff diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 4b7739a5..353ba88f 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -206,7 +206,7 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { outputVersion renderer.OutputVersion setupMocks func() xp.Clients // verifyOutput runs whether or not DiffComposition returns an error, since a failed composition - // is still rendered. + // is still reported, and so still rendered. verifyOutput func(t *testing.T, output string) wantErr bool // wantErrContains, when set, must appear in the returned error. @@ -448,27 +448,18 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { // Create mock XR processor mockXRProc := &tu.MockDiffProcessor{ - PerformDiffFn: func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (bool, error) { - return true, nil + PerformDiffFn: func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (*dt.XRReport, error) { + return &dt.XRReport{}, nil }, } // Create processor using constructor to ensure all fields are initialized logger := tu.TestLogger(t, false) - // Create stdout buffer first so it can be set in config - var stdout bytes.Buffer - config := ProcessorConfig{ - Colorize: false, - Compact: false, - AnalyzeOn: tt.analyzeOn, - OutputFormat: tt.outputFormat, - OutputVersion: tt.outputVersion, - Logger: logger, - Advisories: NewAdvisoryCollector(nil), - Stdout: &stdout, // Set stdout in config so renderers can access it - Stderr: &bytes.Buffer{}, // Discard stderr for tests + AnalyzeOn: tt.analyzeOn, + Logger: logger, + Advisories: NewAdvisoryCollector(nil), RenderFunc: func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { return render.CompositionOutputs{ CompositeResource: in.CompositeResource, @@ -476,18 +467,14 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { }, } config.SetDefaultFactories() - diffOpts := config.GetDiffOptions() - diffRenderer := config.Factories.DiffRenderer(logger, diffOpts) - compDiffRenderer := config.Factories.CompDiffRenderer(logger, diffRenderer, diffOpts) processor := &DefaultCompDiffProcessor{ compositionClient: xpClients.Composition, xrProc: mockXRProc, config: config, - compDiffRenderer: compDiffRenderer, } - _, err := processor.DiffComposition(ctx, tt.compositions, tt.namespace, nil) + output, err := processor.DiffComposition(ctx, tt.compositions, tt.namespace, nil) if (err != nil) != tt.wantErr { t.Errorf("DiffComposition() error = %v, wantErr %v", err, tt.wantErr) @@ -499,7 +486,7 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { } if tt.verifyOutput != nil { - tt.verifyOutput(t, stdout.String()) + tt.verifyOutput(t, renderCompReport(t, output, tt.outputFormat, tt.outputVersion)) } }) } @@ -1595,10 +1582,10 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_NestedXRCompositionLookup(t *te } } -// TestDefaultCompDiffProcessor_DiffComposition_ReturnsErrorNotStderr verifies that -// when XR processing fails, the returned error names the failing XR and carries the -// detail, and nothing is written to stderr: the command prints the error once. -func TestDefaultCompDiffProcessor_DiffComposition_ReturnsErrorNotStderr(t *testing.T) { +// TestDefaultCompDiffProcessor_DiffComposition_ReportErrorOutput verifies that when XR processing +// fails, the report carries the detailed error, attributed to the failed XR, for structured output, +// and the returned error names the failing XR and carries the detail: the command prints it once. +func TestDefaultCompDiffProcessor_DiffComposition_ReportErrorOutput(t *testing.T) { ctx := t.Context() // Create test composition @@ -1638,25 +1625,18 @@ func TestDefaultCompDiffProcessor_DiffComposition_ReturnsErrorNotStderr(t *testi }, } - // Create stderr buffer to capture error output - var stderrBuf bytes.Buffer - - // Create processor with custom stderr logger := tu.TestLogger(t, false) processor := NewCompDiffProcessor( mockXRProc, xpClients.Composition, WithLogger(logger), - WithColorize(false), - WithCompact(false), - WithStderr(&stderrBuf), // Use WithStderr to inject test buffer ) // Run the diff - should succeed but report XR errors. // // The input composition must differ from the cluster's (hence the label): impact analysis is // skipped for an unchanged composition, which would make the XR failure under test unreachable. - _, err := processor.DiffComposition(ctx, []*un.Unstructured{ + output, err := processor.DiffComposition(ctx, []*un.Unstructured{ tu.NewComposition("test-composition"). WithCompositeTypeRef("example.org/v1", "XResource"). WithPipelineMode(). @@ -1675,14 +1655,19 @@ func TestDefaultCompDiffProcessor_DiffComposition_ReturnsErrorNotStderr(t *testi } } - if stderrBuf.Len() != 0 { - t.Errorf("Expected nothing on stderr, got: %q", stderrBuf.String()) + if output == nil || len(output.Errors) != 1 { + t.Fatalf("Expected one error in the report, got: %+v", output) + } + + if got := output.Errors[0]; got.ResourceID != "XResource/fail-xr" || !strings.Contains(got.Message, "render pipeline failed") { + t.Errorf("Expected the report's error to name XResource/fail-xr and 'render pipeline failed', got: %+v", got) } } // TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput is the comp-side twin // of TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput: an advisory that can only -// be discovered while releasing resources must still be in warnings[], not just on stderr. comp reaches +// be discovered while releasing resources must still be in the report's warnings (warnings[]), not +// just on stderr. comp reaches // teardown via xrProc.Cleanup, so it needs its own guard — the xr-side test cannot fail for it. func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput(t *testing.T) { ctx := t.Context() @@ -1703,7 +1688,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOut WithResourcesForComposition("test-composition", "default", []*un.Unstructured{testXR}). Build() - var stdout, stderr bytes.Buffer + var stderr bytes.Buffer advisories := NewAdvisoryCollector(&stderr) @@ -1722,11 +1707,6 @@ func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOut processor := NewCompDiffProcessor(mockXRProc, compClient, WithLogger(tu.TestLogger(t, false)), WithAdvisories(advisories), - WithColorize(false), - WithCompact(false), - WithOutputFormat(renderer.OutputFormatJSON), - WithStdout(&stdout), - WithStderr(&stderr), ) // The input composition carries a label the cluster copy lacks, so impact analysis actually runs. @@ -1736,7 +1716,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOut WithLabels(map[string]string{"version": "0.0.2"}). BuildAsUnstructured() - if _, err := processor.DiffComposition(ctx, []*un.Unstructured{changed}, "default", nil); err != nil { + output, err := processor.DiffComposition(ctx, []*un.Unstructured{changed}, "default", nil) + if err != nil { t.Fatalf("DiffComposition(): unexpected error: %v", err) } @@ -1744,46 +1725,30 @@ func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOut t.Errorf("teardown advisory should reach stderr, got:\n%s", stderr.String()) } - var got struct { - Warnings []dt.OutputWarning `json:"warnings"` - } - - if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { - t.Fatalf("cannot parse structured output %q: %v", stdout.String(), err) - } - + // The report's warnings are what the structured renderer serializes as warnings[]. want := []dt.OutputWarning{{Message: advisory, Context: map[string]string{"errors": "1"}}} - if diff := gcmp.Diff(want, got.Warnings); diff != "" { - t.Errorf("structured warnings[] mismatch (-want +got):\n%s", diff) + if diff := gcmp.Diff(want, output.Warnings); diff != "" { + t.Errorf("report warnings mismatch (-want +got):\n%s", diff) } } // newCompProcessorForTest builds a DefaultCompDiffProcessor wrapping the given composition client // for use by the --resource preflight tests below. -func newCompProcessorForTest(t *testing.T, compClient xp.CompositionClient, includeManual bool) (*DefaultCompDiffProcessor, *bytes.Buffer) { +func newCompProcessorForTest(t *testing.T, compClient xp.CompositionClient, includeManual bool) *DefaultCompDiffProcessor { t.Helper() logger := tu.TestLogger(t, false) - stdout := &bytes.Buffer{} - stderr := &bytes.Buffer{} - config := ProcessorConfig{ IncludeManual: includeManual, Logger: logger, Advisories: NewAdvisoryCollector(nil), - Stdout: stdout, - Stderr: stderr, RenderFunc: func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil }, } config.SetDefaultFactories() - diffOpts := config.GetDiffOptions() - diffRenderer := config.Factories.DiffRenderer(logger, diffOpts) - compRenderer := config.Factories.CompDiffRenderer(logger, diffRenderer, diffOpts) - mockXR := &tu.MockDiffProcessor{ DiffSingleResourceFn: func(context.Context, *un.Unstructured, types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { return map[string]*dt.ResourceDiff{}, nil @@ -1794,8 +1759,7 @@ func newCompProcessorForTest(t *testing.T, compClient xp.CompositionClient, incl compositionClient: compClient, config: config, xrProc: mockXR, - compDiffRenderer: compRenderer, - }, stdout + } } // TestDefaultCompDiffProcessor_DiffComposition_ResourceMode covers the --resource code path: @@ -1855,7 +1819,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { for name, tt := range dispatchTests { t.Run(name, func(t *testing.T) { - proc, _ := newCompProcessorForTest(t, tt.compositionClient, false) + proc := newCompProcessorForTest(t, tt.compositionClient, false) if _, err := proc.DiffComposition(t.Context(), []*un.Unstructured{comp}, tt.namespace, tt.resources); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1869,9 +1833,9 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { WithNoMatchingComposites(). Build() - proc, stdout := newCompProcessorForTest(t, client, false) + proc := newCompProcessorForTest(t, client, false) - _, err := proc.DiffComposition(t.Context(), []*un.Unstructured{comp}, "", + output, err := proc.DiffComposition(t.Context(), []*un.Unstructured{comp}, "", []k8stypes.NamespacedName{{Namespace: "ns", Name: "ghost"}}) if err == nil { t.Fatal("expected error from globally-unmatched preflight, got nil") @@ -1881,8 +1845,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { t.Errorf("error message should name the unmatched ref, got: %v", err) } - if stdout.Len() != 0 { - t.Errorf("expected no output before fail-fast, got: %q", stdout.String()) + if output != nil { + t.Errorf("expected no report to render before fail-fast, got: %+v", output) } }) @@ -1914,7 +1878,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). Build() - proc, _ := newCompProcessorForTest(t, client, false /* IncludeManual */) + proc := newCompProcessorForTest(t, client, false /* IncludeManual */) got, err := proc.processSingleComposition(t.Context(), comp, []*un.Unstructured{manualXR}, tt.surfaceFiltered) if err != nil { @@ -2032,7 +1996,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { WithSuccessfulCompositionFetch(clusterComp). Build() - proc, _ := newCompProcessorForTest(t, client, tt.includeManual) + proc := newCompProcessorForTest(t, client, tt.includeManual) proc.config.AnalyzeOn = tt.analyzeOn got, err := proc.processSingleComposition(t.Context(), metaOnlyComp, []*un.Unstructured{xr1, manualXR}, tt.surfaceFiltered) @@ -2091,7 +2055,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). Build() - proc, _ := newCompProcessorForTest(t, client, false /* IncludeManual */) + proc := newCompProcessorForTest(t, client, false /* IncludeManual */) got, err := proc.processSingleComposition(t.Context(), labeledComp, []*un.Unstructured{selectorMismatchXR}, true /* surfaceFiltered */) if err != nil { @@ -2128,3 +2092,48 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { } }) } + +// renderCompReport renders a DiffComposition report the way the comp command does, in the given format +// (empty means the human-readable diff) and structured output version (empty means the default), and +// returns what it wrote to stdout. +func renderCompReport(t *testing.T, output *renderer.CompDiffOutput, format renderer.OutputFormat, version renderer.OutputVersion) string { + t.Helper() + + if output == nil { + return "" + } + + var stdout bytes.Buffer + + opts := renderer.DefaultDiffOptions() + opts.UseColors = false + opts.Stdout = &stdout + opts.Stderr = &bytes.Buffer{} + + if format != "" { + opts.Format = format + } + + if version != "" { + opts.OutputVersion = version + } + + logger := tu.TestLogger(t, false) + + var compRenderer renderer.CompDiffRenderer + + switch opts.Format { + case renderer.OutputFormatJSON, renderer.OutputFormatYAML: + compRenderer = renderer.NewStructuredCompDiffRenderer(logger, opts) + case renderer.OutputFormatDiff: + fallthrough + default: + compRenderer = renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts) + } + + if err := compRenderer.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff(): %v", err) + } + + return stdout.String() +} diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index c6354b56..37419858 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "maps" - "os" "slices" "sort" "strconv" @@ -31,7 +30,6 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -48,9 +46,10 @@ const ( // DiffProcessor interface for processing resources. type DiffProcessor interface { - // PerformDiff processes resources using a composition provider function. - // Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. - PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) + // PerformDiff processes resources using a composition provider function, and returns the report + // for the caller to render. The report is non-nil whenever resources were processed, even with an + // error, so the successful inputs' results and the failures' errors can still be rendered. + PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) // DiffSingleResource processes a single resource and returns its diffs. DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) @@ -76,7 +75,6 @@ type DefaultDiffProcessor struct { schemaValidator SchemaValidator renderDefaulter Defaulter // strict; defaults the XR render consumes (see EffectiveComposite) diffCalculator DiffCalculator - diffRenderer renderer.DiffRenderer requirementsProvider *RequirementsProvider // engineFn is set when using the default engine-backed RenderFunc so its // Docker network and function runtimes can be released in Cleanup. nil when @@ -87,13 +85,11 @@ type DefaultDiffProcessor struct { // NewDiffProcessor creates a new DefaultDiffProcessor with the provided options. func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) DiffProcessor { // Create default configuration - // Note: Presentation defaults (Colorize, Compact) are intentionally not set here. They should be - // provided via ProcessorOptions from the CLI layer. Engine limits default here so a caller that - // doesn't go through the CLI gets the same limits. + // Note: The processor does no presentation; the caller renders the report PerformDiff returns. + // Engine limits default here so a caller that doesn't go through the CLI gets the same limits. config := ProcessorConfig{ MaxNestedDepth: DefaultMaxNestedDepth, MaxRenderIterations: DefaultMaxRenderIterations, - Stderr: os.Stderr, Logger: logging.NewNopLogger(), } @@ -102,8 +98,10 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) option(&config) } + // Unset, advisories are only collected into the report; a caller that wants them echoed as they + // are raised injects a collector that writes to its stderr. if config.Advisories == nil { - config.Advisories = NewAdvisoryCollector(config.Stderr) + config.Advisories = NewAdvisoryCollector(nil) } // Default the render function to an engine-backed implementation if the @@ -119,9 +117,6 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) // Set default factory functions if not provided config.SetDefaultFactories() - // Create the diff options based on configuration - diffOpts := config.GetDiffOptions() - // Create components using factories resourceManager := config.Factories.ResourceManager(k8cs.Resource, xpcs.Definition, xpcs.ResourceTree, config.Logger, config.Advisories) schemaValidator := config.Factories.SchemaValidator(k8cs.Schema, k8cs.Resource, xpcs.Definition, config.Logger) @@ -129,7 +124,6 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) renderDefaulter := config.Factories.Defaulter(k8cs.Schema, xpcs.Definition, StrictDefaulting) predictor := config.Factories.Defaulter(k8cs.Schema, xpcs.Definition, LenientDefaulting) diffCalculator := config.Factories.DiffCalculator(k8cs.Apply, k8cs.Access, xpcs.ResourceTree, resourceManager, config.Logger, config.GetCompareOptions(), config.DryRunOn, predictor) - diffRenderer := config.Factories.DiffRenderer(config.Logger, diffOpts) functionProvider := config.Factories.FunctionProvider(xpcs.Function, config.Logger) if config.FunctionRegistryOverride != "" { @@ -153,7 +147,6 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) schemaValidator: schemaValidator, renderDefaulter: renderDefaulter, diffCalculator: diffCalculator, - diffRenderer: diffRenderer, requirementsProvider: requirementsProvider, engineFn: defaultEngineFn, } @@ -244,14 +237,15 @@ func (p *DefaultDiffProcessor) initializeSchemaValidator(ctx context.Context) er return nil } -// PerformDiff processes resources using a composition provider function. -// Returns (hasDiffs, error) where hasDiffs indicates if any differences were detected. -func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { +// PerformDiff processes resources using a composition provider function, and returns the report for +// the caller to render. The report is non-nil whenever resources were processed, even with an error. +func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) { p.config.Logger.Debug("Processing resources with composition provider", "count", len(resources)) if len(resources) == 0 { + // Nothing was processed, so there is no report to render. p.config.Logger.Debug("No resources to process") - return false, nil + return nil, nil } // Build one diff group per input XR/claim, in input order, so the renderer @@ -337,57 +331,44 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un. groups[i].Err = &outErr } - // Third: renders that reach one resource in ways no single diff can represent. Collected before the - // output is rendered so they reach errors[] and the returned error like any other error; the output - // is still rendered, so structured output stays valid. + // Third: renders that reach one resource in ways no single diff can represent. Collected into the + // report so they reach errors[] and the returned error like any other error; the report is still + // rendered, so structured output stays valid. for _, err := range validator.RenderOverlaps(groups) { errs = append(errs, err) outputErrors = append(outputErrors, dt.OutputError{Message: err.Error()}) } - // Release resources before rendering, so an advisory raised during teardown (leftover function - // containers) is still in warnings[] when the renderer reads it; the command's deferred Cleanup - // would run only after output has been emitted. + // Release resources before draining advisories, so one raised during teardown (leftover function + // containers) is still in the report's warnings; the command's deferred Cleanup would run only + // after output has been emitted. CleanupDetached(ctx, p, p.config.Logger) - outputErrors = withInterruption(ctx, outputErrors) - - // Always render (even if only errors exist) to ensure valid structured output. The renderer - // includes errors in structured output but does not print them: they are returned, below, to the - // command, which prints them once. Every outputErrors entry has its counterpart in errs, except - // the interruption's, which the command reports itself. - err := p.diffRenderer.RenderDiffs(groups, outputErrors, p.config.Advisories.Advisories()) - if err != nil { - p.config.Logger.Debug("Failed to render diffs", "error", err) - errs = append(errs, errors.Wrap(err, "failed to render diffs")) + // The renderer includes the report's errors in structured output but does not print them: they + // are returned, below, to the command, which prints them once. Every Errors entry has its + // counterpart in errs, except the interruption's, which the command reports itself. + report := &dt.XRReport{ + Groups: groups, + Errors: withInterruption(ctx, outputErrors), + Warnings: p.config.Advisories.Advisories(), } - // Count only non-equal diffs as "having diffs". - // The diffs map may contain DiffTypeEqual entries (e.g., XR stored for removal detection). - hasDiffs := false totalDiffs := 0 - for _, group := range groups { - for _, diff := range group.Diffs { - totalDiffs++ - - if diff.DiffType != dt.DiffTypeEqual { - hasDiffs = true - } - } + totalDiffs += len(group.Diffs) } p.config.Logger.Debug("Processing complete", "resourceCount", len(resources), "totalDiffs", totalDiffs, - "hasDiffs", hasDiffs, + "hasDiffs", report.HasChanges(), "errorCount", len(errs)) if len(errs) > 0 { - return hasDiffs, errors.Join(errs...) + return report, errors.Join(errs...) } - return hasDiffs, nil + return report, nil } // DiffSingleResource handles one resource at a time and returns its diffs. diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index d16867d7..700e11f4 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -3,7 +3,6 @@ package diffprocessor import ( "bytes" "context" - "encoding/json" "fmt" "strings" "testing" @@ -33,7 +32,6 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" - "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/diff/types" @@ -215,8 +213,6 @@ func testProcessorOptions(t *testing.T) []ProcessorOption { t.Helper() return []ProcessorOption{ - WithColorize(false), - WithCompact(false), WithLogger(tu.TestLogger(t, false)), WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil @@ -261,7 +257,7 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { setupMocks func() (k8.Clients, xp.Clients) resources []*un.Unstructured processorOpts []ProcessorOption - verifyOutput func(t *testing.T, output string) + wantHasChanges bool want error validationError bool }{ @@ -321,10 +317,9 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { }, resources: []*un.Unstructured{resource1}, processorOpts: testProcessorOptions(t), - // Note: Error output now goes to stderr (see TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput) - // This test verifies the error return value, not stderr output - verifyOutput: nil, - want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), + // The report carries the error for the caller to render (see + // TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput); this verifies the error return value. + want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), }, "MultipleResourceErrors": { setupMocks: func() (k8.Clients, xp.Clients) { @@ -354,9 +349,8 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { }, resources: []*un.Unstructured{resource1, resource2}, processorOpts: testProcessorOptions(t), - // Note: Error output now goes to stderr (see TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput) - // This test verifies the error return value, not stderr output - verifyOutput: nil, + // The report carries the errors for the caller to render (see + // TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput); this verifies the error return value. want: errors.New("[unable to process resource XR1/my-xr-1: cannot get composition: composition not found, " + "unable to process resource XR1/my-xr-2: cannot get composition: composition not found]"), }, @@ -607,49 +601,9 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { }, } }), - // Override the diff renderer factory to produce actual output - // The factory receives DiffOptions which contains Stdout where output should be written - WithDiffRendererFactory(func(_ logging.Logger, opts renderer.DiffOptions) renderer.DiffRenderer { - return &tu.MockDiffRenderer{ - RenderDiffsFn: func(_ []dt.XRDiffGroup, _ []dt.OutputError, _ []dt.OutputWarning) error { - // Write a simple summary to the output via opts.Stdout - w := opts.Stdout - - _, err := fmt.Fprintln(w, "Changes will be applied to 2 resources:") - if err != nil { - return err - } - - _, err = fmt.Fprintln(w, "- example.org/v1/XR1/test-xr will be modified") - if err != nil { - return err - } - - _, err = fmt.Fprintln(w, "- example.org/v1/ComposedResource/resource-a will be modified") - if err != nil { - return err - } - - _, err = fmt.Fprintln(w, "\nSummary: 0 to create, 2 to modify, 0 to delete") - - return err - }, - } - }), ), - verifyOutput: func(t *testing.T, output string) { - t.Helper() - // We should have some output from the diff - if output == "" { - t.Errorf("Expected non-empty diff output") - } - - // Simple check for expected output format - if !strings.Contains(output, "Summary:") { - t.Errorf("Expected diff output to contain a Summary section") - } - }, - want: nil, + wantHasChanges: true, + want: nil, }, "ValidationError": { setupMocks: func() (k8.Clients, xp.Clients) { @@ -751,24 +705,18 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { // Create components for testing k8sClients, xpClients := tt.setupMocks() - // Create stdout buffer and add it to processor options so renderers can access it - var stdout bytes.Buffer - - opts := append([]ProcessorOption{}, tt.processorOpts...) - opts = append(opts, WithStdout(&stdout)) - // Create the diff processor - processor := NewDiffProcessor(k8sClients, xpClients, opts...) + processor := NewDiffProcessor(k8sClients, xpClients, tt.processorOpts...) // Create a mock composition provider that uses the same mock composition client compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { return xpClients.Composition.FindMatchingComposition(ctx, res) } - _, err := processor.PerformDiff(ctx, tt.resources, compositionProvider) + report, err := processor.PerformDiff(ctx, tt.resources, compositionProvider) - // Check output if verification function is provided (do this first, before error checks) - if tt.verifyOutput != nil { - tt.verifyOutput(t, stdout.String()) + // Checked first, before the error checks: the report is returned even with an error. + if got := report != nil && report.HasChanges(); got != tt.wantHasChanges { + t.Errorf("PerformDiff(...): report.HasChanges() = %v, want %v", got, tt.wantHasChanges) } if tt.want != nil { @@ -1015,11 +963,7 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { ResourceTree: tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), } - var ( - gotGroups []dt.XRDiffGroup - gotErrs []dt.OutputError - gotRecorded []recorded - ) + var gotRecorded []recorded opts := append(testProcessorOptions(t), WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { @@ -1045,16 +989,6 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { }, } }), - WithDiffRendererFactory(func(logging.Logger, renderer.DiffOptions) renderer.DiffRenderer { - return &tu.MockDiffRenderer{ - RenderDiffsFn: func(groups []dt.XRDiffGroup, errs []dt.OutputError, _ []dt.OutputWarning) error { - gotGroups = groups - gotErrs = errs - - return nil - }, - } - }), ) if v := tt.validator; v != nil { @@ -1078,13 +1012,13 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { processor := NewDiffProcessor(k8sClients, xpClients, opts...) - _, err := processor.PerformDiff(ctx, tt.resources, func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + report, err := processor.PerformDiff(ctx, tt.resources, func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { return xpClients.Composition.FindMatchingComposition(ctx, res) }) result := got{Recorded: gotRecorded} - for _, g := range gotGroups { + for _, g := range report.Groups { result.XRs = append(result.XRs, g.XR) if g.Err == nil { @@ -1102,7 +1036,7 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { result.GroupErrs[g.XR.Name] = g.Err.Message } - for _, e := range gotErrs { + for _, e := range report.Errors { result.GlobalErrs = append(result.GlobalErrs, e.Message) } @@ -1122,13 +1056,14 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { // advisory is raised from FunctionProvider.Cleanup, which used to run in the command layer's defer — // i.e. after the renderer had already consumed and serialized the warning slice — so it reached stderr // but was structurally absent from warnings[], contradicting the README. PerformDiff therefore tears -// down before rendering, and this test fails if that ordering is lost. +// down before it drains advisories into the report the command renders, and this test fails if that +// ordering is lost. func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *testing.T) { ctx := t.Context() const advisory = "Some containers could not be cleaned up" - var stdout, stderr bytes.Buffer + var stderr bytes.Buffer advisories := NewAdvisoryCollector(&stderr) @@ -1154,9 +1089,6 @@ func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *t processor := NewDiffProcessor(k8sClients, xpClients, append(testProcessorOptions(t), WithAdvisories(advisories), - WithOutputFormat(renderer.OutputFormatJSON), - WithStdout(&stdout), - WithStderr(&stderr), // Stands in for CachedFunctionProvider.Cleanup raising its advisory when a container could // not be removed; the point under test is WHEN cleanup runs, not how it detects leftovers. WithFunctionProviderFactory(func(xp.FunctionClient, logging.Logger) FunctionProvider { @@ -1174,7 +1106,8 @@ func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *t WithSpecField("coolField", "test-value-1"). Build() - if _, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, xpClients.Composition.FindMatchingComposition); err == nil { + report, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, xpClients.Composition.FindMatchingComposition) + if err == nil { t.Fatal("PerformDiff(): expected the XR's diff to fail, got nil") } @@ -1182,31 +1115,21 @@ func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *t t.Errorf("teardown advisory should reach stderr, got:\n%s", stderr.String()) } - // Decoded via the wire contract rather than the renderer's own struct: what matters is that a - // machine consumer reading warnings[] out of the JSON sees the advisory. - var got struct { - Warnings []dt.OutputWarning `json:"warnings"` - Errors []dt.OutputError `json:"errors"` - } - - if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { - t.Fatalf("cannot parse structured output %q: %v", stdout.String(), err) - } - + // The report's warnings are what the structured renderer serializes as warnings[]. want := []dt.OutputWarning{{Message: advisory, Context: map[string]string{"errors": "1"}}} - if diff := gcmp.Diff(want, got.Warnings); diff != "" { - t.Errorf("structured warnings[] mismatch (-want +got):\n%s", diff) + if diff := gcmp.Diff(want, report.Warnings); diff != "" { + t.Errorf("report warnings mismatch (-want +got):\n%s", diff) } - if len(got.Errors) != 1 { - t.Errorf("expected the XR's failure to still be reported in errors[], got %v", got.Errors) + if len(report.Errors) != 1 { + t.Errorf("expected the XR's failure to still be reported in the report's errors, got %v", report.Errors) } } -// TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr verifies that when -// resource processing fails, the returned error names the resource and carries -// the detail, and nothing is written to stderr: the command prints the error once. -func TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr(t *testing.T) { +// TestDefaultDiffProcessor_PerformDiff_ReportErrorOutput verifies that when resource processing +// fails, the report carries the detailed error, attributed to the failed XR, for structured output, +// and the returned error names the resource and carries the detail: the command prints it once. +func TestDefaultDiffProcessor_PerformDiff_ReportErrorOutput(t *testing.T) { ctx := t.Context() // Create test resource @@ -1214,9 +1137,6 @@ func TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr(t *testing.T) { WithSpecField("coolField", "test-value-1"). Build() - // Create stderr buffer to capture error output - var stderrBuf bytes.Buffer - // Create Kubernetes client mocks. The XR's CRD is needed because the effective XR is defaulted // before its composition is resolved. k8sClients := k8.Clients{ @@ -1244,12 +1164,7 @@ func TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr(t *testing.T) { ResourceTree: tu.NewMockResourceTreeClient().Build(), } - // Create processor with custom stderr buffer - processor := NewDiffProcessor(k8sClients, xpClients, - append(testProcessorOptions(t), - WithStderr(&stderrBuf), // Inject test buffer to capture stderr - )..., - ) + processor := NewDiffProcessor(k8sClients, xpClients, testProcessorOptions(t)...) // Create composition provider using mock client compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { @@ -1257,7 +1172,7 @@ func TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr(t *testing.T) { } // Run the diff - _, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, compositionProvider) + report, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, compositionProvider) // Should return an error if err == nil { @@ -1270,8 +1185,12 @@ func TestDefaultDiffProcessor_PerformDiff_ReturnsErrorNotStderr(t *testing.T) { } } - if stderrBuf.Len() != 0 { - t.Errorf("Expected nothing on stderr, got: %q", stderrBuf.String()) + if len(report.Errors) != 1 { + t.Fatalf("Expected one error in the report, got: %v", report.Errors) + } + + if got := report.Errors[0]; got.ResourceID != "XR1/my-xr-1" || !strings.Contains(got.Message, "composition not found") { + t.Errorf("Expected the report's error to name XR1/my-xr-1 and 'composition not found', got: %+v", got) } } @@ -1328,7 +1247,6 @@ func TestDefaultDiffProcessor_warnIfDeleting(t *testing.T) { processor := &DefaultDiffProcessor{ config: ProcessorConfig{ - Stderr: &stderrBuf, Logger: tu.TestLogger(t, false), Advisories: advisories, }, diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 188a1f19..fcb2ac71 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -1,8 +1,6 @@ package diffprocessor import ( - "io" - corev1 "k8s.io/api/core/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -10,7 +8,6 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" - "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" ) @@ -101,35 +98,19 @@ func (s ChangeScope) triggersAnalysis(on AnalyzeOn) bool { } } -// ProcessorConfig contains configuration for the DiffProcessor. +// ProcessorConfig contains configuration for the DiffProcessor. It holds no presentation state: the +// processors return reports, and the caller renders them. type ProcessorConfig struct { - // Colorize determines whether to use colors in the diff output - Colorize bool - - // Compact determines whether to show a compact diff format - Compact bool - - // OutputFormat specifies the output format for diffs (diff, json, yaml) - OutputFormat renderer.OutputFormat - - // OutputVersion specifies the schema version of structured (json, yaml) output - OutputVersion renderer.OutputVersion - // MaxNestedDepth is the maximum depth for recursive nested XR processing MaxNestedDepth int // IncludeManual determines whether to include XRs with Manual update policy in composition diffs IncludeManual bool - // MinimizeComposition collapses composition changes to a single marker line per - // composition, omitting the full YAML diff body. Human renderer only; structured - // output always includes full compositionChanges. - MinimizeComposition bool - - // Advisories is the channel user-facing advisories are raised on, and drained from for structured - // output. Pass the same collector to every processor of one run (the CLI passes one set of options to - // both), so the composition processor's output includes what its XR processor raised. Unset, each - // processor collects its own, writing to Stderr. + // Advisories is the channel user-facing advisories are raised on, and drained from into the report. + // Pass the same collector to every processor of one run (the CLI passes one set of options to + // both), so the composition processor's report includes what its XR processor raised. Unset, each + // processor collects its own, writing nowhere; the CLI injects one that also writes to stderr. Advisories *AdvisoryCollector // AnalyzeOn is the smallest composition change that triggers per-composite impact analysis. @@ -168,13 +149,6 @@ type ProcessorConfig struct { // so large XRs don't trip the default limit under render. MaxRecvMessageSize int - // Stdout is the writer for diff output (defaults to os.Stdout) - Stdout io.Writer - - // Stderr is the writer for warnings (defaults to os.Stderr). Errors are - // returned to the caller, not written here. - Stderr io.Writer - // Logger is the logger to use Logger logging.Logger @@ -224,12 +198,6 @@ type ComponentFactories struct { // DiffCalculator creates a DiffCalculator DiffCalculator func(apply k8.ApplyClient, access k8.AccessChecker, tree xp.ResourceTreeClient, resourceManager ResourceManager, logger logging.Logger, compareOptions compare.Options, dryRunOn DryRunOn, predictor Defaulter) DiffCalculator - // DiffRenderer creates a DiffRenderer - DiffRenderer func(logger logging.Logger, diffOptions renderer.DiffOptions) renderer.DiffRenderer - - // CompDiffRenderer creates a CompDiffRenderer for composition diffs - CompDiffRenderer func(logger logging.Logger, diffRenderer renderer.DiffRenderer, opts renderer.DiffOptions) renderer.CompDiffRenderer - // RequirementsProvider creates an ExtraResourceProvider RequirementsProvider func(res k8.ResourceClient, def xp.EnvironmentClient, logger logging.Logger) *RequirementsProvider @@ -243,34 +211,6 @@ type ComponentFactories struct { // ProcessorOption defines a function that can modify a ProcessorConfig. type ProcessorOption func(*ProcessorConfig) -// WithColorize sets whether to use colors in diff output. -func WithColorize(colorize bool) ProcessorOption { - return func(config *ProcessorConfig) { - config.Colorize = colorize - } -} - -// WithCompact sets whether to use compact diff format. -func WithCompact(compact bool) ProcessorOption { - return func(config *ProcessorConfig) { - config.Compact = compact - } -} - -// WithOutputFormat sets the output format for diffs. -func WithOutputFormat(format renderer.OutputFormat) ProcessorOption { - return func(config *ProcessorConfig) { - config.OutputFormat = format - } -} - -// WithOutputVersion sets the schema version of structured output. -func WithOutputVersion(version renderer.OutputVersion) ProcessorOption { - return func(config *ProcessorConfig) { - config.OutputVersion = version - } -} - // WithMaxNestedDepth sets the maximum depth for recursive nested XR processing. func WithMaxNestedDepth(depth int) ProcessorOption { return func(config *ProcessorConfig) { @@ -312,13 +252,6 @@ func WithDryRunOn(dryRunOn DryRunOn) ProcessorOption { } } -// WithMinimizeComposition sets whether to collapse composition changes to a single marker line. -func WithMinimizeComposition(minimize bool) ProcessorOption { - return func(config *ProcessorConfig) { - config.MinimizeComposition = minimize - } -} - // WithEventualState sets whether to show eventual state after all reconciliation cycles complete. // When enabled, the processor runs an iterative simulation that synthesizes Ready status on // rendered resources until no new resources appear. This is useful with function-sequencer @@ -366,20 +299,6 @@ func WithMaxRecvMessageSize(mb int) ProcessorOption { } } -// WithStdout sets the writer for diff output. -func WithStdout(w io.Writer) ProcessorOption { - return func(config *ProcessorConfig) { - config.Stdout = w - } -} - -// WithStderr sets the writer for warnings. -func WithStderr(w io.Writer) ProcessorOption { - return func(config *ProcessorConfig) { - config.Stderr = w - } -} - // WithLogger sets the logger for the processor. func WithLogger(logger logging.Logger) ProcessorOption { return func(config *ProcessorConfig) { @@ -452,13 +371,6 @@ func WithDiffCalculatorFactory(factory func(k8.ApplyClient, k8.AccessChecker, xp } } -// WithDiffRendererFactory sets the DiffRenderer factory function. -func WithDiffRendererFactory(factory func(logging.Logger, renderer.DiffOptions) renderer.DiffRenderer) ProcessorOption { - return func(config *ProcessorConfig) { - config.Factories.DiffRenderer = factory - } -} - // WithRequirementsProviderFactory sets the RequirementsProvider factory function. func WithRequirementsProviderFactory(factory func(k8.ResourceClient, xp.EnvironmentClient, logging.Logger) *RequirementsProvider) ProcessorOption { return func(config *ProcessorConfig) { @@ -485,33 +397,6 @@ func (c *ProcessorConfig) GetCompareOptions() compare.Options { return compare.Options{IgnorePaths: c.IgnorePaths} } -// GetDiffOptions returns DiffOptions based on the ProcessorConfig. -func (c *ProcessorConfig) GetDiffOptions() renderer.DiffOptions { - opts := renderer.DefaultDiffOptions() - opts.UseColors = c.Colorize - opts.Compact = c.Compact - opts.MinimizeComposition = c.MinimizeComposition - - if c.OutputFormat != "" { - opts.Format = c.OutputFormat - } - - if c.OutputVersion != "" { - opts.OutputVersion = c.OutputVersion - } - - // Use config's Stdout/Stderr if set, otherwise keep defaults (os.Stdout/os.Stderr) - if c.Stdout != nil { - opts.Stdout = c.Stdout - } - - if c.Stderr != nil { - opts.Stderr = c.Stderr - } - - return opts -} - // SetDefaultFactories sets default component factory functions if not already set. func (c *ProcessorConfig) SetDefaultFactories() { if c.Factories.ResourceManager == nil { @@ -530,32 +415,6 @@ func (c *ProcessorConfig) SetDefaultFactories() { c.Factories.DiffCalculator = NewDiffCalculator } - if c.Factories.DiffRenderer == nil { - // Set the appropriate renderer factory based on output format - switch c.OutputFormat { - case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - c.Factories.DiffRenderer = renderer.NewStructuredDiffRenderer - case renderer.OutputFormatDiff: - c.Factories.DiffRenderer = renderer.NewDiffRenderer - default: - c.Factories.DiffRenderer = renderer.NewDiffRenderer - } - } - - if c.Factories.CompDiffRenderer == nil { - // Set the appropriate renderer factory based on output format - switch c.OutputFormat { - case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - c.Factories.CompDiffRenderer = func(logger logging.Logger, _ renderer.DiffRenderer, opts renderer.DiffOptions) renderer.CompDiffRenderer { - return renderer.NewStructuredCompDiffRenderer(logger, opts) - } - case renderer.OutputFormatDiff: - fallthrough - default: - c.Factories.CompDiffRenderer = renderer.NewDefaultCompDiffRenderer - } - } - if c.Factories.RequirementsProvider == nil { c.Factories.RequirementsProvider = NewRequirementsProvider } diff --git a/internal/diff/diffprocessor/processor_config_test.go b/internal/diff/diffprocessor/processor_config_test.go index 314144c0..8d455725 100644 --- a/internal/diff/diffprocessor/processor_config_test.go +++ b/internal/diff/diffprocessor/processor_config_test.go @@ -1,9 +1,6 @@ package diffprocessor import ( - "bytes" - "io" - "os" "testing" gcmp "github.com/google/go-cmp/cmp" @@ -11,7 +8,6 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - "github.com/crossplane/cli/v2/internal/diff/renderer" ) func TestNewDiffProcessor(t *testing.T) { @@ -20,7 +16,7 @@ func TestNewDiffProcessor(t *testing.T) { expectError bool }{ "WithOptions": { - options: []ProcessorOption{WithColorize(false), WithCompact(true)}, + options: []ProcessorOption{WithMaxNestedDepth(3), WithIncludeManual(true)}, expectError: false, }, "BasicOptions": { @@ -76,176 +72,6 @@ func TestProcessorsDefaultEngineLimits(t *testing.T) { } } -func TestDiffOptions(t *testing.T) { - tests := []struct { - name string - config ProcessorConfig - expected renderer.DiffOptions - }{ - { - name: "DefaultOptions", - config: ProcessorConfig{ - Colorize: true, - Compact: false, - }, - expected: func() renderer.DiffOptions { - opts := renderer.DefaultDiffOptions() - opts.UseColors = true - opts.Compact = false - - return opts - }(), - }, - { - name: "NoColors", - config: ProcessorConfig{ - Colorize: false, - Compact: false, - }, - expected: func() renderer.DiffOptions { - opts := renderer.DefaultDiffOptions() - opts.UseColors = false - opts.Compact = false - - return opts - }(), - }, - { - name: "CompactDiff", - config: ProcessorConfig{ - Colorize: true, - Compact: true, - }, - expected: func() renderer.DiffOptions { - opts := renderer.DefaultDiffOptions() - opts.UseColors = true - opts.Compact = true - - return opts - }(), - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.config.GetDiffOptions() - - if diff := gcmp.Diff(tt.expected.UseColors, got.UseColors); diff != "" { - t.Errorf("GetDiffOptions().UseColors mismatch (-want +got):\n%s", diff) - } - - if diff := gcmp.Diff(tt.expected.Compact, got.Compact); diff != "" { - t.Errorf("GetDiffOptions().Compact mismatch (-want +got):\n%s", diff) - } - }) - } -} - -// TestGetDiffOptions_PropagatesStdoutStderrFormat verifies that when a ProcessorConfig -// has Stdout, Stderr, and OutputFormat set, those values flow through to the returned -// DiffOptions. When Stdout/Stderr are nil, the defaults (os.Stdout/os.Stderr) must be -// preserved. -func TestGetDiffOptions_PropagatesStdoutStderrFormat(t *testing.T) { - t.Run("CustomWritersAndFormatPropagate", func(t *testing.T) { - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) - - config := ProcessorConfig{ - Colorize: true, - Stdout: &stdout, - Stderr: &stderr, - OutputFormat: renderer.OutputFormatJSON, - } - - got := config.GetDiffOptions() - - if got.Stdout != io.Writer(&stdout) { - t.Errorf("Expected Stdout to be the injected buffer, got: %v", got.Stdout) - } - - if got.Stderr != io.Writer(&stderr) { - t.Errorf("Expected Stderr to be the injected buffer, got: %v", got.Stderr) - } - - if got.Format != renderer.OutputFormatJSON { - t.Errorf("Expected Format to be %q, got %q", renderer.OutputFormatJSON, got.Format) - } - }) - - t.Run("ZeroValuesKeepDefaults", func(t *testing.T) { - config := ProcessorConfig{ - Colorize: true, - // Stdout, Stderr, and OutputFormat intentionally left at zero value - } - - got := config.GetDiffOptions() - - // When the config's Stdout/Stderr are nil, DefaultDiffOptions() defaults - // (os.Stdout/os.Stderr) should be preserved. - if got.Stdout != io.Writer(os.Stdout) { - t.Errorf("Expected default os.Stdout when config.Stdout is nil, got: %v", got.Stdout) - } - - if got.Stderr != io.Writer(os.Stderr) { - t.Errorf("Expected default os.Stderr when config.Stderr is nil, got: %v", got.Stderr) - } - - // When the config's OutputFormat is the zero value (""), DefaultDiffOptions() - // default (OutputFormatDiff) should be preserved rather than overwritten with "". - if got.Format != renderer.OutputFormatDiff { - t.Errorf("Expected default Format %q when config.OutputFormat is empty, got %q", - renderer.OutputFormatDiff, got.Format) - } - }) -} - -func TestWithOptions(t *testing.T) { - tests := []struct { - name string - options []ProcessorOption - expected ProcessorConfig - }{ - { - name: "WithMultipleOptions", - options: []ProcessorOption{ - WithColorize(false), - WithCompact(true), - }, - expected: ProcessorConfig{ - Colorize: false, - Compact: true, - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create a default config - config := ProcessorConfig{ - Colorize: true, - Compact: false, - } - - // Apply the options - for _, option := range tt.options { - option(&config) - } - - // Check colorize - if diff := gcmp.Diff(tt.expected.Colorize, config.Colorize); diff != "" { - t.Errorf("Colorize mismatch (-want +got):\n%s", diff) - } - - // Check compact - if diff := gcmp.Diff(tt.expected.Compact, config.Compact); diff != "" { - t.Errorf("Compact mismatch (-want +got):\n%s", diff) - } - }) - } -} - // TestWithCrossplaneRenderOverrides verifies that the render-backend selector // options set the corresponding ProcessorConfig fields, so they thread through // to render.EngineFlags when the default engine-backed RenderFn is built. @@ -339,28 +165,6 @@ func TestNewDiffProcessorThreadsRenderBackendToEngine(t *testing.T) { } } -// TestWithStdoutStderr verifies that WithStdout and WithStderr set the -// corresponding writers on the ProcessorConfig. -func TestWithStdoutStderr(t *testing.T) { - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) - - config := ProcessorConfig{} - - WithStdout(&stdout)(&config) - WithStderr(&stderr)(&config) - - if config.Stdout != io.Writer(&stdout) { - t.Errorf("Expected config.Stdout to be the injected buffer, got: %v", config.Stdout) - } - - if config.Stderr != io.Writer(&stderr) { - t.Errorf("Expected config.Stderr to be the injected buffer, got: %v", config.Stderr) - } -} - // TestWithMaxRecvMessageSize verifies the option sets the field and that the // zero value is preserved when the option is not applied. func TestWithMaxRecvMessageSize(t *testing.T) { diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 4cd21ba6..8416a9b1 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -6056,7 +6056,6 @@ func TestDiffSingleResourceConcurrent(t *testing.T) { opts := []dp.ProcessorOption{ dp.WithLogger(log), dp.WithAdvisories(advisories), - dp.WithColorize(false), dp.WithMaxNestedDepth(10), dp.WithMaxRenderIterations(20), dp.WithDryRunOn(dp.DryRunOnAll), diff --git a/internal/diff/renderer/structured_renderer.go b/internal/diff/renderer/structured_renderer.go index f97d5e18..740b525b 100644 --- a/internal/diff/renderer/structured_renderer.go +++ b/internal/diff/renderer/structured_renderer.go @@ -393,6 +393,17 @@ func (c *CompositionDiff) HasChanges() bool { return false } +// HasChanges returns true if any composition in the output has changes (see CompositionDiff.HasChanges). +func (o *CompDiffOutput) HasChanges() bool { + for i := range o.Compositions { + if o.Compositions[i].HasChanges() { + return true + } + } + + return false +} + // AffectedResourcesSummary contains counts of affected resources by status. type AffectedResourcesSummary struct { Total int `json:"total"` diff --git a/internal/diff/renderer/types/types.go b/internal/diff/renderer/types/types.go index e4f56dc4..d4cc6e45 100644 --- a/internal/diff/renderer/types/types.go +++ b/internal/diff/renderer/types/types.go @@ -45,6 +45,31 @@ type XRDiffGroup struct { Err *OutputError } +// XRReport is what one xr diff run produced: everything a DiffRenderer renders. +type XRReport struct { + // Groups holds one entry per input XR/claim, in input order. + Groups []XRDiffGroup + // Errors is the run's union error list: every group's error, plus errors no single group owns + // (render overlaps, an interruption). + Errors []OutputError + // Warnings are the advisories the run raised, sorted. + Warnings []OutputWarning +} + +// HasChanges reports whether any group has a non-equal diff. Groups may hold DiffTypeEqual entries +// (e.g. the XR, stored for removal detection), which are not changes. +func (r *XRReport) HasChanges() bool { + for _, group := range r.Groups { + for _, diff := range group.Diffs { + if diff.DiffType != DiffTypeEqual { + return true + } + } + } + + return false +} + // ResourceViews holds the two representations of a single resource involved in // a diff: the Raw object (as rendered, or as fetched from the cluster) and the // Clean object (Raw after cleanupForDiff has stripped ignored paths and diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go index 24524075..cc4de913 100644 --- a/internal/diff/testutils/mock_builder.go +++ b/internal/diff/testutils/mock_builder.go @@ -1439,29 +1439,36 @@ func (b *DiffProcessorBuilder) WithFailedInitialize(errMsg string) *DiffProcesso } // WithPerformDiff adds an implementation for the PerformDiff method. -func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error)) *DiffProcessorBuilder { +func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error)) *DiffProcessorBuilder { b.mock.PerformDiffFn = fn return b } // WithSuccessfulPerformDiff sets a successful PerformDiff implementation with no diffs. func (b *DiffProcessorBuilder) WithSuccessfulPerformDiff() *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { - return false, nil + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error) { + return &types.XRReport{}, nil }) } -// WithSuccessfulPerformDiffWithChanges sets a successful PerformDiff implementation with diffs. +// WithSuccessfulPerformDiffWithChanges sets a successful PerformDiff implementation whose report +// has one added resource. func (b *DiffProcessorBuilder) WithSuccessfulPerformDiffWithChanges() *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { - return true, nil + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error) { + added := &types.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XDownstreamResource"}, + ResourceName: "added-resource", + DiffType: types.DiffTypeAdded, + } + + return &types.XRReport{Groups: []types.XRDiffGroup{{Diffs: map[string]*types.ResourceDiff{added.GetDiffKey(): added}}}}, nil }) } -// WithFailedPerformDiff sets a failing PerformDiff implementation. +// WithFailedPerformDiff sets a failing PerformDiff implementation, whose report holds the error. func (b *DiffProcessorBuilder) WithFailedPerformDiff(errMsg string) *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (bool, error) { - return false, errors.New(errMsg) + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error) { + return &types.XRReport{Errors: []types.OutputError{{Message: errMsg}}}, errors.New(errMsg) }) } diff --git a/internal/diff/testutils/mocks.go b/internal/diff/testutils/mocks.go index 468c1545..8eacd645 100644 --- a/internal/diff/testutils/mocks.go +++ b/internal/diff/testutils/mocks.go @@ -263,7 +263,7 @@ func (m *MockResourceInterface) ApplyStatus(_ context.Context, _ string, _ *un.U type MockDiffProcessor struct { // Function fields for mocking behavior InitializeFn func(ctx context.Context) error - PerformDiffFn func(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) + PerformDiffFn func(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) DiffSingleResourceFn func(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) CleanupFn func(ctx context.Context) error } @@ -278,12 +278,12 @@ func (m *MockDiffProcessor) Initialize(ctx context.Context) error { } // PerformDiff implements the DiffProcessor.PerformDiff method. -func (m *MockDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (bool, error) { +func (m *MockDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) { if m.PerformDiffFn != nil { return m.PerformDiffFn(ctx, resources, compositionProvider) } - return false, nil + return &dt.XRReport{}, nil } // DiffSingleResource implements the DiffProcessor.DiffSingleResource method. From d906461069069a361f95983e4f0fd7d7627640be Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 21:02:15 -0400 Subject: [PATCH 044/126] refactor(diff): move the output model into a result package 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) Signed-off-by: Jonathan Ogilvie --- .gitignore | 4 +- cmd/crossplane/project/simulate_test.go | 27 +- internal/diff/compare/compare.go | 2 +- internal/diff/compare/compare_test.go | 54 ++-- internal/diff/diffprocessor/comp_processor.go | 63 ++-- .../diff/diffprocessor/comp_processor_test.go | 86 +++--- .../diff/diffprocessor/composite_resolver.go | 2 +- .../diff/diffprocessor/diff_calculator.go | 2 +- .../diffprocessor/diff_calculator_test.go | 4 +- internal/diff/diffprocessor/diff_processor.go | 4 +- .../diff/diffprocessor/diff_processor_test.go | 2 +- internal/diff/diffprocessor/errors.go | 4 +- internal/diff/diffprocessor/errors_test.go | 2 +- .../diff/diffprocessor/input_validator.go | 2 +- .../diffprocessor/input_validator_test.go | 2 +- .../diff/diffprocessor/processor_config.go | 2 +- internal/diff/diffprocessor/render_engine.go | 2 +- .../diffprocessor/requirements_provider.go | 2 +- .../diff/diffprocessor/resource_manager.go | 2 +- .../diffprocessor/resource_manager_test.go | 2 +- internal/diff/diffprocessor/warnings.go | 2 +- internal/diff/diffprocessor/warnings_test.go | 2 +- internal/diff/dockerrun/run.go | 2 +- internal/diff/dockerrun/run_test.go | 2 +- internal/diff/integration/integration_test.go | 2 +- internal/diff/renderer/comp_diff_renderer.go | 70 ++--- .../diff/renderer/comp_diff_renderer_test.go | 292 +++++++++--------- internal/diff/renderer/diff_formatter.go | 2 +- internal/diff/renderer/diff_renderer.go | 6 +- internal/diff/renderer/diff_renderer_test.go | 2 +- internal/diff/renderer/dry_run_warnings.go | 189 +----------- .../diff/renderer/dry_run_warnings_test.go | 168 +--------- internal/diff/renderer/structured_renderer.go | 269 ++-------------- .../diff/renderer/structured_renderer_test.go | 24 +- internal/diff/result/comp.go | 232 ++++++++++++++ internal/diff/result/dry_run_warnings.go | 208 +++++++++++++ internal/diff/result/dry_run_warnings_test.go | 203 ++++++++++++ .../diff/{renderer/types => result}/types.go | 8 +- internal/diff/testutils/mock_builder.go | 24 +- internal/diff/testutils/mocks.go | 2 +- .../diff/testutils/structured_assertions.go | 10 +- internal/diff/testutils/utils.go | 2 +- internal/diff/types/types.go | 2 +- 43 files changed, 1044 insertions(+), 949 deletions(-) create mode 100644 internal/diff/result/comp.go create mode 100644 internal/diff/result/dry_run_warnings.go create mode 100644 internal/diff/result/dry_run_warnings_test.go rename internal/diff/{renderer/types => result}/types.go (98%) diff --git a/.gitignore b/.gitignore index afade55e..27b49f0a 100644 --- a/.gitignore +++ b/.gitignore @@ -13,8 +13,8 @@ Zone.Identifier build/ # Nix build output symlinks -result -result-* +/result +/result-* # Exclude the ko data dir kodata/ diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index a2ff54fd..460b211d 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -43,6 +43,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/common" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -493,7 +494,7 @@ var _ dp.CompDiffProcessor = &fakeCompProc{} // fakeCompProc is a CompDiffProcessor that records its calls. type fakeCompProc struct { initErr error - diffFn func(ctx context.Context) (*renderer.CompDiffOutput, error) + diffFn func(ctx context.Context) (*dt.CompDiffOutput, error) gotNamespace string gotRefs []k8stypes.NamespacedName @@ -503,10 +504,10 @@ type fakeCompProc struct { func (f *fakeCompProc) Initialize(context.Context) error { return f.initErr } -func (f *fakeCompProc) DiffComposition(ctx context.Context, comps []*un.Unstructured, ns string, refs []k8stypes.NamespacedName) (*renderer.CompDiffOutput, error) { +func (f *fakeCompProc) DiffComposition(ctx context.Context, comps []*un.Unstructured, ns string, refs []k8stypes.NamespacedName) (*dt.CompDiffOutput, error) { f.gotNamespace, f.gotRefs, f.gotComps = ns, refs, len(comps) if f.diffFn == nil { - return &renderer.CompDiffOutput{}, nil + return &dt.CompDiffOutput{}, nil } return f.diffFn(ctx) } @@ -533,10 +534,10 @@ func TestSimulateRun(t *testing.T) { noInit := func(context.Context, logging.Logger) error { return nil } // changed is a report of one Composition with an affected composite // resource. - changed := func() *renderer.CompDiffOutput { - return &renderer.CompDiffOutput{Compositions: []renderer.CompositionDiff{{ + changed := func() *dt.CompDiffOutput { + return &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{{ Name: "comp", - ImpactAnalysis: []renderer.XRImpact{{Status: renderer.XRStatusChanged}}, + ImpactAnalysis: []dt.XRImpact{{Status: dt.XRStatusChanged}}, }}} } @@ -566,21 +567,21 @@ func TestSimulateRun(t *testing.T) { }, "Changes": { reason: "Changes should exit 3, as a result rather than an error.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { return changed(), nil }}}, want: want{code: dp.ExitCodeDiffDetected, diffed: true}, }, "ErrorWithChanges": { reason: "A tool error should exit 1, not 3, even when there are changes.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { return changed(), errBoom }}}, want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, }, "SchemaValidationErrorWithChanges": { reason: "A schema validation failure should exit 2, not 3, even when there are changes.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { return changed(), dp.NewSchemaValidationError("XR/db", "invalid", errBoom) }}}, want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, @@ -610,21 +611,21 @@ func TestSimulateRun(t *testing.T) { }, "DiffError": { reason: "A failed simulation is a tool error.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { return changed(), errBoom }}}, want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, }, "SchemaValidationError": { reason: "A simulation that failed only schema validation should exit 2.", - args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*renderer.CompDiffOutput, error) { - return &renderer.CompDiffOutput{}, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { + return &dt.CompDiffOutput{}, dp.NewSchemaValidationError("XR/db", "invalid", errBoom) }}}, want: want{code: dp.ExitCodeSchemaValidation, err: errBoom, diffed: true}, }, "Interrupted": { reason: "An interrupted run should return the interruption, whatever it returned, which main exits 130 for.", - args: args{cancel: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(ctx context.Context) (*renderer.CompDiffOutput, error) { + args: args{cancel: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(ctx context.Context) (*dt.CompDiffOutput, error) { return changed(), ctx.Err() }}}, want: want{code: dp.ExitCodeToolError, err: dp.ErrInterrupted, diffed: true}, diff --git a/internal/diff/compare/compare.go b/internal/diff/compare/compare.go index 5cc6f0a3..8fcf626b 100644 --- a/internal/diff/compare/compare.go +++ b/internal/diff/compare/compare.go @@ -32,7 +32,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - t "github.com/crossplane/cli/v2/internal/diff/renderer/types" + t "github.com/crossplane/cli/v2/internal/diff/result" ) // PathLastAppliedConfiguration is the ignore-path form of the annotation a client-side `kubectl diff --git a/internal/diff/compare/compare_test.go b/internal/diff/compare/compare_test.go index 96682035..9975abca 100644 --- a/internal/diff/compare/compare_test.go +++ b/internal/diff/compare/compare_test.go @@ -23,7 +23,7 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -94,7 +94,7 @@ func TestGenerateDiff(t *testing.T) { kind string resName string options Options - wantDiff *types.ResourceDiff + wantDiff *dt.ResourceDiff wantErr bool }{ "ModifiedResource": { @@ -103,13 +103,13 @@ func TestGenerateDiff(t *testing.T) { kind: "TestResource", resName: "test-resource", options: Options{}, - wantDiff: &types.ResourceDiff{ + wantDiff: &dt.ResourceDiff{ Gvk: current.GroupVersionKind(), ResourceName: "test-resource", - DiffType: types.DiffTypeModified, + DiffType: dt.DiffTypeModified, // Nothing to strip, so Clean is content-equal to Raw. - Current: types.ResourceViews{Raw: current, Clean: current}, - Desired: types.ResourceViews{Raw: desired, Clean: desired}, + Current: dt.ResourceViews{Raw: current, Clean: current}, + Desired: dt.ResourceViews{Raw: desired, Clean: desired}, // LineDiffs will be checked separately }, }, @@ -119,13 +119,13 @@ func TestGenerateDiff(t *testing.T) { kind: "TestResource", resName: "test-resource", options: Options{}, - wantDiff: &types.ResourceDiff{ + wantDiff: &dt.ResourceDiff{ Gvk: current.GroupVersionKind(), ResourceName: "test-resource", - DiffType: types.DiffTypeEqual, + DiffType: dt.DiffTypeEqual, // Equal diffs render nothing, so Clean is left nil by generation. - Current: types.ResourceViews{Raw: current}, - Desired: types.ResourceViews{Raw: noChanges}, + Current: dt.ResourceViews{Raw: current}, + Desired: dt.ResourceViews{Raw: noChanges}, }, }, "NewResource": { @@ -134,12 +134,12 @@ func TestGenerateDiff(t *testing.T) { kind: "TestResource", resName: "test-resource", options: Options{}, - wantDiff: &types.ResourceDiff{ + wantDiff: &dt.ResourceDiff{ Gvk: desired.GroupVersionKind(), ResourceName: "test-resource", - DiffType: types.DiffTypeAdded, - Current: types.ResourceViews{}, - Desired: types.ResourceViews{Raw: desired, Clean: desired}, + DiffType: dt.DiffTypeAdded, + Current: dt.ResourceViews{}, + Desired: dt.ResourceViews{Raw: desired, Clean: desired}, // LineDiffs will be checked separately }, }, @@ -149,12 +149,12 @@ func TestGenerateDiff(t *testing.T) { kind: "TestResource", resName: "test-resource", options: Options{}, - wantDiff: &types.ResourceDiff{ + wantDiff: &dt.ResourceDiff{ Gvk: current.GroupVersionKind(), ResourceName: "test-resource", - DiffType: types.DiffTypeRemoved, - Current: types.ResourceViews{Raw: current, Clean: current}, - Desired: types.ResourceViews{}, + DiffType: dt.DiffTypeRemoved, + Current: dt.ResourceViews{Raw: current, Clean: current}, + Desired: dt.ResourceViews{}, // LineDiffs will be checked separately }, }, @@ -167,12 +167,12 @@ func TestGenerateDiff(t *testing.T) { kind: "TestResource", resName: "test-resource", options: ignoreOpts, - wantDiff: &types.ResourceDiff{ + wantDiff: &dt.ResourceDiff{ Gvk: noisyCurrent.GroupVersionKind(), ResourceName: "test-resource", - DiffType: types.DiffTypeModified, - Current: types.ResourceViews{Raw: noisyCurrent, Clean: noisyCurrentClean}, - Desired: types.ResourceViews{Raw: noisyDesired, Clean: noisyDesiredClean}, + DiffType: dt.DiffTypeModified, + Current: dt.ResourceViews{Raw: noisyCurrent, Clean: noisyCurrentClean}, + Desired: dt.ResourceViews{Raw: noisyDesired, Clean: noisyDesiredClean}, }, }, "ModifiedResource_PrefersCurrentNamespace": { @@ -183,13 +183,13 @@ func TestGenerateDiff(t *testing.T) { kind: "TestResource", resName: "test-resource", options: Options{}, - wantDiff: &types.ResourceDiff{ + wantDiff: &dt.ResourceDiff{ Gvk: nsCurrent.GroupVersionKind(), ResourceName: "test-resource", Namespace: "production", - DiffType: types.DiffTypeModified, - Current: types.ResourceViews{Raw: nsCurrent, Clean: nsCurrent}, - Desired: types.ResourceViews{Raw: nsDesired, Clean: nsDesired}, + DiffType: dt.DiffTypeModified, + Current: dt.ResourceViews{Raw: nsCurrent, Clean: nsCurrent}, + Desired: dt.ResourceViews{Raw: nsDesired, Clean: nsDesired}, }, }, "BothNil": { @@ -235,7 +235,7 @@ func TestGenerateDiff(t *testing.T) { } // Check for line diffs - should be non-empty for changed resources - if diff.DiffType != types.DiffTypeEqual && len(diff.LineDiffs) == 0 { + if diff.DiffType != dt.DiffTypeEqual && len(diff.LineDiffs) == 0 { t.Errorf("LineDiffs is empty for %s", name) } diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index b0ac73eb..7f321d2b 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -37,8 +37,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" "github.com/crossplane/cli/v2/internal/diff/compare" "github.com/crossplane/cli/v2/internal/diff/ref" - "github.com/crossplane/cli/v2/internal/diff/renderer" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" dtypes "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -80,7 +79,7 @@ type CompDiffProcessor interface { // preflight pass. If any ref is relevant to no supplied composition, the call fails before // processing any composition, with a nil report (CLI input error). When `resources` is empty, // behavior is unchanged. - DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (*renderer.CompDiffOutput, error) + DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (*dt.CompDiffOutput, error) Initialize(ctx context.Context) error // Cleaner releases any resources held by the processor (e.g., Docker containers). Cleaner @@ -160,7 +159,7 @@ func (p *DefaultCompDiffProcessor) Cleanup(ctx context.Context) error { // DiffComposition processes composition changes and their impact on existing XRs, and returns the // report for the caller to render. The report is non-nil whenever compositions were processed, even // with an error. -func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (*renderer.CompDiffOutput, error) { +func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, compositions []*un.Unstructured, namespace string, resources []k8stypes.NamespacedName) (*dt.CompDiffOutput, error) { p.config.Logger.Debug("Processing composition diff", "compositionCount", len(compositions), "namespace", namespace, @@ -178,8 +177,8 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit return nil, err } - output := &renderer.CompDiffOutput{ - Compositions: make([]renderer.CompositionDiff, 0, len(compositions)), + output := &dt.CompDiffOutput{ + Compositions: make([]dt.CompositionDiff, 0, len(compositions)), Errors: []dt.OutputError{}, } @@ -235,16 +234,16 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit compositionErrors++ // Include failed composition with error instead of skipping - output.Compositions = append(output.Compositions, renderer.CompositionDiff{ + output.Compositions = append(output.Compositions, dt.CompositionDiff{ Name: compositionID, Error: new(NewOutputError(fmt.Sprintf("Composition/%s", compositionID), err)), - AffectedResources: renderer.AffectedResourcesSummary{ + AffectedResources: dt.AffectedResourcesSummary{ Total: 0, WithChanges: 0, Unchanged: 0, WithErrors: 0, }, - ImpactAnalysis: []renderer.XRImpact{}, + ImpactAnalysis: []dt.XRImpact{}, }) } else { output.Compositions = append(output.Compositions, *compResult) @@ -256,7 +255,7 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit for _, impact := range comp.ImpactAnalysis { // The impact carries the OutputError built when it was created (see buildImpactAnalysis), so the // per-XR entry and this list hold the same error. - if impact.Status == renderer.XRStatusError && impact.Error != nil { + if impact.Status == dt.XRStatusError && impact.Error != nil { output.Errors = append(output.Errors, *impact.Error) } } @@ -370,11 +369,11 @@ func (p *DefaultCompDiffProcessor) preflightResourceRefs(ctx context.Context, co // switch whether this comes from the --resource preflight or default-discovery via FindComposites). // When `surfaceFiltered` is true, XRs dropped by update-policy filtering are surfaced in // ImpactAnalysis with XRStatusFilteredByPolicy so users see what was matched-but-skipped. -func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, newComp *un.Unstructured, affectedXRs []*un.Unstructured, surfaceFiltered bool) (*renderer.CompositionDiff, error) { - result := &renderer.CompositionDiff{ +func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, newComp *un.Unstructured, affectedXRs []*un.Unstructured, surfaceFiltered bool) (*dt.CompositionDiff, error) { + result := &dt.CompositionDiff{ Name: newComp.GetName(), - ImpactAnalysis: []renderer.XRImpact{}, - AffectedResources: renderer.AffectedResourcesSummary{ + ImpactAnalysis: []dt.XRImpact{}, + AffectedResources: dt.AffectedResourcesSummary{ Total: 0, WithChanges: 0, Unchanged: 0, @@ -405,7 +404,7 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // independent of whether anything renders differently — so it is reported at every --analyze-on // setting, and carried as a typed per-composition field rather than a warning, because a CI // consumer may want to gate on it. - result.RevisionImpact = renderer.RevisionImpact{ + result.RevisionImpact = dt.RevisionImpact{ ChangeScope: string(comparison.scope), CreatesRevision: comparison.changed(), } @@ -447,14 +446,14 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // In default-discovery mode, preserve the existing summary-only behavior. if surfaceFiltered { for _, d := range droppedXRs { - result.ImpactAnalysis = append(result.ImpactAnalysis, renderer.XRImpact{ + result.ImpactAnalysis = append(result.ImpactAnalysis, dt.XRImpact{ ObjectReference: corev1.ObjectReference{ APIVersion: d.xr.GetAPIVersion(), Kind: d.xr.GetKind(), Name: d.xr.GetName(), Namespace: d.xr.GetNamespace(), }, - Status: renderer.XRStatusFiltered, + Status: dt.XRStatusFiltered, FilterReason: d.reason, FilterDetail: d.detail, }) @@ -1000,7 +999,7 @@ func predictedRevisionLabels(newComp *un.Unstructured, pred predictedRevision) m // optional human-readable detail (e.g. which selector failed to match which labels). type filteredXR struct { xr *un.Unstructured - reason renderer.FilterReason + reason dt.FilterReason detail string } @@ -1089,7 +1088,7 @@ func (p *DefaultCompDiffProcessor) classifyXR(xr *un.Unstructured, targetLabels, return xrDisposition{filtered: &filteredXR{ xr: xr, - reason: renderer.FilterReasonDeleting, + reason: dt.FilterReasonDeleting, detail: fmt.Sprintf("deletionTimestamp: %s", stamp), }}, nil } @@ -1111,7 +1110,7 @@ func (p *DefaultCompDiffProcessor) classifyXR(xr *un.Unstructured, targetLabels, return xrDisposition{pinnedToRevision: true}, nil } - return xrDisposition{filtered: &filteredXR{xr: xr, reason: renderer.FilterReasonManualPolicy}}, nil + return xrDisposition{filtered: &filteredXR{xr: xr, reason: dt.FilterReasonManualPolicy}}, nil } // Automatic (or default) policy: honor compositionRevisionSelector. An XR whose selector does not @@ -1123,7 +1122,7 @@ func (p *DefaultCompDiffProcessor) classifyXR(xr *un.Unstructured, targetLabels, } if !matches { - return xrDisposition{filtered: &filteredXR{xr: xr, reason: renderer.FilterReasonRevisionSelectorMismatch, detail: detail}}, nil + return xrDisposition{filtered: &filteredXR{xr: xr, reason: dt.FilterReasonRevisionSelectorMismatch, detail: detail}}, nil } return xrDisposition{}, nil @@ -1143,11 +1142,11 @@ func countFilterReasons(dropped []filteredXR) filterCounts { for _, d := range dropped { switch d.reason { - case renderer.FilterReasonManualPolicy: + case dt.FilterReasonManualPolicy: counts.byPolicy++ - case renderer.FilterReasonRevisionSelectorMismatch: + case dt.FilterReasonRevisionSelectorMismatch: counts.bySelector++ - case renderer.FilterReasonDeleting: + case dt.FilterReasonDeleting: counts.byDeletion++ } } @@ -1157,16 +1156,16 @@ func countFilterReasons(dropped []filteredXR) filterCounts { // applyTo writes the per-reason tallies onto a summary. Centralized so every code path that builds // an AffectedResourcesSummary reports the same set of reasons. -func (c filterCounts) applyTo(summary *renderer.AffectedResourcesSummary) { +func (c filterCounts) applyTo(summary *dt.AffectedResourcesSummary) { summary.FilteredByPolicy = c.byPolicy summary.FilteredBySelector = c.bySelector summary.FilteredByDeletion = c.byDeletion } // buildImpactAnalysis builds the impact analysis and summary from XR results. -func (p *DefaultCompDiffProcessor) buildImpactAnalysis(xrs []*un.Unstructured, results map[string]*XRDiffResult) ([]renderer.XRImpact, renderer.AffectedResourcesSummary) { - impacts := make([]renderer.XRImpact, 0, len(xrs)) - summary := renderer.AffectedResourcesSummary{ +func (p *DefaultCompDiffProcessor) buildImpactAnalysis(xrs []*un.Unstructured, results map[string]*XRDiffResult) ([]dt.XRImpact, dt.AffectedResourcesSummary) { + impacts := make([]dt.XRImpact, 0, len(xrs)) + summary := dt.AffectedResourcesSummary{ Total: len(xrs), } @@ -1174,7 +1173,7 @@ func (p *DefaultCompDiffProcessor) buildImpactAnalysis(xrs []*un.Unstructured, r resourceID := dt.MakeDiffKeyFromResource(xr) result := results[resourceID] - impact := renderer.XRImpact{ + impact := dt.XRImpact{ ObjectReference: corev1.ObjectReference{ APIVersion: xr.GetAPIVersion(), Kind: xr.GetKind(), @@ -1185,17 +1184,17 @@ func (p *DefaultCompDiffProcessor) buildImpactAnalysis(xrs []*un.Unstructured, r switch { case result != nil && result.HasError(): - impact.Status = renderer.XRStatusError + impact.Status = dt.XRStatusError // NewOutputError surfaces typed validation failures via OutputError.ValidationFailures // when result.Error wraps a SchemaValidationError carrying a structured Result. impact.Error = new(NewOutputError(fmt.Sprintf("%s/%s", impact.Kind, impact.Name), result.Error)) summary.WithErrors++ case result != nil && result.HasChanges(): - impact.Status = renderer.XRStatusChanged + impact.Status = dt.XRStatusChanged impact.Diffs = result.Diffs summary.WithChanges++ default: - impact.Status = renderer.XRStatusUnchanged + impact.Status = dt.XRStatusUnchanged summary.Unchanged++ } diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 353ba88f..8780c6f0 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -38,7 +38,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" "github.com/crossplane/cli/v2/internal/diff/renderer" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -742,7 +742,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { // against these to decide whether the XR would adopt the new revision. type droppedWant struct { name string - reason renderer.FilterReason + reason dt.FilterReason } tests := map[string]struct { @@ -801,7 +801,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: []string{"manual-xr"}, wantRepointing: nil, - wantDropped: []droppedWant{{name: "auto-mismatch", reason: renderer.FilterReasonRevisionSelectorMismatch}}, + wantDropped: []droppedWant{{name: "auto-mismatch", reason: dt.FilterReasonRevisionSelectorMismatch}}, // The only kept XR is the pinned Manual one, so nothing re-points. }, "IncludeManualFalse_FiltersManualXRs": { @@ -815,7 +815,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: []string{"auto-xr"}, wantRepointing: []string{"auto-xr"}, - wantDropped: []droppedWant{{name: "manual-xr", reason: renderer.FilterReasonManualPolicy}}, + wantDropped: []droppedWant{{name: "manual-xr", reason: dt.FilterReasonManualPolicy}}, }, // Automatic XR whose selector does not match the composition labels is dropped. "AutomaticSelectorMismatch_Dropped": { @@ -828,7 +828,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: nil, wantRepointing: nil, - wantDropped: []droppedWant{{name: "selector-old", reason: renderer.FilterReasonRevisionSelectorMismatch}}, + wantDropped: []droppedWant{{name: "selector-old", reason: dt.FilterReasonRevisionSelectorMismatch}}, }, // Automatic XR whose selector matches the composition labels is kept. "AutomaticSelectorMatch_Kept": { @@ -905,7 +905,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: nil, wantRepointing: nil, - wantDropped: []droppedWant{{name: "manual-match", reason: renderer.FilterReasonManualPolicy}}, + wantDropped: []droppedWant{{name: "manual-match", reason: dt.FilterReasonManualPolicy}}, }, "EmptyList_ReturnsEmpty": { includeManual: false, @@ -929,7 +929,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: []string{"live-xr"}, wantRepointing: []string{"live-xr"}, - wantDropped: []droppedWant{{name: "deleting-xr", reason: renderer.FilterReasonDeleting}}, + wantDropped: []droppedWant{{name: "deleting-xr", reason: dt.FilterReasonDeleting}}, }, // Deletion is evaluated before the policy rules and is not rescued by --include-manual: a // deleting Manual XR is reported as deleting, not as manual_policy. @@ -943,7 +943,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: nil, wantRepointing: nil, - wantDropped: []droppedWant{{name: "deleting-manual", reason: renderer.FilterReasonDeleting}}, + wantDropped: []droppedWant{{name: "deleting-manual", reason: dt.FilterReasonDeleting}}, }, // A matching compositionRevisionSelector does not rescue a deleting XR either. "DeletingWithMatchingSelector_Dropped": { @@ -957,7 +957,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: nil, wantRepointing: nil, - wantDropped: []droppedWant{{name: "deleting-match", reason: renderer.FilterReasonDeleting}}, + wantDropped: []droppedWant{{name: "deleting-match", reason: dt.FilterReasonDeleting}}, }, // An explicit null deletionTimestamp is how round-tripped Kubernetes YAML spells "unset"; // it must not be mistaken for a deleting XR. @@ -985,7 +985,7 @@ func TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy(t *testing.T) { }, wantKept: nil, wantRepointing: nil, - wantDropped: []droppedWant{{name: "selector-xr", reason: renderer.FilterReasonRevisionSelectorMismatch}}, + wantDropped: []droppedWant{{name: "selector-xr", reason: dt.FilterReasonRevisionSelectorMismatch}}, }, } @@ -1083,45 +1083,45 @@ func TestDefaultCompDiffProcessor_buildImpactAnalysis(t *testing.T) { tests := map[string]struct { result *XRDiffResult - wantImpact renderer.XRImpact - wantSummary renderer.AffectedResourcesSummary + wantImpact dt.XRImpact + wantSummary dt.AffectedResourcesSummary }{ // The impact's error is what both the per-XR entry and the top-level errors[] report, so it // must keep the typed validation breakdown rather than flatten it to a message. "ValidationErrorKeepsValidationFailures": { result: &XRDiffResult{Error: validationErr}, - wantImpact: renderer.XRImpact{ + wantImpact: dt.XRImpact{ ObjectReference: ref, - Status: renderer.XRStatusError, + Status: dt.XRStatusError, Error: &dt.OutputError{ ResourceID: "XResource/my-xr", Message: validationErr.Error(), ValidationFailures: failures, }, }, - wantSummary: renderer.AffectedResourcesSummary{Total: 1, WithErrors: 1}, + wantSummary: dt.AffectedResourcesSummary{Total: 1, WithErrors: 1}, }, "PlainErrorCarriesOnlyItsMessage": { result: &XRDiffResult{Error: errors.New("render failed")}, - wantImpact: renderer.XRImpact{ + wantImpact: dt.XRImpact{ ObjectReference: ref, - Status: renderer.XRStatusError, + Status: dt.XRStatusError, Error: &dt.OutputError{ResourceID: "XResource/my-xr", Message: "render failed"}, }, - wantSummary: renderer.AffectedResourcesSummary{Total: 1, WithErrors: 1}, + wantSummary: dt.AffectedResourcesSummary{Total: 1, WithErrors: 1}, }, "Changed": { result: &XRDiffResult{Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": modified}}, - wantImpact: renderer.XRImpact{ + wantImpact: dt.XRImpact{ ObjectReference: ref, - Status: renderer.XRStatusChanged, + Status: dt.XRStatusChanged, Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": modified}, }, - wantSummary: renderer.AffectedResourcesSummary{Total: 1, WithChanges: 1}, + wantSummary: dt.AffectedResourcesSummary{Total: 1, WithChanges: 1}, }, "NoResultIsUnchanged": { - wantImpact: renderer.XRImpact{ObjectReference: ref, Status: renderer.XRStatusUnchanged}, - wantSummary: renderer.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + wantImpact: dt.XRImpact{ObjectReference: ref, Status: dt.XRStatusUnchanged}, + wantSummary: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, }, } @@ -1134,7 +1134,7 @@ func TestDefaultCompDiffProcessor_buildImpactAnalysis(t *testing.T) { impacts, summary := (&DefaultCompDiffProcessor{}).buildImpactAnalysis([]*un.Unstructured{xr}, results) - if diff := gcmp.Diff([]renderer.XRImpact{tt.wantImpact}, impacts); diff != "" { + if diff := gcmp.Diff([]dt.XRImpact{tt.wantImpact}, impacts); diff != "" { t.Errorf("buildImpactAnalysis() impacts mismatch (-want +got):\n%s", diff) } @@ -1857,14 +1857,14 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { surfaceTests := map[string]struct { surfaceFiltered bool wantImpactCount int - wantStatus renderer.XRStatus // empty when wantImpactCount == 0 - wantReason renderer.FilterReason // empty when wantImpactCount == 0 + wantStatus dt.XRStatus // empty when wantImpactCount == 0 + wantReason dt.FilterReason // empty when wantImpactCount == 0 }{ "ResourceMode_SurfacesFilteredXRs": { surfaceFiltered: true, wantImpactCount: 1, - wantStatus: renderer.XRStatusFiltered, - wantReason: renderer.FilterReasonManualPolicy, + wantStatus: dt.XRStatusFiltered, + wantReason: dt.FilterReasonManualPolicy, }, "DefaultDiscovery_OmitsFilteredFromImpactAnalysis": { surfaceFiltered: false, @@ -1931,8 +1931,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { type want struct { Skipped bool - Summary renderer.AffectedResourcesSummary - RevisionImpact renderer.RevisionImpact + Summary dt.AffectedResourcesSummary + RevisionImpact dt.RevisionImpact // Impacts is every ImpactAnalysis entry as " ", in order — deliberately not // narrowed to the filtered ones. Under a skip the kept composites were never evaluated, so // ImpactAnalysis must hold only the filtered entries; a changed/unchanged/error entry here @@ -1950,8 +1950,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { analyzeOn: AnalyzeOnSpecChange, want: want{ Skipped: true, - Summary: renderer.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, - RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Summary: dt.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, }, }, "Skipped_ResourceMode_NamesFilteredComposites": { @@ -1959,8 +1959,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { surfaceFiltered: true, want: want{ Skipped: true, - Summary: renderer.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, - RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Summary: dt.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, Impacts: []string{"filtered manual-xr"}, }, }, @@ -1972,8 +1972,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { surfaceFiltered: true, want: want{ Skipped: true, - Summary: renderer.AffectedResourcesSummary{Total: 2}, - RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Summary: dt.AffectedResourcesSummary{Total: 2}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, }, }, // The same tally when the analysis does run: both composites are evaluated, one re-points. @@ -1983,8 +1983,8 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { includeManual: true, surfaceFiltered: true, want: want{ - Summary: renderer.AffectedResourcesSummary{Total: 2, Unchanged: 2}, - RevisionImpact: renderer.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Summary: dt.AffectedResourcesSummary{Total: 2, Unchanged: 2}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, Impacts: []string{"unchanged xr-1", "unchanged manual-xr"}, }, }, @@ -2067,12 +2067,12 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { } impact := got.ImpactAnalysis[0] - if impact.Status != renderer.XRStatusFiltered { - t.Errorf("Status: got %q, want %q", impact.Status, renderer.XRStatusFiltered) + if impact.Status != dt.XRStatusFiltered { + t.Errorf("Status: got %q, want %q", impact.Status, dt.XRStatusFiltered) } - if impact.FilterReason != renderer.FilterReasonRevisionSelectorMismatch { - t.Errorf("FilterReason: got %q, want %q", impact.FilterReason, renderer.FilterReasonRevisionSelectorMismatch) + if impact.FilterReason != dt.FilterReasonRevisionSelectorMismatch { + t.Errorf("FilterReason: got %q, want %q", impact.FilterReason, dt.FilterReasonRevisionSelectorMismatch) } if !strings.Contains(impact.FilterDetail, "does not match composition labels") { @@ -2096,7 +2096,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { // renderCompReport renders a DiffComposition report the way the comp command does, in the given format // (empty means the human-readable diff) and structured output version (empty means the default), and // returns what it wrote to stdout. -func renderCompReport(t *testing.T, output *renderer.CompDiffOutput, format renderer.OutputFormat, version renderer.OutputVersion) string { +func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.OutputFormat, version renderer.OutputVersion) string { t.Helper() if output == nil { diff --git a/internal/diff/diffprocessor/composite_resolver.go b/internal/diff/diffprocessor/composite_resolver.go index c16215bc..bba6c9ff 100644 --- a/internal/diff/diffprocessor/composite_resolver.go +++ b/internal/diff/diffprocessor/composite_resolver.go @@ -17,7 +17,7 @@ import ( clixrgen "github.com/crossplane/cli/v2/cmd/crossplane/xr" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // clusterWrittenFields are the Crossplane machinery fields of a composite that Crossplane, not the diff --git a/internal/diff/diffprocessor/diff_calculator.go b/internal/diff/diffprocessor/diff_calculator.go index e87e3dcc..5ed81b6b 100644 --- a/internal/diff/diffprocessor/diff_calculator.go +++ b/internal/diff/diffprocessor/diff_calculator.go @@ -18,7 +18,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" dtypes "github.com/crossplane/cli/v2/internal/diff/types" ) diff --git a/internal/diff/diffprocessor/diff_calculator_test.go b/internal/diff/diffprocessor/diff_calculator_test.go index 3d6ff124..2cbaccee 100644 --- a/internal/diff/diffprocessor/diff_calculator_test.go +++ b/internal/diff/diffprocessor/diff_calculator_test.go @@ -22,7 +22,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" dtypes "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -2198,7 +2198,7 @@ func TestDefaultDiffCalculator_preserveExistingResourceIdentity(t *testing.T) { // TestDefaultDiffCalculator_DegradedDryRunDetail covers what a degraded dry run records on the diff, // which is everything the renderer's summary of unverified additions is built from: one warning per -// GVK + namespace + distinct cause, counting its resources (see renderer.dryRunWarnings). +// GVK + namespace + distinct cause, counting its resources (see result.DryRunWarnings). // // For that grouping to work, dryRun.detail must be the cause and nothing else. The mock wraps its error // exactly as DefaultApplyClient.DryRunCreate does, naming the resource, because that wrapper is what diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 37419858..1385904f 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -30,7 +30,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -250,7 +250,7 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un. // Build one diff group per input XR/claim, in input order, so the renderer // can group structured output (xrs[]) and human output (per-XR sections) by - // the input that produced each change. See renderer/types.XRDiffGroup. + // the input that produced each change. See result.XRDiffGroup. groups := make([]dt.XRDiffGroup, 0, len(resources)) // Collect errors for the top-level/global (union) error list, preserved for diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index 700e11f4..18bc00ee 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -32,7 +32,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/diff/types" ) diff --git a/internal/diff/diffprocessor/errors.go b/internal/diff/diffprocessor/errors.go index 57bb4322..f7d8e7f9 100644 --- a/internal/diff/diffprocessor/errors.go +++ b/internal/diff/diffprocessor/errors.go @@ -23,7 +23,7 @@ import ( un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" ) @@ -90,7 +90,7 @@ func withInterruption(ctx context.Context, errs []dt.OutputError) []dt.OutputErr // output field. They are carried separately rather than synthesised into a // pkgvalidate.ValidationResult because that would mean inventing a // FieldErrorType value upstream does not define; ResourceValidationFailure is -// explicitly ours to populate (see its doc in renderer/types). +// explicitly ours to populate (see its doc in result). type SchemaValidationError struct { ResourceID string Message string diff --git a/internal/diff/diffprocessor/errors_test.go b/internal/diff/diffprocessor/errors_test.go index 7bb5a79c..640b36b0 100644 --- a/internal/diff/diffprocessor/errors_test.go +++ b/internal/diff/diffprocessor/errors_test.go @@ -28,7 +28,7 @@ import ( xperrors "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" ) diff --git a/internal/diff/diffprocessor/input_validator.go b/internal/diff/diffprocessor/input_validator.go index a0deb88b..dcdcc367 100644 --- a/internal/diff/diffprocessor/input_validator.go +++ b/internal/diff/diffprocessor/input_validator.go @@ -13,7 +13,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/types" ) diff --git a/internal/diff/diffprocessor/input_validator_test.go b/internal/diff/diffprocessor/input_validator_test.go index 4234ce05..36db75e2 100644 --- a/internal/diff/diffprocessor/input_validator_test.go +++ b/internal/diff/diffprocessor/input_validator_test.go @@ -11,7 +11,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index fcb2ac71..0221c6ef 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -8,7 +8,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) const ( diff --git a/internal/diff/diffprocessor/render_engine.go b/internal/diff/diffprocessor/render_engine.go index 8fbda8f4..44c35eda 100644 --- a/internal/diff/diffprocessor/render_engine.go +++ b/internal/diff/diffprocessor/render_engine.go @@ -38,7 +38,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/render" "github.com/crossplane/cli/v2/internal/diff/dockerrun" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // RenderFn is the render abstraction injected into diff processors. Callers diff --git a/internal/diff/diffprocessor/requirements_provider.go b/internal/diff/diffprocessor/requirements_provider.go index 64f42f3b..5af7c3e2 100644 --- a/internal/diff/diffprocessor/requirements_provider.go +++ b/internal/diff/diffprocessor/requirements_provider.go @@ -17,7 +17,7 @@ import ( xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // addUniqueResource adds a resource to the map if not already present. diff --git a/internal/diff/diffprocessor/resource_manager.go b/internal/diff/diffprocessor/resource_manager.go index 1baa605c..4ca5fa82 100644 --- a/internal/diff/diffprocessor/resource_manager.go +++ b/internal/diff/diffprocessor/resource_manager.go @@ -20,7 +20,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // ResourceManager handles resource-related operations like fetching, updating owner refs, diff --git a/internal/diff/diffprocessor/resource_manager_test.go b/internal/diff/diffprocessor/resource_manager_test.go index 053012e1..cdc264cd 100644 --- a/internal/diff/diffprocessor/resource_manager_test.go +++ b/internal/diff/diffprocessor/resource_manager_test.go @@ -18,7 +18,7 @@ import ( cmp "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) diff --git a/internal/diff/diffprocessor/warnings.go b/internal/diff/diffprocessor/warnings.go index 2536740e..e852eb48 100644 --- a/internal/diff/diffprocessor/warnings.go +++ b/internal/diff/diffprocessor/warnings.go @@ -24,7 +24,7 @@ import ( "strings" "sync" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // AdvisoryCollector is the CLI's dt.Advisories: every advisory raised is written to diff --git a/internal/diff/diffprocessor/warnings_test.go b/internal/diff/diffprocessor/warnings_test.go index 8f93d8c1..efcb0496 100644 --- a/internal/diff/diffprocessor/warnings_test.go +++ b/internal/diff/diffprocessor/warnings_test.go @@ -9,7 +9,7 @@ import ( gcmp "github.com/google/go-cmp/cmp" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // TestAdvisoryCollector_Raise covers the dual emission and the collector's identity rule. Each raised diff --git a/internal/diff/dockerrun/run.go b/internal/diff/dockerrun/run.go index 90c1edcf..502443a6 100644 --- a/internal/diff/dockerrun/run.go +++ b/internal/diff/dockerrun/run.go @@ -48,7 +48,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/cli/v2/cmd/crossplane/render" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) const ( diff --git a/internal/diff/dockerrun/run_test.go b/internal/diff/dockerrun/run_test.go index 55c7446e..61e61e54 100644 --- a/internal/diff/dockerrun/run_test.go +++ b/internal/diff/dockerrun/run_test.go @@ -33,7 +33,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/cli/v2/cmd/crossplane/render" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 8416a9b1..1f70e721 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -33,7 +33,7 @@ import ( dclient "github.com/crossplane/cli/v2/internal/diff/client" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) diff --git a/internal/diff/renderer/comp_diff_renderer.go b/internal/diff/renderer/comp_diff_renderer.go index 78635443..11f0bbf3 100644 --- a/internal/diff/renderer/comp_diff_renderer.go +++ b/internal/diff/renderer/comp_diff_renderer.go @@ -28,7 +28,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) const ( @@ -45,7 +45,7 @@ type CompDiffRenderer interface { // written to stderr: the processor returns them to the command, which // prints them once. Per-composition messages (diffs, "no changes", // per-composition errors) go to DiffOptions.Stdout as part of the diff narrative. - RenderCompDiff(output *CompDiffOutput) error + RenderCompDiff(output *dt.CompDiffOutput) error } // DefaultCompDiffRenderer renders composition diffs in human-readable format. @@ -67,7 +67,7 @@ func NewDefaultCompDiffRenderer(logger logging.Logger, diffRenderer DiffRenderer // RenderCompDiff renders the composition diff in human-readable format. // Per-composition output (diffs, status messages, per-composition errors) goes // to r.opts.Stdout. Top-level errors are not printed; see CompDiffRenderer. -func (r *DefaultCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { +func (r *DefaultCompDiffRenderer) RenderCompDiff(output *dt.CompDiffOutput) error { stdout := r.opts.Stdout for i, comp := range output.Compositions { @@ -84,14 +84,14 @@ func (r *DefaultCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { // One run-wide summary of unverified additions; see DiffRenderer.RenderDiffs for why it is derived // at render time, and why the per-composition reuse of the diff renderer above does not emit it. - return writeWarnings(r.opts.Stderr, compDryRunWarnings(output)) + return writeWarnings(r.opts.Stderr, dt.CompDryRunWarnings(output)) } // renderComposition renders every section belonging to a single composition: its own changes, // followed — only if it processed successfully — by the sections describing its effect on composites. // A composition that failed to process has no such sections to show; renderCompositionChanges // reported the error in their place. -func (r *DefaultCompDiffRenderer) renderComposition(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderComposition(comp *dt.CompositionDiff) error { switch { case r.opts.MinimizeComposition: if err := r.renderMinimizedCompositionChanges(comp); err != nil { @@ -113,7 +113,7 @@ func (r *DefaultCompDiffRenderer) renderComposition(comp *CompositionDiff) error // renderCompositionImpact renders the sections describing how a composition affects its composites: // either the note standing in for them when their evaluation was skipped, or the affected-XR list and // the downstream impact analysis. -func (r *DefaultCompDiffRenderer) renderCompositionImpact(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderCompositionImpact(comp *dt.CompositionDiff) error { if comp.ImpactAnalysisSkipped { return r.renderSkippedComposition(comp) } @@ -137,7 +137,7 @@ func (r *DefaultCompDiffRenderer) renderCompositionImpact(comp *CompositionDiff) // composition's XRs were deliberately not evaluated. Both sections would be empty and misleading in // that case, so say so once instead — then report the composites the tool did rule out locally, which // no render was needed to establish. -func (r *DefaultCompDiffRenderer) renderSkippedComposition(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderSkippedComposition(comp *dt.CompositionDiff) error { if _, err := fmt.Fprint(r.opts.Stdout, skippedMessage(comp.RevisionImpact)); err != nil { return errors.Wrap(err, "cannot write impact analysis skipped message") } @@ -146,7 +146,7 @@ func (r *DefaultCompDiffRenderer) renderSkippedComposition(comp *CompositionDiff } // renderCompositionChanges renders the composition changes section. -func (r *DefaultCompDiffRenderer) renderCompositionChanges(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderCompositionChanges(comp *dt.CompositionDiff) error { stdout := r.opts.Stdout if _, err := fmt.Fprintf(stdout, headerCompositionChanges+"\n\n"); err != nil { @@ -193,7 +193,7 @@ func (r *DefaultCompDiffRenderer) renderCompositionChanges(comp *CompositionDiff // CompositionRevision and so genuinely cannot affect anything, whereas a metadata-only change does // create one — the user simply asked not to pay for evaluating it. Reporting the first message for // the second case would claim a guarantee the tool did not establish. -func skippedMessage(impact RevisionImpact) string { +func skippedMessage(impact dt.RevisionImpact) string { if impact.CreatesRevision { return fmt.Sprintf("Impact analysis skipped: --analyze-on=spec-change and this composition's spec is unchanged. Applying it still creates a new CompositionRevision that %d composite%s would adopt; whether that changes any rendered output was not evaluated. Pass --analyze-on=any-change to check.\n\n", impact.RepointedComposites, pluralize(impact.RepointedComposites)) @@ -216,7 +216,7 @@ func skippedMessage(impact RevisionImpact) string { // entries, because the kept composites were never rendered. That is the processor's guarantee, pinned // by its SkipReportsFilterConsequences test. Re-filtering here would not add safety — it would hide an // unearned changed/unchanged verdict from this output while it still reached the structured output. -func (r *DefaultCompDiffRenderer) renderFilteredUnderSkip(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderFilteredUnderSkip(comp *dt.CompositionDiff) error { filtered := totalFiltered(comp.AffectedResources) if filtered == 0 { return nil @@ -262,7 +262,7 @@ func (r *DefaultCompDiffRenderer) renderFilteredUnderSkip(comp *CompositionDiff) // // The skipped path does not use this: skippedMessage carries the same fact in its own sentence, so // emitting both would state it twice. -func revisionImpactMessage(impact RevisionImpact) string { +func revisionImpactMessage(impact dt.RevisionImpact) string { if !impact.CreatesRevision || impact.RepointedComposites == 0 { return "" } @@ -276,7 +276,7 @@ func revisionImpactMessage(impact RevisionImpact) string { // identical, or it differs only in fields excluded from the diff. The latter is still a change — // applying it produces a new CompositionRevision that affected composites adopt — so it must not be // announced as "no changes". -func writeNoDisplayableChanges(stdout io.Writer, comp *CompositionDiff) error { +func writeNoDisplayableChanges(stdout io.Writer, comp *dt.CompositionDiff) error { if comp.MaskedChangesOnly { if _, err := fmt.Fprintf(stdout, "Composition %s differs only in fields excluded from this diff (--ignore-paths, or fields suppressed for readability). That is still a change: applying it creates a new CompositionRevision.\n\n", comp.Name); err != nil { return errors.Wrap(err, "cannot write masked-changes message") @@ -295,7 +295,7 @@ func writeNoDisplayableChanges(stdout io.Writer, comp *CompositionDiff) error { // renderMinimizedCompositionChanges renders a single marker line per composition // instead of the full YAML diff body. Errors and no-change compositions are // shown as usual (i.e., not minimized); only changed compositions are collapsed. -func (r *DefaultCompDiffRenderer) renderMinimizedCompositionChanges(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderMinimizedCompositionChanges(comp *dt.CompositionDiff) error { stdout := r.opts.Stdout if _, err := fmt.Fprintf(stdout, headerCompositionChanges+"\n\n"); err != nil { @@ -345,7 +345,7 @@ func (r *DefaultCompDiffRenderer) renderMinimizedCompositionChanges(comp *Compos } // renderAffectedResourcesList renders the affected XRs list with status indicators. -func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *dt.CompositionDiff) error { stdout := r.opts.Stdout if len(comp.ImpactAnalysis) == 0 { @@ -385,7 +385,7 @@ func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *CompositionD } // renderImpactAnalysis renders the impact analysis section with downstream diffs. -func (r *DefaultCompDiffRenderer) renderImpactAnalysis(comp *CompositionDiff) error { +func (r *DefaultCompDiffRenderer) renderImpactAnalysis(comp *dt.CompositionDiff) error { stdout := r.opts.Stdout if _, err := fmt.Fprintf(stdout, headerImpactAnalysis+"\n\n"); err != nil { @@ -396,7 +396,7 @@ func (r *DefaultCompDiffRenderer) renderImpactAnalysis(comp *CompositionDiff) er allDiffs := make(map[string]*dt.ResourceDiff) for _, impact := range comp.ImpactAnalysis { - if impact.Status == XRStatusChanged && impact.Diffs != nil { + if impact.Status == dt.XRStatusChanged && impact.Diffs != nil { for key, diff := range impact.Diffs { // Skip equal diffs (may be stored for removal detection purposes) if diff.DiffType != dt.DiffTypeEqual { @@ -425,7 +425,7 @@ func (r *DefaultCompDiffRenderer) renderImpactAnalysis(comp *CompositionDiff) er // totalFiltered sums the per-reason filter counters. Kept in one place so adding a reason cannot // leave a caller silently ignoring it. -func totalFiltered(summary AffectedResourcesSummary) int { +func totalFiltered(summary dt.AffectedResourcesSummary) int { return summary.FilteredByPolicy + summary.FilteredBySelector + summary.FilteredByDeletion } @@ -434,7 +434,7 @@ func totalFiltered(summary AffectedResourcesSummary) int { // nothing is shown and how to see more. Single-reason cases get bespoke prose that names the // remedy; mixed reasons fall through to an enumerated breakdown. Only called when at least one // XR was filtered. -func allFilteredMessage(compName string, summary AffectedResourcesSummary) string { +func allFilteredMessage(compName string, summary dt.AffectedResourcesSummary) string { byPolicy, bySelector, byDeletion := summary.FilteredByPolicy, summary.FilteredBySelector, summary.FilteredByDeletion total := totalFiltered(summary) @@ -458,7 +458,7 @@ func allFilteredMessage(compName string, summary AffectedResourcesSummary) strin // "1 with Manual update policy (use --include-manual to see them), 2 being deleted"), omitting reasons // with no XRs. Shared by allFilteredMessage's mixed-reason case and renderFilteredUnderSkip so a newly // added reason is described identically in both. -func filteredClauses(summary AffectedResourcesSummary) string { +func filteredClauses(summary dt.AffectedResourcesSummary) string { clauses := make([]string, 0, 3) for _, c := range []struct { @@ -480,17 +480,17 @@ func filteredClauses(summary AffectedResourcesSummary) string { // filteredSuffix returns the human-readable explanation appended to a filtered XR line, chosen by // the XR's FilterReason. Selector-mismatch entries additionally surface the concrete FilterDetail // hint (which selector failed to match which labels) so users can self-diagnose the exclusion. -func filteredSuffix(impact XRImpact) string { +func filteredSuffix(impact dt.XRImpact) string { switch impact.FilterReason { - case FilterReasonManualPolicy: + case dt.FilterReasonManualPolicy: return " — filtered: Manual update policy (use --include-manual to evaluate)" - case FilterReasonRevisionSelectorMismatch: + case dt.FilterReasonRevisionSelectorMismatch: if impact.FilterDetail != "" { return fmt.Sprintf(" — filtered: revision selector mismatch (%s)", impact.FilterDetail) } return " — filtered: revision selector mismatch" - case FilterReasonDeleting: + case dt.FilterReasonDeleting: if impact.FilterDetail != "" { return fmt.Sprintf(" — filtered: being deleted (%s)", impact.FilterDetail) } @@ -502,7 +502,7 @@ func filteredSuffix(impact XRImpact) string { } // buildXRStatusList builds the XR list with status indicators. -func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []XRImpact) string { +func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []dt.XRImpact) string { var sb strings.Builder // Color codes and indicators @@ -535,16 +535,16 @@ func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []XRImpact) string { ) switch impact.Status { - case XRStatusError: + case dt.XRStatusError: indicator = errorMark color = colorRed - case XRStatusChanged: + case dt.XRStatusChanged: indicator = warningMark color = colorYellow - case XRStatusUnchanged: + case dt.XRStatusUnchanged: indicator = checkMark color = colorGreen - case XRStatusFiltered: + case dt.XRStatusFiltered: indicator = "⊘" // ⊘ color = colorYellow suffix = filteredSuffix(impact) @@ -558,7 +558,7 @@ func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []XRImpact) string { colorReset) // Include error details for XRStatusError impacts so users can diagnose issues. - if impact.Status == XRStatusError && impact.Error != nil { + if impact.Status == dt.XRStatusError && impact.Error != nil { fmt.Fprintf(&sb, "%s Error: %s%s\n", color, impact.Error.Message, colorReset) } } @@ -583,10 +583,10 @@ func NewStructuredCompDiffRenderer(logger logging.Logger, opts DiffOptions) Comp // RenderCompDiff renders the composition diff in structured format (JSON/YAML) // to r.opts.Stdout. Top-level errors go into the payload only; see // CompDiffRenderer. -func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) error { +func (r *StructuredCompDiffRenderer) RenderCompDiff(output *dt.CompDiffOutput) error { // One run-wide summary of unverified additions, derived here from the diffs and appended after the // warnings raised during the run. See DiffRenderer.RenderDiffs. - dryRun := compDryRunWarnings(output) + dryRun := dt.CompDryRunWarnings(output) // Convert internal representation to JSON output structure warnings := append(slices.Clone(output.Warnings), dryRun...) @@ -632,7 +632,7 @@ func (r *StructuredCompDiffRenderer) RenderCompDiff(output *CompDiffOutput) erro // buildStructuredCompOutput converts internal CompDiffOutput to the v2 document, from which v1 is // derived (see compDiffWireV2.v1). Each failed composition's error is appended to the top-level // errors, after the processor's, so that list is the union of every error in the document. -func buildStructuredCompOutput(output *CompDiffOutput) *compDiffWireV2 { +func buildStructuredCompOutput(output *dt.CompDiffOutput) *compDiffWireV2 { result := &compDiffWireV2{ OutputVersion: OutputVersionV2, Compositions: make([]compositionDiffWireV2, 0, len(output.Compositions)), @@ -650,8 +650,8 @@ func buildStructuredCompOutput(output *CompDiffOutput) *compDiffWireV2 { // Omit revisionImpact entirely when the comparison never completed, rather than serializing a // zero value that would claim changeScope "" and createsRevision false. See - // RevisionImpact.determined and issue #479. - if comp.RevisionImpact.determined() { + // RevisionImpact.Determined and issue #479. + if comp.RevisionImpact.Determined() { revisionImpact := comp.RevisionImpact jsonComp.RevisionImpact = &revisionImpact } @@ -734,7 +734,7 @@ func (e xrEntryWire) v1() xrImpactWire { Error: firstErrorMessage(e.Errors), } - if e.Status == XRStatusChanged && len(e.Changes) > 0 { + if e.Status == dt.XRStatusChanged && len(e.Changes) > 0 { result.DownstreamChanges = &DownstreamChanges{Summary: e.Summary, Changes: e.Changes} } diff --git a/internal/diff/renderer/comp_diff_renderer_test.go b/internal/diff/renderer/comp_diff_renderer_test.go index 67c7ba56..801be593 100644 --- a/internal/diff/renderer/comp_diff_renderer_test.go +++ b/internal/diff/renderer/comp_diff_renderer_test.go @@ -12,14 +12,14 @@ import ( un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) // testCompDiffFixture defines a reusable test case with input and expected output. type testCompDiffFixture struct { name string - output *CompDiffOutput + output *dt.CompDiffOutput minimize bool validate func(t *testing.T, format OutputFormat, result string) } @@ -36,7 +36,7 @@ func sharedCompDiffFixtures() []testCompDiffFixture { return []testCompDiffFixture{ { name: "EmptyCompositions", - output: &CompDiffOutput{Compositions: []CompositionDiff{}}, + output: &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{}}, validate: func(t *testing.T, format OutputFormat, result string) { t.Helper() @@ -56,8 +56,8 @@ func sharedCompDiffFixtures() []testCompDiffFixture { }, { name: "WithChanges", - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", CompositionDiff: &dt.ResourceDiff{ DiffType: dt.DiffTypeAdded, @@ -65,10 +65,10 @@ func sharedCompDiffFixtures() []testCompDiffFixture { Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), }, - AffectedResources: AffectedResourcesSummary{Total: 2, WithChanges: 1, Unchanged: 1}, - ImpactAnalysis: []XRImpact{ - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusChanged}, - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-2"}, Status: XRStatusUnchanged}, + AffectedResources: dt.AffectedResourcesSummary{Total: 2, WithChanges: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{ + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: dt.XRStatusChanged}, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-2"}, Status: dt.XRStatusUnchanged}, }, }}, }, @@ -158,8 +158,8 @@ func sharedCompDiffFixtures() []testCompDiffFixture { // (JSON/YAML) output must always retain full compositionChanges. name: "MinimizeKeepsStructuredFidelity", minimize: true, - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", CompositionDiff: &dt.ResourceDiff{ DiffType: dt.DiffTypeModified, @@ -168,8 +168,8 @@ func sharedCompDiffFixtures() []testCompDiffFixture { Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), }, - AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, - ImpactAnalysis: []XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusUnchanged}}, + AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: dt.XRStatusUnchanged}}, }}, }, validate: func(t *testing.T, format OutputFormat, result string) { @@ -200,18 +200,18 @@ func sharedCompDiffFixtures() []testCompDiffFixture { // the tool never evaluated. The object is omitted instead; a composition that *was* compared // keeps it, including when the composites went unevaluated. name: "FailedCompositionOmitsRevisionImpact", - output: &CompDiffOutput{ - Compositions: []CompositionDiff{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{ { Name: "compared.example.org", ImpactAnalysisSkipped: true, - RevisionImpact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, - ImpactAnalysis: []XRImpact{}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + ImpactAnalysis: []dt.XRImpact{}, }, { Name: "broken.example.org", Error: &dt.OutputError{Message: "cannot calculate composition diff"}, - ImpactAnalysis: []XRImpact{}, + ImpactAnalysis: []dt.XRImpact{}, }, }, }, @@ -238,7 +238,7 @@ func sharedCompDiffFixtures() []testCompDiffFixture { compared, broken := parsed.Compositions[0], parsed.Compositions[1] - want := &RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1} + want := &dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1} if diff := gcmp.Diff(want, compared.RevisionImpact); diff != "" { t.Errorf("compared composition revisionImpact mismatch (-want +got):\n%s", diff) } @@ -284,13 +284,13 @@ func TestStructuredCompDiffRenderer_RenderCompDiff(t *testing.T) { func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { tests := map[string]struct { - output *CompDiffOutput + output *dt.CompDiffOutput colorize bool minimize bool validate func(t *testing.T, result string) }{ "EmptyCompositions": { - output: &CompDiffOutput{Compositions: []CompositionDiff{}}, + output: &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{}}, colorize: false, validate: func(t *testing.T, result string) { t.Helper() @@ -301,11 +301,11 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { }, }, "NoChangesComposition": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", - AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, - ImpactAnalysis: []XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusUnchanged}}, + AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: dt.XRStatusUnchanged}}, }}, }, colorize: false, @@ -326,11 +326,11 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { }, }, "FilteredByPolicy": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", - AffectedResources: AffectedResourcesSummary{FilteredByPolicy: 3}, - ImpactAnalysis: []XRImpact{}, + AffectedResources: dt.AffectedResourcesSummary{FilteredByPolicy: 3}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -343,11 +343,11 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { }, }, "FilteredBySelectorOnly": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", - AffectedResources: AffectedResourcesSummary{FilteredBySelector: 2}, - ImpactAnalysis: []XRImpact{}, + AffectedResources: dt.AffectedResourcesSummary{FilteredBySelector: 2}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -360,11 +360,11 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { }, }, "FilteredByPolicyAndSelector": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", - AffectedResources: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2}, - ImpactAnalysis: []XRImpact{}, + AffectedResources: dt.AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -377,17 +377,17 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { }, }, "CompositionWithError": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "error-comp", Error: &dt.OutputError{Message: "failed to fetch composition from cluster"}, - AffectedResources: AffectedResourcesSummary{ + AffectedResources: dt.AffectedResourcesSummary{ Total: 0, WithChanges: 0, Unchanged: 0, WithErrors: 0, }, - ImpactAnalysis: []XRImpact{}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -416,12 +416,12 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { "MinimizeChangedComposition": { // A changed composition collapses to a single marker line (no YAML body, // no per-composition Summary footer), while the impact sections remain. - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", CompositionDiff: &dt.ResourceDiff{DiffType: dt.DiffTypeModified, ResourceName: "test-comp", Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}}, - AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, - ImpactAnalysis: []XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: XRStatusUnchanged}}, + AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: dt.XRStatusUnchanged}}, }}, }, colorize: false, @@ -454,13 +454,13 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { // render, so a skipped impact analysis must still report it. What the skip withholds is the // changed/unchanged/errored summary — that verdict needed the renders it did not run. "SkippedAnalysisStillReportsFilteredComposites": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", ImpactAnalysisSkipped: true, - RevisionImpact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, - AffectedResources: AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredByDeletion: 1}, - ImpactAnalysis: []XRImpact{}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + AffectedResources: dt.AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -490,16 +490,16 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { // individually with their reason and detail — the affordance issue #457 shipped, which the skip // used to discard. "SkippedAnalysisNamesFilteredCompositesInResourceMode": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", ImpactAnalysisSkipped: true, - RevisionImpact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true}, - AffectedResources: AffectedResourcesSummary{Total: 1, FilteredByDeletion: 1}, - ImpactAnalysis: []XRImpact{{ + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true}, + AffectedResources: dt.AffectedResourcesSummary{Total: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []dt.XRImpact{{ ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "my-deleting-xr", Namespace: "default"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonDeleting, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonDeleting, FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", }}, }}, @@ -522,13 +522,13 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { }, // Nothing was filtered, so the skip note stands alone — no "0 of 0" line. "SkippedAnalysisWithNothingFilteredSaysNoMore": { - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", ImpactAnalysisSkipped: true, - RevisionImpact: RevisionImpact{ChangeScope: "none"}, - AffectedResources: AffectedResourcesSummary{Total: 2}, - ImpactAnalysis: []XRImpact{}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "none"}, + AffectedResources: dt.AffectedResourcesSummary{Total: 2}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -547,11 +547,11 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { "MinimizeErrorStillSurfaces": { // A processing error must render in full even when minimized: only the // diff body is collapsed, never the error. - output: &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "error-comp", Error: &dt.OutputError{Message: "boom"}, - ImpactAnalysis: []XRImpact{}, + ImpactAnalysis: []dt.XRImpact{}, }}, }, colorize: false, @@ -595,8 +595,8 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { // that top-level errors in CompDiffOutput.Errors are written to neither stdout // nor stderr: the processor returns them to the command, which prints them once. func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsNotPrinted(t *testing.T) { - output := &CompDiffOutput{ - Compositions: []CompositionDiff{}, + output := &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{}, Errors: []dt.OutputError{ {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, {Message: "cluster connection lost"}, @@ -635,8 +635,8 @@ func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsNotPrinted(t *test // that top-level errors in CompDiffOutput.Errors are included in the structured // output but not written to stderr. func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsInOutputOnly(t *testing.T) { - output := &CompDiffOutput{ - Compositions: []CompositionDiff{}, + output := &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{}, Errors: []dt.OutputError{ {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, {Message: "cluster connection lost"}, @@ -732,8 +732,8 @@ func Test_pluralize(t *testing.T) { // serializes correctly, YAML will too. YAML format coverage is provided by // TestStructuredCompDiffRenderer_RenderCompDiff. func TestCompDiffOutput_JSONSchema(t *testing.T) { - output := &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output := &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "xbuckets.example.org", CompositionDiff: &dt.ResourceDiff{ DiffType: dt.DiffTypeModified, @@ -742,11 +742,11 @@ func TestCompDiffOutput_JSONSchema(t *testing.T) { Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), }, - AffectedResources: AffectedResourcesSummary{Total: 5, WithChanges: 2, Unchanged: 2, WithErrors: 1}, - ImpactAnalysis: []XRImpact{ + AffectedResources: dt.AffectedResourcesSummary{Total: 5, WithChanges: 2, Unchanged: 2, WithErrors: 1}, + ImpactAnalysis: []dt.XRImpact{ { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-1"}, - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Diffs: map[string]*dt.ResourceDiff{ "s3.aws.upbound.io/v1beta1/Bucket//new-bucket": { DiffType: dt.DiffTypeAdded, @@ -763,8 +763,8 @@ func TestCompDiffOutput_JSONSchema(t *testing.T) { }, }, }, - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-2"}, Status: XRStatusUnchanged}, - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3"}, Status: XRStatusError, Error: &dt.OutputError{ResourceID: "XBucket/bucket-3", Message: "render failed"}}, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-2"}, Status: dt.XRStatusUnchanged}, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3"}, Status: dt.XRStatusError, Error: &dt.OutputError{ResourceID: "XBucket/bucket-3", Message: "render failed"}}, }, }}, } @@ -832,26 +832,26 @@ func TestCompDiffOutput_JSONSchema(t *testing.T) { } func TestXRStatusFiltered_JSON(t *testing.T) { - output := &CompDiffOutput{ - Compositions: []CompositionDiff{{ + output := &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "test-comp", - AffectedResources: AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, - ImpactAnalysis: []XRImpact{ + AffectedResources: dt.AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []dt.XRImpact{ { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonManualPolicy, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonManualPolicy, }, { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonRevisionSelectorMismatch, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "compositionRevisionSelector {version: 0.0.1} does not match composition labels {version: 0.0.2}", }, { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "deleting-xr", Namespace: "ns"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonDeleting, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonDeleting, FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", }, }, @@ -927,25 +927,25 @@ func TestXRStatusFiltered_JSON(t *testing.T) { } func TestXRStatusFiltered_TextRenderer(t *testing.T) { - comp := CompositionDiff{ + comp := dt.CompositionDiff{ Name: "test-comp", - AffectedResources: AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, - ImpactAnalysis: []XRImpact{ + AffectedResources: dt.AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, + ImpactAnalysis: []dt.XRImpact{ { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonManualPolicy, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonManualPolicy, }, { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonRevisionSelectorMismatch, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "compositionRevisionSelector {version: 0.0.1} does not match composition labels {version: 0.0.2}", }, { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "deleting-xr", Namespace: "ns"}, - Status: XRStatusFiltered, - FilterReason: FilterReasonDeleting, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonDeleting, FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", }, }, @@ -990,35 +990,35 @@ func TestXRStatusFiltered_TextRenderer(t *testing.T) { // cannot silently go unreported. func Test_allFilteredMessage(t *testing.T) { tests := map[string]struct { - summary AffectedResourcesSummary + summary dt.AffectedResourcesSummary want string }{ "PolicyOnly": { - summary: AffectedResourcesSummary{FilteredByPolicy: 2}, + summary: dt.AffectedResourcesSummary{FilteredByPolicy: 2}, want: "All 2 XR(s) using composition test-comp have Manual update policy (use --include-manual to see them)", }, "SelectorOnly": { - summary: AffectedResourcesSummary{FilteredBySelector: 3}, + summary: dt.AffectedResourcesSummary{FilteredBySelector: 3}, want: "All 3 XR(s) using composition test-comp have a compositionRevisionSelector that does not match the composition's labels, so they would not adopt this revision", }, "DeletionOnly": { - summary: AffectedResourcesSummary{FilteredByDeletion: 1}, + summary: dt.AffectedResourcesSummary{FilteredByDeletion: 1}, want: "All 1 XR(s) using composition test-comp are being deleted, so they would not adopt this revision", }, "PolicyAndSelector": { - summary: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2}, + summary: dt.AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2}, want: "All 3 XR(s) using composition test-comp were filtered: 1 with Manual update policy (use --include-manual to see them), 2 with a compositionRevisionSelector that does not match the composition's labels", }, "PolicyAndDeletion": { - summary: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredByDeletion: 1}, + summary: dt.AffectedResourcesSummary{FilteredByPolicy: 1, FilteredByDeletion: 1}, want: "All 2 XR(s) using composition test-comp were filtered: 1 with Manual update policy (use --include-manual to see them), 1 being deleted", }, "SelectorAndDeletion": { - summary: AffectedResourcesSummary{FilteredBySelector: 1, FilteredByDeletion: 2}, + summary: dt.AffectedResourcesSummary{FilteredBySelector: 1, FilteredByDeletion: 2}, want: "All 3 XR(s) using composition test-comp were filtered: 1 with a compositionRevisionSelector that does not match the composition's labels, 2 being deleted", }, "AllThreeReasons": { - summary: AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2, FilteredByDeletion: 3}, + summary: dt.AffectedResourcesSummary{FilteredByPolicy: 1, FilteredBySelector: 2, FilteredByDeletion: 3}, want: "All 6 XR(s) using composition test-comp were filtered: 1 with Manual update policy (use --include-manual to see them), 2 with a compositionRevisionSelector that does not match the composition's labels, 3 being deleted", }, } @@ -1039,13 +1039,13 @@ func Test_allFilteredMessage(t *testing.T) { // never established, which is the whole reason skippedMessage branches at all. func TestSkippedMessage(t *testing.T) { tests := map[string]struct { - impact RevisionImpact + impact dt.RevisionImpact // wantContains are substrings that must appear; wantOmits must not. wantContains []string wantOmits []string }{ "IdenticalCompositionMakesTheStrongClaim": { - impact: RevisionImpact{ChangeScope: "none", CreatesRevision: false}, + impact: dt.RevisionImpact{ChangeScope: "none", CreatesRevision: false}, wantContains: []string{ "identical to the cluster's", "creates no new CompositionRevision", @@ -1056,7 +1056,7 @@ func TestSkippedMessage(t *testing.T) { wantOmits: []string{"was not evaluated", "not predictable"}, }, "MetadataOnlyChangeMakesOnlyTheWeakClaim": { - impact: RevisionImpact{ + impact: dt.RevisionImpact{ ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 2, @@ -1077,7 +1077,7 @@ func TestSkippedMessage(t *testing.T) { }, }, "SingleCompositeIsNotPluralized": { - impact: RevisionImpact{ + impact: dt.RevisionImpact{ ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1, @@ -1112,17 +1112,17 @@ func TestSkippedMessage(t *testing.T) { // when there is no revision to name. func TestRevisionImpactMessage(t *testing.T) { tests := map[string]struct { - impact RevisionImpact + impact dt.RevisionImpact want string wantContains []string wantOmits []string }{ "NoRevisionSaysNothing": { - impact: RevisionImpact{ChangeScope: "none", CreatesRevision: false}, + impact: dt.RevisionImpact{ChangeScope: "none", CreatesRevision: false}, want: "", }, "ReportsTheChurnWithoutTheHash": { - impact: RevisionImpact{ + impact: dt.RevisionImpact{ ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 3, @@ -1140,7 +1140,7 @@ func TestRevisionImpactMessage(t *testing.T) { // The line's only job is explaining a downstream diff that a template reading the revision // name would cause. Nothing adopts this revision, so there is no such diff, and the sections // around it already report the composition as changed. - impact: RevisionImpact{ + impact: dt.RevisionImpact{ ChangeScope: "spec", CreatesRevision: true, PredictedRevisionName: "xbuckets.example.org-abc1234", @@ -1151,7 +1151,7 @@ func TestRevisionImpactMessage(t *testing.T) { // Nothing was seeded here, so a revision-observing change would go undetected — but that is // said on stderr as a warning, which is the channel for "this could not be checked". The // stdout line reports the same churn either way, so it needs no variant. - impact: RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + impact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, wantContains: []string{"creates a new CompositionRevision", "1 composite would adopt"}, wantOmits: []string{"not predictable"}, }, @@ -1189,7 +1189,7 @@ func TestRevisionImpactMessage(t *testing.T) { // exactly once rather than twice (skippedMessage carries its own sentence, and both would be emitted if // the early-continue were removed). It also pins that the hash never reaches stdout by either route. func TestRenderCompDiff_RevisionImpactLine(t *testing.T) { - impact := RevisionImpact{ + impact := dt.RevisionImpact{ ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1, @@ -1197,25 +1197,25 @@ func TestRenderCompDiff_RevisionImpactLine(t *testing.T) { } tests := map[string]struct { - comp CompositionDiff + comp dt.CompositionDiff // wantCount is how many times the churn is reported: "composite would adopt" appears in both // revisionImpactMessage and skippedMessage, so it counts either route. wantCount int }{ "EvaluatedCompositionReportsChurnOnce": { - comp: CompositionDiff{ + comp: dt.CompositionDiff{ Name: "xbuckets.example.org", RevisionImpact: impact, - AffectedResources: AffectedResourcesSummary{Total: 1, Unchanged: 1}, - ImpactAnalysis: []XRImpact{{ + AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{{ ObjectReference: corev1.ObjectReference{Kind: "XBucket", Name: "test-xr"}, - Status: XRStatusUnchanged, + Status: dt.XRStatusUnchanged, }}, }, wantCount: 1, }, "SkippedCompositionReportsChurnOnce": { - comp: CompositionDiff{ + comp: dt.CompositionDiff{ Name: "xbuckets.example.org", RevisionImpact: impact, ImpactAnalysisSkipped: true, @@ -1223,9 +1223,9 @@ func TestRenderCompDiff_RevisionImpactLine(t *testing.T) { wantCount: 1, }, "UnchangedCompositionReportsChurnNever": { - comp: CompositionDiff{ + comp: dt.CompositionDiff{ Name: "xbuckets.example.org", - RevisionImpact: RevisionImpact{ChangeScope: "none", CreatesRevision: false}, + RevisionImpact: dt.RevisionImpact{ChangeScope: "none", CreatesRevision: false}, }, wantCount: 0, }, @@ -1242,7 +1242,7 @@ func TestRenderCompDiff_RevisionImpactLine(t *testing.T) { r := NewDefaultCompDiffRenderer(tu.TestLogger(t, false), NewDiffRenderer(tu.TestLogger(t, false), opts), opts) - if err := r.RenderCompDiff(&CompDiffOutput{Compositions: []CompositionDiff{tt.comp}}); err != nil { + if err := r.RenderCompDiff(&dt.CompDiffOutput{Compositions: []dt.CompositionDiff{tt.comp}}); err != nil { t.Fatalf("RenderCompDiff() unexpected error: %v", err) } @@ -1261,15 +1261,15 @@ func TestRenderCompDiff_RevisionImpactLine(t *testing.T) { } func TestCompositionDiff_HasChanges_FilteredOnly(t *testing.T) { - c := &CompositionDiff{ - ImpactAnalysis: []XRImpact{ - {Status: XRStatusFiltered, FilterReason: FilterReasonManualPolicy}, - {Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch}, - {Status: XRStatusFiltered, FilterReason: FilterReasonDeleting}, + c := &dt.CompositionDiff{ + ImpactAnalysis: []dt.XRImpact{ + {Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonManualPolicy}, + {Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch}, + {Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonDeleting}, }, } if c.HasChanges() { - t.Errorf("CompositionDiff with only filtered impacts should not be HasChanges()") + t.Errorf("dt.CompositionDiff with only filtered impacts should not be HasChanges()") } } @@ -1298,26 +1298,26 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { ValidationFailures: []dt.ResourceValidationFailure{{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3", Status: "invalid", Errors: []dt.FieldValidationError{{Type: "schema", Field: "spec.size", Message: "must be positive"}}}}, } compErr := &dt.OutputError{ResourceID: "Composition/xbroken.example.org", Message: "cannot fetch composition"} - revisionImpact := RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 3} - affected := AffectedResourcesSummary{Total: 4, WithChanges: 1, Unchanged: 1, WithErrors: 1, FilteredBySelector: 1} + revisionImpact := dt.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 3} + affected := dt.AffectedResourcesSummary{Total: 4, WithChanges: 1, Unchanged: 1, WithErrors: 1, FilteredBySelector: 1} - output := &CompDiffOutput{ - Compositions: []CompositionDiff{ + output := &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{ { Name: "xbuckets.example.org", AffectedResources: affected, RevisionImpact: revisionImpact, - ImpactAnalysis: []XRImpact{ - {ObjectReference: ref("bucket-1"), Status: XRStatusChanged, Diffs: map[string]*dt.ResourceDiff{"s3.aws.upbound.io/v1beta1/Bucket//new-bucket": addedBucket}}, - {ObjectReference: ref("bucket-2"), Status: XRStatusUnchanged}, - {ObjectReference: ref("bucket-3"), Status: XRStatusError, Error: xrErr}, - {ObjectReference: ref("bucket-4"), Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, + ImpactAnalysis: []dt.XRImpact{ + {ObjectReference: ref("bucket-1"), Status: dt.XRStatusChanged, Diffs: map[string]*dt.ResourceDiff{"s3.aws.upbound.io/v1beta1/Bucket//new-bucket": addedBucket}}, + {ObjectReference: ref("bucket-2"), Status: dt.XRStatusUnchanged}, + {ObjectReference: ref("bucket-3"), Status: dt.XRStatusError, Error: xrErr}, + {ObjectReference: ref("bucket-4"), Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, }, }, { Name: "xbroken.example.org", Error: compErr, - ImpactAnalysis: []XRImpact{}, + ImpactAnalysis: []dt.XRImpact{}, }, }, // The processor lists each errored XR's OutputError at top level. @@ -1338,10 +1338,10 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { AffectedResources: affected, RevisionImpact: &revisionImpact, ImpactAnalysis: []xrImpactWire{ - {ObjectReference: ref("bucket-1"), Status: XRStatusChanged, DownstreamChanges: &DownstreamChanges{Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}}, - {ObjectReference: ref("bucket-2"), Status: XRStatusUnchanged}, - {ObjectReference: ref("bucket-3"), Status: XRStatusError, Error: "render failed"}, - {ObjectReference: ref("bucket-4"), Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, + {ObjectReference: ref("bucket-1"), Status: dt.XRStatusChanged, DownstreamChanges: &DownstreamChanges{Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}}, + {ObjectReference: ref("bucket-2"), Status: dt.XRStatusUnchanged}, + {ObjectReference: ref("bucket-3"), Status: dt.XRStatusError, Error: "render failed"}, + {ObjectReference: ref("bucket-4"), Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, }, }, { @@ -1364,10 +1364,10 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { AffectedResources: affected, RevisionImpact: &revisionImpact, ImpactAnalysis: []xrEntryWire{ - {XR: ref("bucket-1"), Status: XRStatusChanged, Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}, - {XR: ref("bucket-2"), Status: XRStatusUnchanged, Changes: []ChangeDetail{}}, - {XR: ref("bucket-3"), Status: XRStatusError, Changes: []ChangeDetail{}, Errors: []dt.OutputError{*xrErr}}, - {XR: ref("bucket-4"), Status: XRStatusFiltered, FilterReason: FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch", Changes: []ChangeDetail{}}, + {XR: ref("bucket-1"), Status: dt.XRStatusChanged, Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}, + {XR: ref("bucket-2"), Status: dt.XRStatusUnchanged, Changes: []ChangeDetail{}}, + {XR: ref("bucket-3"), Status: dt.XRStatusError, Changes: []ChangeDetail{}, Errors: []dt.OutputError{*xrErr}}, + {XR: ref("bucket-4"), Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch", Changes: []ChangeDetail{}}, }, }, { diff --git a/internal/diff/renderer/diff_formatter.go b/internal/diff/renderer/diff_formatter.go index 156345bc..2e46c8d0 100644 --- a/internal/diff/renderer/diff_formatter.go +++ b/internal/diff/renderer/diff_formatter.go @@ -9,7 +9,7 @@ import ( "github.com/sergi/go-diff/diffmatchpatch" - t "github.com/crossplane/cli/v2/internal/diff/renderer/types" + t "github.com/crossplane/cli/v2/internal/diff/result" ) // DiffOptions holds configuration options for the diff output. diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index 08d20b07..3ab63c7b 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -10,7 +10,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // flattenGroups merges every group's diffs into a single map, for the human @@ -43,7 +43,7 @@ type DiffRenderer interface { // order with the work that produced them. The human renderer therefore ignores this parameter. // // One kind of warning is instead derived here, from the groups: the summary of added resources - // that could not be verified against the apiserver (see dryRunWarnings). Deciding what to summarise + // that could not be verified against the apiserver (see dt.DryRunWarnings). Deciding what to summarise // is a presentation decision, and the DryRunInfo it is built from only exists once the diffs do. // Every renderer writes that summary to stderr; a structured one also appends it to warnings[], // after the warnings passed in. @@ -249,7 +249,7 @@ func (r *DefaultDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.Out return err } - return writeWarnings(r.diffOpts.Stderr, xrDryRunWarnings(xrGroups)) + return writeWarnings(r.diffOpts.Stderr, dt.XRDryRunWarnings(xrGroups)) } // renderFlat renders a single flat block of diffs plus a "Summary:" line. This diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index 8cbb65a0..32e42914 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -9,7 +9,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime/schema" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) diff --git a/internal/diff/renderer/dry_run_warnings.go b/internal/diff/renderer/dry_run_warnings.go index bcb768cb..4eefdd0c 100644 --- a/internal/diff/renderer/dry_run_warnings.go +++ b/internal/diff/renderer/dry_run_warnings.go @@ -1,201 +1,14 @@ package renderer import ( - "cmp" "fmt" "io" - "maps" - "slices" - "strconv" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) -// dryRunSkipSummary is how one dt.DryRunSkipReason is summarised for a human: the warning's message, -// and the context key its DryRunInfo.Detail is reported under (none for a reason without a cause). -type dryRunSkipSummary struct { - message string - detailKey string -} - -// storageVersionNote is appended to the summary of additions whose CRD stores them at a version other -// than the one requested (dt.DryRunInfo.StorageVersion). Their local prediction defaulted against the -// requested version only, so the storage version's defaults, and any conversion through it, are not -// in their diffs. The versions are reported under the `versions` context key. -const storageVersionNote = "; they are stored at a version other than the one requested, whose defaults and conversion the cluster may apply but their diffs do not" - -// summaryFor returns how the additions of group g are summarised, and false for a group that is not -// worth telling the user about. Additions stored at a version other than the one requested get -// storageVersionNote appended to their reason's message. -// -// dt.DryRunSkipDisabled is summarised only for additions stored at another version: -// --dry-run-on=existing is the user's own choice, so the additions it leaves unverified are not news to -// them, but that the cluster stores some at a version the prediction did not default against is. -func summaryFor(g dryRunGroup) (dryRunSkipSummary, bool) { - var s dryRunSkipSummary - - switch g.reason { - case dt.DryRunSkipForbidden: - s = dryRunSkipSummary{ - message: "skipped apiserver verification of added resources: not authorized to create them, so their diffs omit server-side defaulting and admission", - detailKey: "reason", - } - case dt.DryRunSkipNamespaceNotFound: - s = dryRunSkipSummary{ - message: "skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission", - detailKey: "cause", - } - case dt.DryRunSkipWebhookUnavailable: - s = dryRunSkipSummary{ - message: "skipped apiserver verification of added resources: the cluster could not complete admission", - detailKey: "cause", - } - case dt.DryRunSkipDisabled: - if g.storageVersion == "" { - return dryRunSkipSummary{}, false - } - - // The user's own choice has no cause, so there is no detail key. - s = dryRunSkipSummary{message: "did not verify added resources against the apiserver (--dry-run-on=existing)"} - default: - return dryRunSkipSummary{}, false - } - - if g.storageVersion != "" { - s.message += storageVersionNote - } - - return s, true -} - -// dryRunGroup is what one summary warning stands for: additions of one GVK in one namespace that were -// not verified for one reason and one cause, and are stored at one version. -// -// storageVersion never splits a group in practice: one GVK has one CRD, and so one storage version. It -// is part of the key so that the note a warning carries is always true of every resource it counts. -type dryRunGroup struct { - gvk string - version string // the requested version, already part of gvk; kept for the `versions` key - namespace string - reason dt.DryRunSkipReason - detail string - storageVersion string -} - -// dryRunWarnings derives, from the DryRunInfo carried on the supplied diffs, the warnings that tell a -// human which added resources could not be verified against the apiserver, and why. -// -// It is the only place a human sees this: the text renderer does not show DryRunInfo. So there is one -// warning per GVK + namespace + distinct cause, rather than per reason, because grouping any coarser -// would keep one cause and silently drop the rest. Each warning counts the resources behind it under -// the `count` context key and, for additions stored at a version other than the one requested, names -// both versions under the `versions` context key. A resource that appears in more than one set (two -// overlapping inputs that render the same object) is one resource and is counted once. -// -// The result is sorted by GVK, namespace, reason, cause and storage version, since diffs arrive in map -// order. It is nil -// when there is nothing to report. -// -// The per-resource dryRun field in structured output stays the machine-readable record: a warning has -// no resource anchor, so it cannot tell a pipeline WHICH additions were degraded. -func dryRunWarnings(diffSets ...map[string]*dt.ResourceDiff) []dt.OutputWarning { - resources := make(map[dryRunGroup]map[string]struct{}) - - for _, diffs := range diffSets { - for _, d := range diffs { - if d.DryRun == nil { - continue - } - - g := dryRunGroup{ - gvk: d.Gvk.String(), - version: d.Gvk.Version, - namespace: d.Namespace, - reason: d.DryRun.SkipReason, - detail: d.DryRun.Detail, - storageVersion: d.DryRun.StorageVersion, - } - - if _, summarised := summaryFor(g); !summarised { - continue - } - - if resources[g] == nil { - resources[g] = make(map[string]struct{}) - } - - resources[g][d.GetDiffKey()] = struct{}{} - } - } - - if len(resources) == 0 { - return nil - } - - groups := slices.SortedFunc(maps.Keys(resources), func(a, b dryRunGroup) int { - return cmp.Or( - cmp.Compare(a.gvk, b.gvk), - cmp.Compare(a.namespace, b.namespace), - cmp.Compare(a.reason, b.reason), - cmp.Compare(a.detail, b.detail), - cmp.Compare(a.storageVersion, b.storageVersion), - ) - }) - - warnings := make([]dt.OutputWarning, 0, len(groups)) - - for _, g := range groups { - s, _ := summaryFor(g) - - context := map[string]string{ - "gvk": g.gvk, - "namespace": g.namespace, - "count": strconv.Itoa(len(resources[g])), - } - if s.detailKey != "" { - context[s.detailKey] = g.detail - } - - if g.storageVersion != "" { - context["versions"] = fmt.Sprintf("requested %s, stored %s", g.version, g.storageVersion) - } - - warnings = append(warnings, dt.OutputWarning{Message: s.message, Context: context}) - } - - return warnings -} - -// xrDryRunWarnings is dryRunWarnings over the diffs an xr run emits, across every input. -// -// A failed input's diffs are withheld, so nothing is summarised for it: the summary says how far to -// trust the diffs that are shown, and a failed input shows none. Its error is reported instead. -func xrDryRunWarnings(groups []dt.XRDiffGroup) []dt.OutputWarning { - sets := make([]map[string]*dt.ResourceDiff, 0, len(groups)) - for _, g := range groups { - sets = append(sets, g.Diffs) - } - - return dryRunWarnings(sets...) -} - -// compDryRunWarnings is dryRunWarnings over the downstream diffs a comp run emits, across every -// composition: one run-wide summary rather than one per composition, matching where warnings[] lives. -// As for xr, a failed XR's diffs are withheld and so are not summarised. -func compDryRunWarnings(output *CompDiffOutput) []dt.OutputWarning { - var sets []map[string]*dt.ResourceDiff - - for _, comp := range output.Compositions { - for _, impact := range comp.ImpactAnalysis { - sets = append(sets, impact.Diffs) - } - } - - return dryRunWarnings(sets...) -} - // writeWarnings writes each warning to w as its human-readable stderr line. // // Only warnings derived at render time belong here. Those raised during the run were written when they diff --git a/internal/diff/renderer/dry_run_warnings_test.go b/internal/diff/renderer/dry_run_warnings_test.go index 47b8e642..c982d02c 100644 --- a/internal/diff/renderer/dry_run_warnings_test.go +++ b/internal/diff/renderer/dry_run_warnings_test.go @@ -10,18 +10,13 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime/schema" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) const ( - forbiddenSummary = "skipped apiserver verification of added resources: not authorized to create them, so their diffs omit server-side defaulting and admission" namespaceSummary = "skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission" - webhookSummary = "skipped apiserver verification of added resources: the cluster could not complete admission" - storageNote = "; they are stored at a version other than the one requested, whose defaults and conversion the cluster may apply but their diffs do not" - disabledSummary = "did not verify added resources against the apiserver (--dry-run-on=existing)" testResourceGVK = "example.org/v1, Kind=TestResource" - otherResourceGVK = "example.org/v1, Kind=OtherResource" ) // unverifiedAddition builds the diff of an added resource whose dry-run create was skipped for reason, @@ -36,14 +31,6 @@ func unverifiedAddition(kind, name, namespace string, reason dt.DryRunSkipReason } } -// storedAt makes d an addition requested at version and stored, by its CRD, at storageVersion. -func storedAt(d *dt.ResourceDiff, version, storageVersion string) *dt.ResourceDiff { - d.Gvk.Version = version - d.DryRun.StorageVersion = storageVersion - - return d -} - // diffMap keys diffs the way the diff calculator does. func diffMap(diffs ...*dt.ResourceDiff) map[string]*dt.ResourceDiff { out := make(map[string]*dt.ResourceDiff, len(diffs)) @@ -54,145 +41,6 @@ func diffMap(diffs ...*dt.ResourceDiff) map[string]*dt.ResourceDiff { return out } -// TestDryRunWarnings covers how the advisory for unverified additions is derived from the DryRunInfo -// on the diffs: one warning per GVK + namespace + distinct cause, counting the resources behind it, and -// no cause ever dropped. The text renderer shows no DryRunInfo, so this warning is the only place a -// human learns that an addition was not verified, or why. -func TestDryRunWarnings(t *testing.T) { - tests := map[string]struct { - reason string - diffSets []map[string]*dt.ResourceDiff - want []dt.OutputWarning - }{ - "ForbiddenCollapsesPerGVKAndNamespace": { - reason: "Four denied additions across three GVK+namespace groups raise three warnings, each counting its resources.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipForbidden, "no create"), - unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipForbidden, "no create"), - unverifiedAddition("TestResource", "c", "ns-b", dt.DryRunSkipForbidden, "no create"), - unverifiedAddition("OtherResource", "d", "ns-a", dt.DryRunSkipForbidden, "no create"), - )}, - want: []dt.OutputWarning{ - {Message: forbiddenSummary, Context: map[string]string{"gvk": otherResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "1"}}, - {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "2"}}, - {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-b", "reason": "no create", "count": "1"}}, - }, - }, - "DistinctCausesAreAllKept": { - reason: "Two different apiserver causes in one GVK+namespace group are two warnings. Grouping on reason alone would keep one and silently drop the other.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipWebhookUnavailable, `failed calling webhook "policy.example.org"`), - unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipWebhookUnavailable, `failed calling webhook "quota.example.org"`), - unverifiedAddition("TestResource", "c", "ns-a", dt.DryRunSkipWebhookUnavailable, `failed calling webhook "policy.example.org"`), - )}, - want: []dt.OutputWarning{ - {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `failed calling webhook "policy.example.org"`, "count": "2"}}, - {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `failed calling webhook "quota.example.org"`, "count": "1"}}, - }, - }, - "MissingNamespaceAcrossInputsCountsEachResourceOnce": { - reason: "Additions from several inputs share one warning. A resource two overlapping inputs both render is one resource, so it is counted once.", - diffSets: []map[string]*dt.ResourceDiff{ - diffMap( - unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), - unverifiedAddition("TestResource", "shared", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), - ), - diffMap( - unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), - unverifiedAddition("TestResource", "shared", "ns-a", dt.DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), - ), - }, - want: []dt.OutputWarning{ - {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `namespaces "ns-a" not found`, "count": "3"}}, - }, - }, - "ClusterScopedResourceKeepsAnEmptyNamespace": { - reason: "A cluster-scoped addition still carries the namespace key, empty, so every summary has the same context keys.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - unverifiedAddition("TestResource", "a", "", dt.DryRunSkipForbidden, "no create"), - )}, - want: []dt.OutputWarning{ - {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "", "reason": "no create", "count": "1"}}, - }, - }, - "ReasonsAreOrderedDeterministically": { - reason: "Diffs arrive in map order, so the summary is sorted: by GVK, then namespace, then reason, then cause.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipWebhookUnavailable, "timeout"), - unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipForbidden, "no create"), - unverifiedAddition("TestResource", "c", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone"), - )}, - want: []dt.OutputWarning{ - {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "1"}}, - {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "1"}}, - {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "timeout", "count": "1"}}, - }, - }, - "DisabledIsNotWarnedAbout": { - reason: "--dry-run-on=existing is the user's own choice, so the additions it leaves unverified are not news to them.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipDisabled, ""), - )}, - want: nil, - }, - "StorageVersionIsNotedOnTheGroupsWarning": { - reason: "Additions requested at a version their CRD does not store say which version it stores, on the warning their group already raises, without splitting the group.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - storedAt(unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipForbidden, "no create"), "v1alpha1", "v1"), - storedAt(unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipForbidden, "no create"), "v1alpha1", "v1"), - )}, - want: []dt.OutputWarning{ - {Message: forbiddenSummary + storageNote, Context: map[string]string{ - "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "reason": "no create", "versions": "requested v1alpha1, stored v1", "count": "2", - }}, - }, - }, - "StorageVersionNoteStaysWithItsOwnVersion": { - reason: "Additions of one kind at the storage version and at another version are already two GVKs, so only the one at the other version carries the note.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - storedAt(unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone"), "v1alpha1", "v1"), - unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipNamespaceNotFound, "gone"), - )}, - want: []dt.OutputWarning{ - {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "1"}}, - {Message: namespaceSummary + storageNote, Context: map[string]string{ - "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "cause": "gone", "versions": "requested v1alpha1, stored v1", "count": "1", - }}, - }, - }, - "DisabledIsWarnedAboutForAStorageVersion": { - reason: "--dry-run-on=existing is the user's choice, but that the cluster stores the additions at another version is not, so it is news worth a warning.", - diffSets: []map[string]*dt.ResourceDiff{diffMap( - storedAt(unverifiedAddition("TestResource", "a", "ns-a", dt.DryRunSkipDisabled, ""), "v1alpha1", "v1"), - unverifiedAddition("TestResource", "b", "ns-a", dt.DryRunSkipDisabled, ""), - )}, - want: []dt.OutputWarning{ - {Message: disabledSummary + storageNote, Context: map[string]string{ - "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "versions": "requested v1alpha1, stored v1", "count": "1", - }}, - }, - }, - "VerifiedDiffsRaiseNothing": { - reason: "A diff without DryRunInfo was verified, and there is nothing to say about it.", - diffSets: []map[string]*dt.ResourceDiff{diffMap(&dt.ResourceDiff{ - Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, - Namespace: "ns-a", - ResourceName: "a", - DiffType: dt.DiffTypeAdded, - })}, - want: nil, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - if d := cmp.Diff(tc.want, dryRunWarnings(tc.diffSets...)); d != "" { - t.Errorf("\n%s\ndryRunWarnings() mismatch (-want +got):\n%s", tc.reason, d) - } - }) - } -} - // TestRenderers_EmitDryRunWarnings covers every entry point a command renders through. Each derives the // summary from what it is handed, writes it to stderr in every output mode, and adds it to warnings[] in // structured modes after the warnings raised during the run. The summary spans inputs, so two XRs' @@ -213,14 +61,14 @@ func TestRenderers_EmitDryRunWarnings(t *testing.T) { {XR: corev1.ObjectReference{Kind: "XR", Name: "broken"}, Err: &dt.OutputError{ResourceID: "XR/broken", Message: "boom"}}, } - compOutput := func() *CompDiffOutput { - return &CompDiffOutput{ - Compositions: []CompositionDiff{{ + compOutput := func() *dt.CompDiffOutput { + return &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ Name: "xrs.example.org", - ImpactAnalysis: []XRImpact{ - {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "one"}, Status: XRStatusChanged, Diffs: diffMap(first)}, - {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "two"}, Status: XRStatusChanged, Diffs: diffMap(second)}, - {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "broken"}, Status: XRStatusError}, + ImpactAnalysis: []dt.XRImpact{ + {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "one"}, Status: dt.XRStatusChanged, Diffs: diffMap(first)}, + {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "two"}, Status: dt.XRStatusChanged, Diffs: diffMap(second)}, + {ObjectReference: corev1.ObjectReference{Kind: "XR", Name: "broken"}, Status: dt.XRStatusError}, }, }}, Warnings: []dt.OutputWarning{raised}, diff --git a/internal/diff/renderer/structured_renderer.go b/internal/diff/renderer/structured_renderer.go index 740b525b..c8fe4de4 100644 --- a/internal/diff/renderer/structured_renderer.go +++ b/internal/diff/renderer/structured_renderer.go @@ -13,7 +13,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // OutputFormat represents the desired output format for diffs. @@ -43,44 +43,6 @@ const ( OutputVersionV2 OutputVersion = "v2" ) -// XRStatus represents the processing status of an XR in composition diffs. -type XRStatus string - -const ( - // XRStatusChanged indicates the XR has downstream resource changes. - XRStatusChanged XRStatus = "changed" - // XRStatusUnchanged indicates the XR has no downstream resource changes. - XRStatusUnchanged XRStatus = "unchanged" - // XRStatusError indicates an error occurred while processing the XR. - XRStatusError XRStatus = "error" - // XRStatusFiltered indicates the XR matched the composition by name but was excluded from - // evaluation because it would not adopt the composition change being diffed. The specific cause - // is carried separately in XRImpact.FilterReason (outcome and reason are intentionally divorced - // so the reason set can grow without expanding the status enum). The XR is surfaced in impact - // analysis with no downstream changes so users see the skip explicitly. - XRStatusFiltered XRStatus = "filtered" -) - -// FilterReason explains why an XRImpact has XRStatusFiltered. It is only meaningful when -// XRImpact.Status == XRStatusFiltered. -type FilterReason string - -const ( - // FilterReasonManualPolicy indicates the XR was excluded because it has a Manual - // compositionUpdatePolicy and --include-manual was not set. Such XRs are pinned to a specific - // revision and would not adopt the composition change automatically. - FilterReasonManualPolicy FilterReason = "manual_policy" - // FilterReasonRevisionSelectorMismatch indicates the XR was excluded because it has an Automatic - // compositionUpdatePolicy with a compositionRevisionSelector that does not match the labels of - // the composition change being diffed. Such XRs would not select the resulting revision. - FilterReasonRevisionSelectorMismatch FilterReason = "revision_selector_mismatch" - // FilterReasonDeleting indicates the XR was excluded because it has a metadata.deletionTimestamp. - // Crossplane's composite reconciler takes its deletion path for such an XR — it tears down - // composed resources rather than composing them — so the XR will never adopt the resulting - // revision, and rendering it produces no composed resources to diff against. - FilterReasonDeleting FilterReason = "deleting" -) - // OutputError is an alias for dt.OutputError for convenience. // Use this type for error handling in structured output. type OutputError = dt.OutputError @@ -121,7 +83,7 @@ type StructuredDiffOutput struct { // gating on failure should read Errors. Warnings are not grouped per input XR because they // originate deep in the call stack, below the point where the owning input is known. They are // sorted by message, then context, followed by the renderer's summary of unverified additions (see - // dryRunWarnings). + // dt.DryRunWarnings). Warnings []dt.OutputWarning `json:"warnings,omitempty"` } @@ -161,8 +123,8 @@ func (d structuredDiffOutputV2) v1(changes []ChangeDetail) StructuredDiffOutput // command lists in its top-level errors[]. type xrEntryWire struct { XR corev1.ObjectReference `json:"xr"` - Status XRStatus `json:"status"` - FilterReason FilterReason `json:"filterReason,omitempty"` + Status dt.XRStatus `json:"status"` + FilterReason dt.FilterReason `json:"filterReason,omitempty"` FilterDetail string `json:"filterDetail,omitempty"` Summary Summary `json:"summary"` Changes []ChangeDetail `json:"changes"` @@ -171,7 +133,7 @@ type xrEntryWire struct { // toXREntry builds the per-XR entry both commands emit. An errored XR carries err; a changed XR its // non-equal diffs. Other statuses get the zero summary and an empty change list. -func toXREntry(ref corev1.ObjectReference, status XRStatus, diffs map[string]*dt.ResourceDiff, err *dt.OutputError) xrEntryWire { +func toXREntry(ref corev1.ObjectReference, status dt.XRStatus, diffs map[string]*dt.ResourceDiff, err *dt.OutputError) xrEntryWire { // Project to exactly apiVersion/kind/name/namespace. We use ObjectReference as the type (so // consumers can unmarshal "xr" into the platform-standard struct), but deliberately omit its other // fields: fieldPath is meaningless for an XR; uid is unstable across delete/recreate and would @@ -189,13 +151,13 @@ func toXREntry(ref corev1.ObjectReference, status XRStatus, diffs map[string]*dt } switch status { - case XRStatusChanged: + case dt.XRStatusChanged: entry.Summary, entry.Changes = buildChangeSet(diffs) - case XRStatusError: + case dt.XRStatusError: if err != nil { entry.Errors = []dt.OutputError{*err} } - case XRStatusUnchanged, XRStatusFiltered: + case dt.XRStatusUnchanged, dt.XRStatusFiltered: // No changes or errors to report. } @@ -204,14 +166,14 @@ func toXREntry(ref corev1.ObjectReference, status XRStatus, diffs map[string]*dt // xrGroupStatus derives an xr input's status: error if it failed, changed if it has any non-equal // diff, unchanged otherwise. -func xrGroupStatus(g dt.XRDiffGroup) XRStatus { +func xrGroupStatus(g dt.XRDiffGroup) dt.XRStatus { switch { case g.Err != nil: - return XRStatusError + return dt.XRStatusError case slices.ContainsFunc(slices.Collect(maps.Values(g.Diffs)), func(d *dt.ResourceDiff) bool { return d.DiffType != dt.DiffTypeEqual }): - return XRStatusChanged + return dt.XRStatusChanged default: - return XRStatusUnchanged + return dt.XRStatusUnchanged } } @@ -269,179 +231,6 @@ type ChangeDetail struct { DryRun *dt.DryRunInfo `json:"dryRun,omitempty"` } -// CompDiffOutput is the top-level output for composition diffs (internal representation). -// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. -type CompDiffOutput struct { - Compositions []CompositionDiff - Errors []dt.OutputError // top-level errors (e.g., XRs that failed impact analysis) - // Warnings are non-fatal advisories raised during the run. Like the xr command's, they do not - // affect the exit code and are not attributed to a single composition — they are raised below the - // point where the owning composition is known. They have already been written to stderr when - // raised; this field carries them into structured output. The summary of unverified additions is - // not among them: the renderer derives it from the diffs (see compDryRunWarnings). - Warnings []dt.OutputWarning -} - -// CompositionDiff represents the diff result for a single composition (internal). -// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. -type CompositionDiff struct { - Name string - // Error is the per-composition processing error (nil if successful). Its ResourceID is - // "Composition/". - Error *dt.OutputError - CompositionDiff *dt.ResourceDiff // the actual composition diff (nil if unchanged) - AffectedResources AffectedResourcesSummary - ImpactAnalysis []XRImpact - // ImpactAnalysisSkipped records that the affected XRs were deliberately not evaluated, because - // the composition changed by less than --analyze-on asked to analyse. Distinguishes "we did not - // look" from "we looked and found no affected XRs", which would otherwise be indistinguishable. - // - // It does not mean ImpactAnalysis is empty. Composites excluded by update policy, revision - // selector or deletion are identified locally, with no render, so they are reported whether or not - // the analysis ran — in --resource mode as XRStatusFiltered entries here, and always via the - // AffectedResources filter counters (issue #478). What a skip suppresses is the renders, and - // therefore any changed/unchanged/errored verdict about the composites that were kept. - // - // Why the composites went unevaluated is not recoverable from this field alone — read - // RevisionImpact.ChangeScope alongside it. A "none" scope means no CompositionRevision is created - // and so nothing could have changed; anything else means a revision is created and simply was not - // evaluated. - ImpactAnalysisSkipped bool - // MaskedChangesOnly records that the composition differs from the cluster's only in fields - // excluded from the rendered diff — the user's --ignore-paths, or the comparison's display-only - // suppressions. CompositionDiff is nil in that case, but the composition did change, so the - // renderer must not report it as unchanged. - MaskedChangesOnly bool - // RevisionImpact is what applying this composition does to CompositionRevisions, independent of - // whether anything renders differently. Populated whenever the comparison completed, including - // when the composites were not evaluated; left zero for a composition that failed to process, - // where nothing about it was established (see RevisionImpact.determined). - RevisionImpact RevisionImpact -} - -// RevisionImpact describes what applying a composition does to CompositionRevisions and the -// composites tracking them, independent of whether any composed resource renders differently. -// -// This is deliberately a typed field rather than a warning. Revision churn is a per-composition -// fact a CI consumer may want to gate on, and warnings are documented as neither attributed to a -// composition nor intended for gating — so a flat warning could not say which of several diffed -// compositions creates a revision. -type RevisionImpact struct { - // ChangeScope is how much of the composition differs, in the terms Crossplane uses to decide - // whether a revision is needed: "none", "metadata" or "spec". Crossplane's Composition.Hash() - // covers labels and annotations as well as spec, so "metadata" still means a revision is created. - ChangeScope string `json:"changeScope"` - // CreatesRevision records whether applying this composition produces a new CompositionRevision. - CreatesRevision bool `json:"createsRevision"` - // RepointedComposites counts the composites that would adopt that revision and reconcile as a - // result: those with an Automatic (or defaulted) compositionUpdatePolicy whose - // compositionRevisionSelector, if any, selects the resulting revision, and which are not being - // deleted. A Manual-policy composite is never counted — it is pinned by compositionRevisionRef, so - // --include-manual keeping it for analysis does not make it re-point (issue #479). - // - // Note this counts composites that re-point, which is not the same as composites whose rendered - // output changes; re-pointing alone usually renders identically. - RepointedComposites int `json:"repointedComposites"` - // PredictedRevisionName is what that revision would be called: "-", derived - // from Crossplane's own Composition.Hash(). When CreatesRevision is false it names the existing - // revision the composites already track, which is the same name by construction — an unchanged - // composition hashes to the same value. - // - // It is what makes a downstream change interpretable: a composed resource whose template reads the - // revision name shows a diff with no other visible cause, and this is the cause. The composites are - // rendered with this value seeded onto their compositionRevisionRef, so such a change is detected - // rather than assumed away. - // - // Absent with CreatesRevision true means the name could not be predicted, which happens when the - // composition differs from the cluster's only by kubectl's last-applied-configuration annotation: - // its post-apply value is a function of how the user applies rather than of the file, so the hash — - // and therefore the name — is unknowable. A warning says so, and nothing is seeded in that case, so - // a revision-observing change would go undetected. Note also that for a composition applied - // client-side *with* other edits the suffix is predicted from the file as supplied and so may differ - // from the eventual one; the change is still detected, its predicted value is just imprecise. - PredictedRevisionName string `json:"predictedRevisionName,omitempty"` -} - -// determined reports whether the comparison that populates a RevisionImpact actually ran. ChangeScope -// is set unconditionally alongside the other fields and every real scope is non-empty, so an empty -// scope means nothing here was established. That is the case for a composition that failed to process: -// serializing the zero value would put changeScope outside its documented enum and would assert -// createsRevision: false, a positive claim about an apply the tool never evaluated (issue #479). -func (i RevisionImpact) determined() bool { - return i.ChangeScope != "" -} - -// HasChanges returns true if this composition diff has any changes, which is what drives -// ExitCodeDiffDetected. -// -// Deliberately excluded: RevisionImpact. A composition whose only difference is in masked fields -// creates a CompositionRevision but has nothing to show and nothing rendering differently, and a -// GitOps loop re-applying the same manifests would otherwise fail its diff gate on every run. A -// consumer that does want to gate on revision churn reads revisionImpact.createsRevision from the -// structured output. -func (c *CompositionDiff) HasChanges() bool { - if c.CompositionDiff != nil && c.CompositionDiff.DiffType != dt.DiffTypeEqual { - return true - } - - for _, impact := range c.ImpactAnalysis { - if impact.Status == XRStatusChanged { - return true - } - } - - return false -} - -// HasChanges returns true if any composition in the output has changes (see CompositionDiff.HasChanges). -func (o *CompDiffOutput) HasChanges() bool { - for i := range o.Compositions { - if o.Compositions[i].HasChanges() { - return true - } - } - - return false -} - -// AffectedResourcesSummary contains counts of affected resources by status. -type AffectedResourcesSummary struct { - Total int `json:"total"` - WithChanges int `json:"withChanges"` - Unchanged int `json:"unchanged"` - WithErrors int `json:"withErrors"` - // FilteredByPolicy counts XRs excluded because of a Manual compositionUpdatePolicy - // (FilterReasonManualPolicy). - FilteredByPolicy int `json:"filteredByPolicy,omitempty"` - // FilteredBySelector counts XRs excluded because their compositionRevisionSelector does not match - // the diffed composition's labels (FilterReasonRevisionSelectorMismatch). Kept separate from - // FilteredByPolicy so the breakdown is visible even in default-discovery mode, where individual - // XR impacts are not surfaced. - FilteredBySelector int `json:"filteredBySelector,omitempty"` - // FilteredByDeletion counts XRs excluded because they are being deleted - // (FilterReasonDeleting). Kept separate from the other filter counters for the same reason: - // the breakdown stays visible in default-discovery mode. - FilteredByDeletion int `json:"filteredByDeletion,omitempty"` -} - -// XRImpact represents the impact analysis for a single XR (internal). -// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. -// Embeds corev1.ObjectReference for the common resource identity fields. -type XRImpact struct { - corev1.ObjectReference - - Status XRStatus - // FilterReason explains a Status == XRStatusFiltered outcome; empty otherwise. - FilterReason FilterReason - // FilterDetail is an optional human-readable explanation for a filtered outcome (e.g. which - // selector failed to match which labels), surfaced to help users self-diagnose the exclusion. - FilterDetail string - // Error is the structured processing error for an XRStatusError impact (nil otherwise), the same - // OutputError the command lists in its top-level errors. - Error *dt.OutputError - Diffs map[string]*dt.ResourceDiff // downstream diffs (nil if unchanged/error) -} - // --- JSON Output Types (used by StructuredCompDiffRenderer) --- // Note: Only JSON tags are used because sigs.k8s.io/yaml uses JSON tags for YAML serialization. @@ -455,11 +244,11 @@ type compDiffWire struct { } type compositionDiffWire struct { - Name string `json:"name"` - Error string `json:"error,omitempty"` - CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` - AffectedResources AffectedResourcesSummary `json:"affectedResources"` - ImpactAnalysis []xrImpactWire `json:"impactAnalysis"` + Name string `json:"name"` + Error string `json:"error,omitempty"` + CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` + AffectedResources dt.AffectedResourcesSummary `json:"affectedResources"` + ImpactAnalysis []xrImpactWire `json:"impactAnalysis"` // ImpactAnalysisSkipped tells consumers that no changed/unchanged/errored verdict was reached for // the composites that would adopt the resulting revision, rather than that none were found. It // does not imply impactAnalysis is empty: composites excluded by update policy, revision selector @@ -474,7 +263,7 @@ type compositionDiffWire struct { // whether anything renders differently. Present whenever the comparison completed — including when // impactAnalysis was skipped, which is the point of it — and absent for a composition that failed // to process, where the `error` field says why nothing about the revision was determined. - RevisionImpact *RevisionImpact `json:"revisionImpact,omitempty"` + RevisionImpact *dt.RevisionImpact `json:"revisionImpact,omitempty"` } // compDiffWireV2 is the v2 (OutputVersionV2) composition diff document. Its Errors also lists each @@ -490,22 +279,22 @@ type compDiffWireV2 struct { // and impactAnalysis[] entries of the shared xrEntryWire type. See compositionDiffWire for the // meaning of the other fields. type compositionDiffWireV2 struct { - Name string `json:"name"` - Errors []dt.OutputError `json:"errors,omitempty"` - CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` - AffectedResources AffectedResourcesSummary `json:"affectedResources"` - ImpactAnalysis []xrEntryWire `json:"impactAnalysis"` - ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` - MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` - RevisionImpact *RevisionImpact `json:"revisionImpact,omitempty"` + Name string `json:"name"` + Errors []dt.OutputError `json:"errors,omitempty"` + CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` + AffectedResources dt.AffectedResourcesSummary `json:"affectedResources"` + ImpactAnalysis []xrEntryWire `json:"impactAnalysis"` + ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` + MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` + RevisionImpact *dt.RevisionImpact `json:"revisionImpact,omitempty"` } // xrImpactWire is the v1 (OutputVersionV1) impactAnalysis[] entry; v2 uses xrEntryWire instead. type xrImpactWire struct { corev1.ObjectReference `json:",inline"` - Status XRStatus `json:"status"` - FilterReason FilterReason `json:"filterReason,omitempty"` + Status dt.XRStatus `json:"status"` + FilterReason dt.FilterReason `json:"filterReason,omitempty"` FilterDetail string `json:"filterDetail,omitempty"` Error string `json:"error,omitempty"` DownstreamChanges *DownstreamChanges `json:"downstreamChanges,omitempty"` @@ -551,7 +340,7 @@ func (r *StructuredDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt. // The summary of unverified additions is derived here, from the diffs, and follows the warnings // raised during the run rather than being interleaved with them. See the DiffRenderer interface. - dryRun := xrDryRunWarnings(groups) + dryRun := dt.XRDryRunWarnings(groups) allWarnings := append(slices.Clone(warnings), dryRun...) // Grouped view: one entry per input XR, in input order, each complete for its input. Where inputs diff --git a/internal/diff/renderer/structured_renderer_test.go b/internal/diff/renderer/structured_renderer_test.go index d9e6901c..e7a5c6ba 100644 --- a/internal/diff/renderer/structured_renderer_test.go +++ b/internal/diff/renderer/structured_renderer_test.go @@ -16,7 +16,7 @@ import ( sigsyaml "sigs.k8s.io/yaml" "github.com/crossplane/cli/v2/internal/diff/compare" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -368,18 +368,18 @@ func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { Xrs: []xrEntryWire{ { XR: xrRef("changed-xr"), - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Summary: Summary{Modified: 1}, Changes: []ChangeDetail{bucketChange}, }, { XR: xrRef("unchanged-xr"), - Status: XRStatusUnchanged, + Status: dt.XRStatusUnchanged, Changes: []ChangeDetail{}, }, { XR: xrRef("broken-xr"), - Status: XRStatusError, + Status: dt.XRStatusError, Changes: []ChangeDetail{}, Errors: []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}}, }, @@ -397,7 +397,7 @@ func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { OutputVersion: OutputVersionV1, Changes: []ChangeDetail{}, Xrs: []xrEntryWire{ - {XR: xrRef("unchanged-xr"), Status: XRStatusUnchanged, Changes: []ChangeDetail{}}, + {XR: xrRef("unchanged-xr"), Status: dt.XRStatusUnchanged, Changes: []ChangeDetail{}}, }, }, wantKeys: []string{"changes", "outputVersion", "summary", "xrs"}, @@ -427,18 +427,18 @@ func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { Xrs: []xrEntryWire{ { XR: xrRef("changed-xr"), - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Summary: Summary{Modified: 1}, Changes: []ChangeDetail{bucketChange}, }, { XR: xrRef("unchanged-xr"), - Status: XRStatusUnchanged, + Status: dt.XRStatusUnchanged, Changes: []ChangeDetail{}, }, { XR: xrRef("broken-xr"), - Status: XRStatusError, + Status: dt.XRStatusError, Changes: []ChangeDetail{}, Errors: []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}}, }, @@ -469,13 +469,13 @@ func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { Xrs: []xrEntryWire{ { XR: xrRef("app-claim"), - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Summary: Summary{Modified: 1}, Changes: []ChangeDetail{sharedChange("us-west-2")}, }, { XR: xrRef("app-xr"), - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Summary: Summary{Modified: 1}, Changes: []ChangeDetail{sharedChange("us-west-2")}, }, @@ -504,13 +504,13 @@ func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { Xrs: []xrEntryWire{ { XR: xrRef("app-a"), - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Summary: Summary{Modified: 1}, Changes: []ChangeDetail{sharedChange("us-west-2")}, }, { XR: xrRef("app-b"), - Status: XRStatusChanged, + Status: dt.XRStatusChanged, Summary: Summary{Modified: 1}, Changes: []ChangeDetail{sharedChange("eu-west-1")}, }, diff --git a/internal/diff/result/comp.go b/internal/diff/result/comp.go new file mode 100644 index 00000000..d5de2e5a --- /dev/null +++ b/internal/diff/result/comp.go @@ -0,0 +1,232 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package result + +import ( + corev1 "k8s.io/api/core/v1" +) + +// XRStatus represents the processing status of an XR in composition diffs. +type XRStatus string + +const ( + // XRStatusChanged indicates the XR has downstream resource changes. + XRStatusChanged XRStatus = "changed" + // XRStatusUnchanged indicates the XR has no downstream resource changes. + XRStatusUnchanged XRStatus = "unchanged" + // XRStatusError indicates an error occurred while processing the XR. + XRStatusError XRStatus = "error" + // XRStatusFiltered indicates the XR matched the composition by name but was excluded from + // evaluation because it would not adopt the composition change being diffed. The specific cause + // is carried separately in XRImpact.FilterReason (outcome and reason are intentionally divorced + // so the reason set can grow without expanding the status enum). The XR is surfaced in impact + // analysis with no downstream changes so users see the skip explicitly. + XRStatusFiltered XRStatus = "filtered" +) + +// FilterReason explains why an XRImpact has XRStatusFiltered. It is only meaningful when +// XRImpact.Status == XRStatusFiltered. +type FilterReason string + +const ( + // FilterReasonManualPolicy indicates the XR was excluded because it has a Manual + // compositionUpdatePolicy and --include-manual was not set. Such XRs are pinned to a specific + // revision and would not adopt the composition change automatically. + FilterReasonManualPolicy FilterReason = "manual_policy" + // FilterReasonRevisionSelectorMismatch indicates the XR was excluded because it has an Automatic + // compositionUpdatePolicy with a compositionRevisionSelector that does not match the labels of + // the composition change being diffed. Such XRs would not select the resulting revision. + FilterReasonRevisionSelectorMismatch FilterReason = "revision_selector_mismatch" + // FilterReasonDeleting indicates the XR was excluded because it has a metadata.deletionTimestamp. + // Crossplane's composite reconciler takes its deletion path for such an XR — it tears down + // composed resources rather than composing them — so the XR will never adopt the resulting + // revision, and rendering it produces no composed resources to diff against. + FilterReasonDeleting FilterReason = "deleting" +) + +// CompDiffOutput is the top-level output for composition diffs (internal representation). +// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. +type CompDiffOutput struct { + Compositions []CompositionDiff + Errors []OutputError // top-level errors (e.g., XRs that failed impact analysis) + // Warnings are non-fatal advisories raised during the run. Like the xr command's, they do not + // affect the exit code and are not attributed to a single composition — they are raised below the + // point where the owning composition is known. They have already been written to stderr when + // raised; this field carries them into structured output. The summary of unverified additions is + // not among them: the renderer derives it from the diffs (see CompDryRunWarnings). + Warnings []OutputWarning +} + +// CompositionDiff represents the diff result for a single composition (internal). +// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. +type CompositionDiff struct { + Name string + // Error is the per-composition processing error (nil if successful). Its ResourceID is + // "Composition/". + Error *OutputError + CompositionDiff *ResourceDiff // the actual composition diff (nil if unchanged) + AffectedResources AffectedResourcesSummary + ImpactAnalysis []XRImpact + // ImpactAnalysisSkipped records that the affected XRs were deliberately not evaluated, because + // the composition changed by less than --analyze-on asked to analyse. Distinguishes "we did not + // look" from "we looked and found no affected XRs", which would otherwise be indistinguishable. + // + // It does not mean ImpactAnalysis is empty. Composites excluded by update policy, revision + // selector or deletion are identified locally, with no render, so they are reported whether or not + // the analysis ran — in --resource mode as XRStatusFiltered entries here, and always via the + // AffectedResources filter counters (issue #478). What a skip suppresses is the renders, and + // therefore any changed/unchanged/errored verdict about the composites that were kept. + // + // Why the composites went unevaluated is not recoverable from this field alone — read + // RevisionImpact.ChangeScope alongside it. A "none" scope means no CompositionRevision is created + // and so nothing could have changed; anything else means a revision is created and simply was not + // evaluated. + ImpactAnalysisSkipped bool + // MaskedChangesOnly records that the composition differs from the cluster's only in fields + // excluded from the rendered diff — the user's --ignore-paths, or the comparison's display-only + // suppressions. CompositionDiff is nil in that case, but the composition did change, so the + // renderer must not report it as unchanged. + MaskedChangesOnly bool + // RevisionImpact is what applying this composition does to CompositionRevisions, independent of + // whether anything renders differently. Populated whenever the comparison completed, including + // when the composites were not evaluated; left zero for a composition that failed to process, + // where nothing about it was established (see RevisionImpact.Determined). + RevisionImpact RevisionImpact +} + +// RevisionImpact describes what applying a composition does to CompositionRevisions and the +// composites tracking them, independent of whether any composed resource renders differently. +// +// This is deliberately a typed field rather than a warning. Revision churn is a per-composition +// fact a CI consumer may want to gate on, and warnings are documented as neither attributed to a +// composition nor intended for gating — so a flat warning could not say which of several diffed +// compositions creates a revision. +type RevisionImpact struct { + // ChangeScope is how much of the composition differs, in the terms Crossplane uses to decide + // whether a revision is needed: "none", "metadata" or "spec". Crossplane's Composition.Hash() + // covers labels and annotations as well as spec, so "metadata" still means a revision is created. + ChangeScope string `json:"changeScope"` + // CreatesRevision records whether applying this composition produces a new CompositionRevision. + CreatesRevision bool `json:"createsRevision"` + // RepointedComposites counts the composites that would adopt that revision and reconcile as a + // result: those with an Automatic (or defaulted) compositionUpdatePolicy whose + // compositionRevisionSelector, if any, selects the resulting revision, and which are not being + // deleted. A Manual-policy composite is never counted — it is pinned by compositionRevisionRef, so + // --include-manual keeping it for analysis does not make it re-point (issue #479). + // + // Note this counts composites that re-point, which is not the same as composites whose rendered + // output changes; re-pointing alone usually renders identically. + RepointedComposites int `json:"repointedComposites"` + // PredictedRevisionName is what that revision would be called: "-", derived + // from Crossplane's own Composition.Hash(). When CreatesRevision is false it names the existing + // revision the composites already track, which is the same name by construction — an unchanged + // composition hashes to the same value. + // + // It is what makes a downstream change interpretable: a composed resource whose template reads the + // revision name shows a diff with no other visible cause, and this is the cause. The composites are + // rendered with this value seeded onto their compositionRevisionRef, so such a change is detected + // rather than assumed away. + // + // Absent with CreatesRevision true means the name could not be predicted, which happens when the + // composition differs from the cluster's only by kubectl's last-applied-configuration annotation: + // its post-apply value is a function of how the user applies rather than of the file, so the hash — + // and therefore the name — is unknowable. A warning says so, and nothing is seeded in that case, so + // a revision-observing change would go undetected. Note also that for a composition applied + // client-side *with* other edits the suffix is predicted from the file as supplied and so may differ + // from the eventual one; the change is still detected, its predicted value is just imprecise. + PredictedRevisionName string `json:"predictedRevisionName,omitempty"` +} + +// Determined reports whether the comparison that populates a RevisionImpact actually ran. ChangeScope +// is set unconditionally alongside the other fields and every real scope is non-empty, so an empty +// scope means nothing here was established. That is the case for a composition that failed to process: +// serializing the zero value would put changeScope outside its documented enum and would assert +// createsRevision: false, a positive claim about an apply the tool never evaluated (issue #479). +func (i RevisionImpact) Determined() bool { + return i.ChangeScope != "" +} + +// HasChanges returns true if this composition diff has any changes, which is what drives +// ExitCodeDiffDetected. +// +// Deliberately excluded: RevisionImpact. A composition whose only difference is in masked fields +// creates a CompositionRevision but has nothing to show and nothing rendering differently, and a +// GitOps loop re-applying the same manifests would otherwise fail its diff gate on every run. A +// consumer that does want to gate on revision churn reads revisionImpact.createsRevision from the +// structured output. +func (c *CompositionDiff) HasChanges() bool { + if c.CompositionDiff != nil && c.CompositionDiff.DiffType != DiffTypeEqual { + return true + } + + for _, impact := range c.ImpactAnalysis { + if impact.Status == XRStatusChanged { + return true + } + } + + return false +} + +// HasChanges returns true if any composition in the output has changes (see CompositionDiff.HasChanges). +func (o *CompDiffOutput) HasChanges() bool { + for i := range o.Compositions { + if o.Compositions[i].HasChanges() { + return true + } + } + + return false +} + +// AffectedResourcesSummary contains counts of affected resources by status. +type AffectedResourcesSummary struct { + Total int `json:"total"` + WithChanges int `json:"withChanges"` + Unchanged int `json:"unchanged"` + WithErrors int `json:"withErrors"` + // FilteredByPolicy counts XRs excluded because of a Manual compositionUpdatePolicy + // (FilterReasonManualPolicy). + FilteredByPolicy int `json:"filteredByPolicy,omitempty"` + // FilteredBySelector counts XRs excluded because their compositionRevisionSelector does not match + // the diffed composition's labels (FilterReasonRevisionSelectorMismatch). Kept separate from + // FilteredByPolicy so the breakdown is visible even in default-discovery mode, where individual + // XR impacts are not surfaced. + FilteredBySelector int `json:"filteredBySelector,omitempty"` + // FilteredByDeletion counts XRs excluded because they are being deleted + // (FilterReasonDeleting). Kept separate from the other filter counters for the same reason: + // the breakdown stays visible in default-discovery mode. + FilteredByDeletion int `json:"filteredByDeletion,omitempty"` +} + +// XRImpact represents the impact analysis for a single XR (internal). +// This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. +// Embeds corev1.ObjectReference for the common resource identity fields. +type XRImpact struct { + corev1.ObjectReference + + Status XRStatus + // FilterReason explains a Status == XRStatusFiltered outcome; empty otherwise. + FilterReason FilterReason + // FilterDetail is an optional human-readable explanation for a filtered outcome (e.g. which + // selector failed to match which labels), surfaced to help users self-diagnose the exclusion. + FilterDetail string + // Error is the structured processing error for an XRStatusError impact (nil otherwise), the same + // OutputError the command lists in its top-level errors. + Error *OutputError + Diffs map[string]*ResourceDiff // downstream diffs (nil if unchanged/error) +} diff --git a/internal/diff/result/dry_run_warnings.go b/internal/diff/result/dry_run_warnings.go new file mode 100644 index 00000000..bf62bf5b --- /dev/null +++ b/internal/diff/result/dry_run_warnings.go @@ -0,0 +1,208 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package result + +import ( + "cmp" + "fmt" + "maps" + "slices" + "strconv" +) + +// dryRunSkipSummary is how one DryRunSkipReason is summarised for a human: the warning's message, +// and the context key its DryRunInfo.Detail is reported under (none for a reason without a cause). +type dryRunSkipSummary struct { + message string + detailKey string +} + +// storageVersionNote is appended to the summary of additions whose CRD stores them at a version other +// than the one requested (DryRunInfo.StorageVersion). Their local prediction defaulted against the +// requested version only, so the storage version's defaults, and any conversion through it, are not +// in their diffs. The versions are reported under the `versions` context key. +const storageVersionNote = "; they are stored at a version other than the one requested, whose defaults and conversion the cluster may apply but their diffs do not" + +// summaryFor returns how the additions of group g are summarised, and false for a group that is not +// worth telling the user about. Additions stored at a version other than the one requested get +// storageVersionNote appended to their reason's message. +// +// DryRunSkipDisabled is summarised only for additions stored at another version: +// --dry-run-on=existing is the user's own choice, so the additions it leaves unverified are not news to +// them, but that the cluster stores some at a version the prediction did not default against is. +func summaryFor(g dryRunGroup) (dryRunSkipSummary, bool) { + var s dryRunSkipSummary + + switch g.reason { + case DryRunSkipForbidden: + s = dryRunSkipSummary{ + message: "skipped apiserver verification of added resources: not authorized to create them, so their diffs omit server-side defaulting and admission", + detailKey: "reason", + } + case DryRunSkipNamespaceNotFound: + s = dryRunSkipSummary{ + message: "skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission", + detailKey: "cause", + } + case DryRunSkipWebhookUnavailable: + s = dryRunSkipSummary{ + message: "skipped apiserver verification of added resources: the cluster could not complete admission", + detailKey: "cause", + } + case DryRunSkipDisabled: + if g.storageVersion == "" { + return dryRunSkipSummary{}, false + } + + // The user's own choice has no cause, so there is no detail key. + s = dryRunSkipSummary{message: "did not verify added resources against the apiserver (--dry-run-on=existing)"} + default: + return dryRunSkipSummary{}, false + } + + if g.storageVersion != "" { + s.message += storageVersionNote + } + + return s, true +} + +// dryRunGroup is what one summary warning stands for: additions of one GVK in one namespace that were +// not verified for one reason and one cause, and are stored at one version. +// +// storageVersion never splits a group in practice: one GVK has one CRD, and so one storage version. It +// is part of the key so that the note a warning carries is always true of every resource it counts. +type dryRunGroup struct { + gvk string + version string // the requested version, already part of gvk; kept for the `versions` key + namespace string + reason DryRunSkipReason + detail string + storageVersion string +} + +// DryRunWarnings derives, from the DryRunInfo carried on the supplied diffs, the warnings that tell a +// human which added resources could not be verified against the apiserver, and why. +// +// It is the only place a human sees this: the text renderer does not show DryRunInfo. So there is one +// warning per GVK + namespace + distinct cause, rather than per reason, because grouping any coarser +// would keep one cause and silently drop the rest. Each warning counts the resources behind it under +// the `count` context key and, for additions stored at a version other than the one requested, names +// both versions under the `versions` context key. A resource that appears in more than one set (two +// overlapping inputs that render the same object) is one resource and is counted once. +// +// The result is sorted by GVK, namespace, reason, cause and storage version, since diffs arrive in map +// order. It is nil +// when there is nothing to report. +// +// The per-resource dryRun field in structured output stays the machine-readable record: a warning has +// no resource anchor, so it cannot tell a pipeline WHICH additions were degraded. +func DryRunWarnings(diffSets ...map[string]*ResourceDiff) []OutputWarning { + resources := make(map[dryRunGroup]map[string]struct{}) + + for _, diffs := range diffSets { + for _, d := range diffs { + if d.DryRun == nil { + continue + } + + g := dryRunGroup{ + gvk: d.Gvk.String(), + version: d.Gvk.Version, + namespace: d.Namespace, + reason: d.DryRun.SkipReason, + detail: d.DryRun.Detail, + storageVersion: d.DryRun.StorageVersion, + } + + if _, summarised := summaryFor(g); !summarised { + continue + } + + if resources[g] == nil { + resources[g] = make(map[string]struct{}) + } + + resources[g][d.GetDiffKey()] = struct{}{} + } + } + + if len(resources) == 0 { + return nil + } + + groups := slices.SortedFunc(maps.Keys(resources), func(a, b dryRunGroup) int { + return cmp.Or( + cmp.Compare(a.gvk, b.gvk), + cmp.Compare(a.namespace, b.namespace), + cmp.Compare(a.reason, b.reason), + cmp.Compare(a.detail, b.detail), + cmp.Compare(a.storageVersion, b.storageVersion), + ) + }) + + warnings := make([]OutputWarning, 0, len(groups)) + + for _, g := range groups { + s, _ := summaryFor(g) + + context := map[string]string{ + "gvk": g.gvk, + "namespace": g.namespace, + "count": strconv.Itoa(len(resources[g])), + } + if s.detailKey != "" { + context[s.detailKey] = g.detail + } + + if g.storageVersion != "" { + context["versions"] = fmt.Sprintf("requested %s, stored %s", g.version, g.storageVersion) + } + + warnings = append(warnings, OutputWarning{Message: s.message, Context: context}) + } + + return warnings +} + +// XRDryRunWarnings is DryRunWarnings over the diffs an xr run emits, across every input. +// +// A failed input's diffs are withheld, so nothing is summarised for it: the summary says how far to +// trust the diffs that are shown, and a failed input shows none. Its error is reported instead. +func XRDryRunWarnings(groups []XRDiffGroup) []OutputWarning { + sets := make([]map[string]*ResourceDiff, 0, len(groups)) + for _, g := range groups { + sets = append(sets, g.Diffs) + } + + return DryRunWarnings(sets...) +} + +// CompDryRunWarnings is DryRunWarnings over the downstream diffs a comp run emits, across every +// composition: one run-wide summary rather than one per composition, matching where warnings[] lives. +// As for xr, a failed XR's diffs are withheld and so are not summarised. +func CompDryRunWarnings(output *CompDiffOutput) []OutputWarning { + var sets []map[string]*ResourceDiff + + for _, comp := range output.Compositions { + for _, impact := range comp.ImpactAnalysis { + sets = append(sets, impact.Diffs) + } + } + + return DryRunWarnings(sets...) +} diff --git a/internal/diff/result/dry_run_warnings_test.go b/internal/diff/result/dry_run_warnings_test.go new file mode 100644 index 00000000..46751596 --- /dev/null +++ b/internal/diff/result/dry_run_warnings_test.go @@ -0,0 +1,203 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package result + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + forbiddenSummary = "skipped apiserver verification of added resources: not authorized to create them, so their diffs omit server-side defaulting and admission" + namespaceSummary = "skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission" + webhookSummary = "skipped apiserver verification of added resources: the cluster could not complete admission" + storageNote = "; they are stored at a version other than the one requested, whose defaults and conversion the cluster may apply but their diffs do not" + disabledSummary = "did not verify added resources against the apiserver (--dry-run-on=existing)" + testResourceGVK = "example.org/v1, Kind=TestResource" + otherResourceGVK = "example.org/v1, Kind=OtherResource" +) + +// unverifiedAddition builds the diff of an added resource whose dry-run create was skipped for reason, +// with the cluster's explanation detail. +func unverifiedAddition(kind, name, namespace string, reason DryRunSkipReason, detail string) *ResourceDiff { + return &ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: kind}, + Namespace: namespace, + ResourceName: name, + DiffType: DiffTypeAdded, + DryRun: &DryRunInfo{SkipReason: reason, Detail: detail}, + } +} + +// storedAt makes d an addition requested at version and stored, by its CRD, at storageVersion. +func storedAt(d *ResourceDiff, version, storageVersion string) *ResourceDiff { + d.Gvk.Version = version + d.DryRun.StorageVersion = storageVersion + + return d +} + +// diffMap keys diffs the way the diff calculator does. +func diffMap(diffs ...*ResourceDiff) map[string]*ResourceDiff { + out := make(map[string]*ResourceDiff, len(diffs)) + for _, d := range diffs { + out[d.GetDiffKey()] = d + } + + return out +} + +// TestDryRunWarnings covers how the advisory for unverified additions is derived from the DryRunInfo +// on the diffs: one warning per GVK + namespace + distinct cause, counting the resources behind it, and +// no cause ever dropped. The text renderer shows no DryRunInfo, so this warning is the only place a +// human learns that an addition was not verified, or why. +func TestDryRunWarnings(t *testing.T) { + tests := map[string]struct { + reason string + diffSets []map[string]*ResourceDiff + want []OutputWarning + }{ + "ForbiddenCollapsesPerGVKAndNamespace": { + reason: "Four denied additions across three GVK+namespace groups raise three warnings, each counting its resources.", + diffSets: []map[string]*ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipForbidden, "no create"), + unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipForbidden, "no create"), + unverifiedAddition("TestResource", "c", "ns-b", DryRunSkipForbidden, "no create"), + unverifiedAddition("OtherResource", "d", "ns-a", DryRunSkipForbidden, "no create"), + )}, + want: []OutputWarning{ + {Message: forbiddenSummary, Context: map[string]string{"gvk": otherResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "1"}}, + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "2"}}, + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-b", "reason": "no create", "count": "1"}}, + }, + }, + "DistinctCausesAreAllKept": { + reason: "Two different apiserver causes in one GVK+namespace group are two warnings. Grouping on reason alone would keep one and silently drop the other.", + diffSets: []map[string]*ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipWebhookUnavailable, `failed calling webhook "policy.example.org"`), + unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipWebhookUnavailable, `failed calling webhook "quota.example.org"`), + unverifiedAddition("TestResource", "c", "ns-a", DryRunSkipWebhookUnavailable, `failed calling webhook "policy.example.org"`), + )}, + want: []OutputWarning{ + {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `failed calling webhook "policy.example.org"`, "count": "2"}}, + {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `failed calling webhook "quota.example.org"`, "count": "1"}}, + }, + }, + "MissingNamespaceAcrossInputsCountsEachResourceOnce": { + reason: "Additions from several inputs share one warning. A resource two overlapping inputs both render is one resource, so it is counted once.", + diffSets: []map[string]*ResourceDiff{ + diffMap( + unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + unverifiedAddition("TestResource", "shared", "ns-a", DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + ), + diffMap( + unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + unverifiedAddition("TestResource", "shared", "ns-a", DryRunSkipNamespaceNotFound, `namespaces "ns-a" not found`), + ), + }, + want: []OutputWarning{ + {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": `namespaces "ns-a" not found`, "count": "3"}}, + }, + }, + "ClusterScopedResourceKeepsAnEmptyNamespace": { + reason: "A cluster-scoped addition still carries the namespace key, empty, so every summary has the same context keys.", + diffSets: []map[string]*ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "", DryRunSkipForbidden, "no create"), + )}, + want: []OutputWarning{ + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "", "reason": "no create", "count": "1"}}, + }, + }, + "ReasonsAreOrderedDeterministically": { + reason: "Diffs arrive in map order, so the summary is sorted: by GVK, then namespace, then reason, then cause.", + diffSets: []map[string]*ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipWebhookUnavailable, "timeout"), + unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipForbidden, "no create"), + unverifiedAddition("TestResource", "c", "ns-a", DryRunSkipNamespaceNotFound, "gone"), + )}, + want: []OutputWarning{ + {Message: forbiddenSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "reason": "no create", "count": "1"}}, + {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "1"}}, + {Message: webhookSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "timeout", "count": "1"}}, + }, + }, + "DisabledIsNotWarnedAbout": { + reason: "--dry-run-on=existing is the user's own choice, so the additions it leaves unverified are not news to them.", + diffSets: []map[string]*ResourceDiff{diffMap( + unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipDisabled, ""), + )}, + want: nil, + }, + "StorageVersionIsNotedOnTheGroupsWarning": { + reason: "Additions requested at a version their CRD does not store say which version it stores, on the warning their group already raises, without splitting the group.", + diffSets: []map[string]*ResourceDiff{diffMap( + storedAt(unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipForbidden, "no create"), "v1alpha1", "v1"), + storedAt(unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipForbidden, "no create"), "v1alpha1", "v1"), + )}, + want: []OutputWarning{ + {Message: forbiddenSummary + storageNote, Context: map[string]string{ + "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "reason": "no create", "versions": "requested v1alpha1, stored v1", "count": "2", + }}, + }, + }, + "StorageVersionNoteStaysWithItsOwnVersion": { + reason: "Additions of one kind at the storage version and at another version are already two GVKs, so only the one at the other version carries the note.", + diffSets: []map[string]*ResourceDiff{diffMap( + storedAt(unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipNamespaceNotFound, "gone"), "v1alpha1", "v1"), + unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipNamespaceNotFound, "gone"), + )}, + want: []OutputWarning{ + {Message: namespaceSummary, Context: map[string]string{"gvk": testResourceGVK, "namespace": "ns-a", "cause": "gone", "count": "1"}}, + {Message: namespaceSummary + storageNote, Context: map[string]string{ + "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "cause": "gone", "versions": "requested v1alpha1, stored v1", "count": "1", + }}, + }, + }, + "DisabledIsWarnedAboutForAStorageVersion": { + reason: "--dry-run-on=existing is the user's choice, but that the cluster stores the additions at another version is not, so it is news worth a warning.", + diffSets: []map[string]*ResourceDiff{diffMap( + storedAt(unverifiedAddition("TestResource", "a", "ns-a", DryRunSkipDisabled, ""), "v1alpha1", "v1"), + unverifiedAddition("TestResource", "b", "ns-a", DryRunSkipDisabled, ""), + )}, + want: []OutputWarning{ + {Message: disabledSummary + storageNote, Context: map[string]string{ + "gvk": "example.org/v1alpha1, Kind=TestResource", "namespace": "ns-a", "versions": "requested v1alpha1, stored v1", "count": "1", + }}, + }, + }, + "VerifiedDiffsRaiseNothing": { + reason: "A diff without DryRunInfo was verified, and there is nothing to say about it.", + diffSets: []map[string]*ResourceDiff{diffMap(&ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "TestResource"}, + Namespace: "ns-a", + ResourceName: "a", + DiffType: DiffTypeAdded, + })}, + want: nil, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if d := cmp.Diff(tc.want, DryRunWarnings(tc.diffSets...)); d != "" { + t.Errorf("\n%s\nDryRunWarnings() mismatch (-want +got):\n%s", tc.reason, d) + } + }) + } +} diff --git a/internal/diff/renderer/types/types.go b/internal/diff/result/types.go similarity index 98% rename from internal/diff/renderer/types/types.go rename to internal/diff/result/types.go index d4cc6e45..654a39ad 100644 --- a/internal/diff/renderer/types/types.go +++ b/internal/diff/result/types.go @@ -1,5 +1,7 @@ -// Package types provides types used in the renderer in order to facilitate code reuse in test -package types +// Package result is the model of what a diff run found: the reports the processors return and the +// renderers present, with the verdicts and derivations every consumer of a report shares. It imports +// nothing else in cmd/diff, so the processors, the renderers and testutils can all depend on it. +package result import ( "crypto/sha256" @@ -30,7 +32,7 @@ import ( // diffprocessor package; having the renderer convert would require a // renderer -> diffprocessor import cycle. It is nil unless this XR failed. // -// This type lives in the leaf types package (rather than the renderer package +// This type lives in the leaf result package (rather than the renderer package // alongside the DiffRenderer interface) so that testutils — which provides the // DiffRenderer mock — can reference it without importing renderer, which would // close an import cycle through renderer's in-package tests. diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go index cc4de913..6dda7ef2 100644 --- a/internal/diff/testutils/mock_builder.go +++ b/internal/diff/testutils/mock_builder.go @@ -26,7 +26,7 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" - "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" dtypes "github.com/crossplane/cli/v2/internal/diff/types" ) @@ -107,12 +107,12 @@ func (b *MockResourceClientBuilder) WithResourcesExist(resources ...*un.Unstruct // Build a map for fast lookup using the same key format as diff keys for _, res := range resources { - key := types.MakeDiffKeyFromResource(res) + key := dt.MakeDiffKeyFromResource(res) resourceMap[key] = res } return b.WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, namespace, name string) (*un.Unstructured, error) { - key := types.MakeDiffKey(gvk.GroupVersion().String(), gvk.Kind, namespace, name) + key := dt.MakeDiffKey(gvk.GroupVersion().String(), gvk.Kind, namespace, name) if res, found := resourceMap[key]; found { return res, nil } @@ -1439,36 +1439,36 @@ func (b *DiffProcessorBuilder) WithFailedInitialize(errMsg string) *DiffProcesso } // WithPerformDiff adds an implementation for the PerformDiff method. -func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error)) *DiffProcessorBuilder { +func (b *DiffProcessorBuilder) WithPerformDiff(fn func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*dt.XRReport, error)) *DiffProcessorBuilder { b.mock.PerformDiffFn = fn return b } // WithSuccessfulPerformDiff sets a successful PerformDiff implementation with no diffs. func (b *DiffProcessorBuilder) WithSuccessfulPerformDiff() *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error) { - return &types.XRReport{}, nil + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*dt.XRReport, error) { + return &dt.XRReport{}, nil }) } // WithSuccessfulPerformDiffWithChanges sets a successful PerformDiff implementation whose report // has one added resource. func (b *DiffProcessorBuilder) WithSuccessfulPerformDiffWithChanges() *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error) { - added := &types.ResourceDiff{ + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*dt.XRReport, error) { + added := &dt.ResourceDiff{ Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XDownstreamResource"}, ResourceName: "added-resource", - DiffType: types.DiffTypeAdded, + DiffType: dt.DiffTypeAdded, } - return &types.XRReport{Groups: []types.XRDiffGroup{{Diffs: map[string]*types.ResourceDiff{added.GetDiffKey(): added}}}}, nil + return &dt.XRReport{Groups: []dt.XRDiffGroup{{Diffs: map[string]*dt.ResourceDiff{added.GetDiffKey(): added}}}}, nil }) } // WithFailedPerformDiff sets a failing PerformDiff implementation, whose report holds the error. func (b *DiffProcessorBuilder) WithFailedPerformDiff(errMsg string) *DiffProcessorBuilder { - return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*types.XRReport, error) { - return &types.XRReport{Errors: []types.OutputError{{Message: errMsg}}}, errors.New(errMsg) + return b.WithPerformDiff(func(context.Context, []*un.Unstructured, dtypes.CompositionProvider) (*dt.XRReport, error) { + return &dt.XRReport{Errors: []dt.OutputError{{Message: errMsg}}}, errors.New(errMsg) }) } diff --git a/internal/diff/testutils/mocks.go b/internal/diff/testutils/mocks.go index 8eacd645..2b5570b4 100644 --- a/internal/diff/testutils/mocks.go +++ b/internal/diff/testutils/mocks.go @@ -27,7 +27,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" "github.com/crossplane/cli/v2/cmd/crossplane/render" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/types" ) diff --git a/internal/diff/testutils/structured_assertions.go b/internal/diff/testutils/structured_assertions.go index ca47434a..788c9d98 100644 --- a/internal/diff/testutils/structured_assertions.go +++ b/internal/diff/testutils/structured_assertions.go @@ -12,7 +12,7 @@ import ( corev1 "k8s.io/api/core/v1" "k8s.io/client-go/util/jsonpath" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // StructuredDiffOutput mirrors the renderer's xr document to avoid import cycles: v1's @@ -27,7 +27,7 @@ type StructuredDiffOutput struct { Xrs []XRDiffWire `json:"xrs"` } -// OutputWarning mirrors renderer/types.OutputWarning: a non-fatal advisory carried in structured +// OutputWarning mirrors result.OutputWarning: a non-fatal advisory carried in structured // output alongside (but distinct from) errors. type OutputWarning struct { Message string `json:"message"` @@ -1330,11 +1330,11 @@ type CompositionDiffWire struct { ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` // MaskedChangesOnly records that an absent compositionChanges does not mean unchanged. MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` - // RevisionImpact mirrors renderer.RevisionImpact. + // RevisionImpact mirrors result.RevisionImpact. RevisionImpact RevisionImpactWire `json:"revisionImpact"` } -// RevisionImpactWire mirrors renderer.RevisionImpact. +// RevisionImpactWire mirrors result.RevisionImpact. type RevisionImpactWire struct { ChangeScope string `json:"changeScope"` CreatesRevision bool `json:"createsRevision"` @@ -1342,7 +1342,7 @@ type RevisionImpactWire struct { PredictedRevisionName string `json:"predictedRevisionName,omitempty"` } -// AffectedResourcesSummary mirrors renderer.AffectedResourcesSummary. +// AffectedResourcesSummary mirrors result.AffectedResourcesSummary. type AffectedResourcesSummary struct { Total int `json:"total"` WithChanges int `json:"withChanges"` diff --git a/internal/diff/testutils/utils.go b/internal/diff/testutils/utils.go index 632d1659..ce7103e5 100644 --- a/internal/diff/testutils/utils.go +++ b/internal/diff/testutils/utils.go @@ -18,7 +18,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - dt "github.com/crossplane/cli/v2/internal/diff/renderer/types" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) // Colors for terminal output. diff --git a/internal/diff/types/types.go b/internal/diff/types/types.go index b4ba7482..326ceb22 100644 --- a/internal/diff/types/types.go +++ b/internal/diff/types/types.go @@ -51,7 +51,7 @@ import ( // testutils must name it to implement the mock, and testutils cannot import // client/kubernetes — that package's tests are internal (package kubernetes) // and import testutils, so the edge would close a cycle. Same reason -// renderer/types exists. Do not "tidy" this back next to the interface. +// result is a leaf package. Do not "tidy" this back next to the interface. type Verb string const ( From b9668f4f9868c25962fde0d5ff77ed58d4e11239 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 21:04:27 -0400 Subject: [PATCH 045/126] refactor(diff): record filtered XRs in both modes; the renderer decides 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: " (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) Signed-off-by: Jonathan Ogilvie --- .../client/crossplane/composition_client.go | 4 +- .../client/crossplane/revision_selector.go | 8 + internal/diff/diffprocessor/comp_processor.go | 80 +++++----- .../diff/diffprocessor/comp_processor_test.go | 137 ++++++++++-------- internal/diff/renderer/comp_diff_renderer.go | 44 ++++-- .../diff/renderer/comp_diff_renderer_test.go | 105 +++++++++++++- internal/diff/result/comp.go | 20 ++- 7 files changed, 280 insertions(+), 118 deletions(-) diff --git a/internal/diff/client/crossplane/composition_client.go b/internal/diff/client/crossplane/composition_client.go index bf68da8f..35fc17bc 100644 --- a/internal/diff/client/crossplane/composition_client.go +++ b/internal/diff/client/crossplane/composition_client.go @@ -224,12 +224,12 @@ func (c *DefaultCompositionClient) cachedCompositions() []*apiextensionsv1.Compo // (present but not a string/object), which is treated as a hard failure by the caller. // Checks both v2 (spec.crossplane.compositionRevisionRef) and v1 (spec.compositionRevisionRef) paths. func (c *DefaultCompositionClient) getCompositionRevisionRef(xrd, res *un.Unstructured) (string, bool, error) { - name, found, err := nestedCrossplaneString(res.Object, xrd.GetAPIVersion(), "compositionRevisionRef", "name") + name, err := XRCompositionRevisionRef(res.Object, xrd.GetAPIVersion()) if err != nil { return "", false, err } - return name, found && name != "", nil + return name, name != "", nil } // resolveCompositionFromRevisions selects the CompositionRevision Crossplane would compose res with, diff --git a/internal/diff/client/crossplane/revision_selector.go b/internal/diff/client/crossplane/revision_selector.go index 8d3fdcad..c02a0735 100644 --- a/internal/diff/client/crossplane/revision_selector.go +++ b/internal/diff/client/crossplane/revision_selector.go @@ -158,6 +158,14 @@ func XRUpdatePolicy(obj map[string]any, apiVersion string) (string, error) { return updatePolicyAutomatic, nil } +// XRCompositionRevisionRef reads the name in an XR/Claim's compositionRevisionRef, checking the paths +// XRUpdatePolicy does. Empty when the ref is unset. A non-nil error means the field is present but +// malformed (not a string). +func XRCompositionRevisionRef(obj map[string]any, apiVersion string) (string, error) { + name, _, err := nestedCrossplaneString(obj, apiVersion, "compositionRevisionRef", "name") + return name, err +} + // labelSelectorFromUnstructuredMap converts an unstructured compositionRevisionSelector map (as // stored on an XR spec) into a typed metav1.LabelSelector via runtime conversion. func labelSelectorFromUnstructuredMap(m map[string]any) (*metav1.LabelSelector, error) { diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 7f321d2b..8de5bafb 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -197,18 +197,15 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit // Resolve the affected XR set up-front. In --resource mode the preflight already produced it; // in default-discovery mode we query the cluster (best-effort: net-new compositions yield empty). - // surfaceFiltered controls whether Manual-policy XRs go into ImpactAnalysis as XRStatusFilteredByPolicy - // entries (true in --resource mode so users see what was matched-but-skipped) vs. only being counted - // in the summary (default-discovery mode). - var ( - affectedXRs []*un.Unstructured - surfaceFiltered bool - ) + // Either way the filtered XRs are recorded in ImpactAnalysis; scoped records which mode produced + // the set, so the renderer can decide how to present them. + scoped := len(resources) > 0 + + var affectedXRs []*un.Unstructured switch { - case len(resources) > 0: + case scoped: affectedXRs = preflightMatches[compositionID] - surfaceFiltered = true default: // Default-discovery only needs comp.GetName() — pass the unstructured directly. // FindComposites converts to typed internally only in refs mode (which we're not in here). @@ -227,7 +224,7 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit } // Process this single composition and build the result - compResult, err := p.processSingleComposition(ctx, comp, affectedXRs, surfaceFiltered) + compResult, err := p.processSingleComposition(ctx, comp, affectedXRs, scoped) if err != nil { p.config.Logger.Debug("Failed to process composition", "composition", compositionID, "error", err) @@ -235,8 +232,9 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit // Include failed composition with error instead of skipping output.Compositions = append(output.Compositions, dt.CompositionDiff{ - Name: compositionID, - Error: new(NewOutputError(fmt.Sprintf("Composition/%s", compositionID), err)), + Name: compositionID, + Scoped: scoped, + Error: new(NewOutputError(fmt.Sprintf("Composition/%s", compositionID), err)), AffectedResources: dt.AffectedResourcesSummary{ Total: 0, WithChanges: 0, @@ -367,11 +365,11 @@ func (p *DefaultCompDiffProcessor) preflightResourceRefs(ctx context.Context, co // processSingleComposition processes a single composition and builds the result. // `affectedXRs` is the pre-resolved set of XRs to evaluate (caller decides via DiffComposition's // switch whether this comes from the --resource preflight or default-discovery via FindComposites). -// When `surfaceFiltered` is true, XRs dropped by update-policy filtering are surfaced in -// ImpactAnalysis with XRStatusFilteredByPolicy so users see what was matched-but-skipped. -func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, newComp *un.Unstructured, affectedXRs []*un.Unstructured, surfaceFiltered bool) (*dt.CompositionDiff, error) { +// `scoped` records that the set came from --resource rather than discovery (see CompositionDiff.Scoped). +func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, newComp *un.Unstructured, affectedXRs []*un.Unstructured, scoped bool) (*dt.CompositionDiff, error) { result := &dt.CompositionDiff{ Name: newComp.GetName(), + Scoped: scoped, ImpactAnalysis: []dt.XRImpact{}, AffectedResources: dt.AffectedResourcesSummary{ Total: 0, @@ -441,23 +439,20 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, "filteredByDeletion", counts.byDeletion, "includeManual", p.config.IncludeManual) - // In --resource mode (surfaceFiltered=true), surface filtered composites in the impact - // analysis as XRStatusFiltered (with their reason) so users see what was matched-but-skipped. - // In default-discovery mode, preserve the existing summary-only behavior. - if surfaceFiltered { - for _, d := range droppedXRs { - result.ImpactAnalysis = append(result.ImpactAnalysis, dt.XRImpact{ - ObjectReference: corev1.ObjectReference{ - APIVersion: d.xr.GetAPIVersion(), - Kind: d.xr.GetKind(), - Name: d.xr.GetName(), - Namespace: d.xr.GetNamespace(), - }, - Status: dt.XRStatusFiltered, - FilterReason: d.reason, - FilterDetail: d.detail, - }) - } + // Record every filtered composite as XRStatusFiltered, with its reason, in either mode. Whether to + // list them or only count them is the renderer's decision (see CompositionDiff.Scoped). + for _, d := range droppedXRs { + result.ImpactAnalysis = append(result.ImpactAnalysis, dt.XRImpact{ + ObjectReference: corev1.ObjectReference{ + APIVersion: d.xr.GetAPIVersion(), + Kind: d.xr.GetKind(), + Name: d.xr.GetName(), + Namespace: d.xr.GetNamespace(), + }, + Status: dt.XRStatusFiltered, + FilterReason: d.reason, + FilterDetail: d.detail, + }) } // Which composites were discovered, and which of them would not adopt the resulting revision, is @@ -489,7 +484,7 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, return result, nil } - p.config.Logger.Debug("Processing affected XRs", "composition", newComp.GetName(), "count", len(affectedXRs), "surfaceFiltered", surfaceFiltered) + p.config.Logger.Debug("Processing affected XRs", "composition", newComp.GetName(), "count", len(affectedXRs), "scoped", scoped) if len(keptXRs) == 0 { // All XRs were filtered; the summary recorded above is already complete. @@ -523,6 +518,12 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // Build impact analysis and counts from results for the kept set, then merge in any // already-appended filtered entries. keptImpacts, keptSummary := p.buildImpactAnalysis(keptXRs, xrResults) + // buildImpactAnalysis keeps keptXRs' order. A kept composite that does not re-point is one + // --include-manual kept although its Manual policy pins it. + for i, xr := range keptXRs { + keptImpacts[i].Pinned = !repointingXRs[dt.MakeDiffKeyFromResource(xr)] + } + result.ImpactAnalysis = append(result.ImpactAnalysis, keptImpacts...) // keptSummary.Total counts only kept; widen to include filtered so totals stay consistent. keptSummary.Total = len(affectedXRs) @@ -1110,7 +1111,18 @@ func (p *DefaultCompDiffProcessor) classifyXR(xr *un.Unstructured, targetLabels, return xrDisposition{pinnedToRevision: true}, nil } - return xrDisposition{filtered: &filteredXR{xr: xr, reason: dt.FilterReasonManualPolicy}}, nil + // Name the revision it stays pinned to, when it names one. + revision, err := xp.XRCompositionRevisionRef(xr.Object, xr.GetAPIVersion()) + if err != nil { + return xrDisposition{}, errors.Wrapf(err, "cannot read compositionRevisionRef for XR %q", xr.GetName()) + } + + detail := "" + if revision != "" { + detail = "compositionRevisionRef: " + revision + } + + return xrDisposition{filtered: &filteredXR{xr: xr, reason: dt.FilterReasonManualPolicy, detail: detail}}, nil } // Automatic (or default) policy: honor compositionRevisionSelector. An XR whose selector does not diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 8780c6f0..9d690f5b 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -1850,29 +1850,54 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { } }) - // SurfaceFilteredControlsImpactAnalysis: when surfaceFiltered=true (resource mode), - // Manual-policy XRs are surfaced as XRStatusFiltered impacts (reason manual_policy); when false - // (default-discovery), they're counted in the summary but absent from ImpactAnalysis. - t.Run("SurfaceFilteredControlsImpactAnalysis", func(t *testing.T) { - surfaceTests := map[string]struct { - surfaceFiltered bool - wantImpactCount int - wantStatus dt.XRStatus // empty when wantImpactCount == 0 - wantReason dt.FilterReason // empty when wantImpactCount == 0 + // RecordsFilteredInBothModes: Manual-policy XRs are recorded as XRStatusFiltered impacts (reason + // manual_policy) whether the set came from --resource or from discovery; Scoped records which, so + // the renderer can decide whether to name them. A Manual XR that names its revision carries it as + // FilterDetail. + t.Run("RecordsFilteredInBothModes", func(t *testing.T) { + pinnedManualXR := tu.NewResource("example.org/v1", "XR", "pinned-manual-xr"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + WithSpecField("crossplane", map[string]any{ + "compositionUpdatePolicy": "Manual", + "compositionRevisionRef": map[string]any{"name": "test-comp-abc1234"}, + }). + Build() + + filtered := func(name, detail string) dt.XRImpact { + return dt.XRImpact{ + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: name, Namespace: "ns"}, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonManualPolicy, + FilterDetail: detail, + } + } + + type want struct { + Scoped bool + Summary dt.AffectedResourcesSummary + ImpactAnalysis []dt.XRImpact + } + + wantImpacts := []dt.XRImpact{ + filtered("manual-xr", ""), + filtered("pinned-manual-xr", "compositionRevisionRef: test-comp-abc1234"), + } + + cases := map[string]struct { + scoped bool + want want }{ - "ResourceMode_SurfacesFilteredXRs": { - surfaceFiltered: true, - wantImpactCount: 1, - wantStatus: dt.XRStatusFiltered, - wantReason: dt.FilterReasonManualPolicy, + "ResourceMode": { + scoped: true, + want: want{Scoped: true, Summary: dt.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 2}, ImpactAnalysis: wantImpacts}, }, - "DefaultDiscovery_OmitsFilteredFromImpactAnalysis": { - surfaceFiltered: false, - wantImpactCount: 0, + "DefaultDiscovery": { + want: want{Summary: dt.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 2}, ImpactAnalysis: wantImpacts}, }, } - for name, tt := range surfaceTests { + for name, tt := range cases { t.Run(name, func(t *testing.T) { client := tu.NewMockCompositionClient(). WithSuccessfulCompositionFetch(&apiextensionsv1.Composition{}). @@ -1880,29 +1905,14 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { proc := newCompProcessorForTest(t, client, false /* IncludeManual */) - got, err := proc.processSingleComposition(t.Context(), comp, []*un.Unstructured{manualXR}, tt.surfaceFiltered) + got, err := proc.processSingleComposition(t.Context(), comp, []*un.Unstructured{manualXR, pinnedManualXR}, tt.scoped) if err != nil { t.Fatalf("processSingleComposition: %v", err) } - if len(got.ImpactAnalysis) != tt.wantImpactCount { - t.Fatalf("ImpactAnalysis: got %d entries, want %d (%+v)", len(got.ImpactAnalysis), tt.wantImpactCount, got.ImpactAnalysis) - } - - if tt.wantImpactCount > 0 && got.ImpactAnalysis[0].Status != tt.wantStatus { - t.Errorf("ImpactAnalysis[0].Status: got %q, want %q", got.ImpactAnalysis[0].Status, tt.wantStatus) - } - - if tt.wantImpactCount > 0 && got.ImpactAnalysis[0].FilterReason != tt.wantReason { - t.Errorf("ImpactAnalysis[0].FilterReason: got %q, want %q", got.ImpactAnalysis[0].FilterReason, tt.wantReason) - } - - if got.AffectedResources.FilteredByPolicy != 1 { - t.Errorf("FilteredByPolicy: got %d, want 1", got.AffectedResources.FilteredByPolicy) - } - - if got.AffectedResources.Total != 1 { - t.Errorf("Total: got %d, want 1", got.AffectedResources.Total) + gotWant := want{Scoped: got.Scoped, Summary: got.AffectedResources, ImpactAnalysis: got.ImpactAnalysis} + if diff := gcmp.Diff(tt.want, gotWant); diff != "" { + t.Errorf("processSingleComposition() mismatch (-want +got):\n%s", diff) } }) } @@ -1933,30 +1943,33 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { Skipped bool Summary dt.AffectedResourcesSummary RevisionImpact dt.RevisionImpact - // Impacts is every ImpactAnalysis entry as " ", in order — deliberately not - // narrowed to the filtered ones. Under a skip the kept composites were never evaluated, so - // ImpactAnalysis must hold only the filtered entries; a changed/unchanged/error entry here - // would be a verdict the tool never earned, and narrowing this view would hide exactly that. + // Impacts is every ImpactAnalysis entry as " ", in order, with " pinned" appended + // for a Pinned one — deliberately not narrowed to the filtered ones. Under a skip the kept + // composites were never evaluated, so ImpactAnalysis must hold only the filtered entries; a + // changed/unchanged/error entry here would be a verdict the tool never earned, and narrowing + // this view would hide exactly that. Impacts []string } cases := map[string]struct { - analyzeOn AnalyzeOn - includeManual bool - surfaceFiltered bool - want want + analyzeOn AnalyzeOn + includeManual bool + scoped bool + want want }{ - "Skipped_DefaultDiscovery_CountsFilteredComposites": { + // Discovery records the filtered composite too; only the renderer leaves it unnamed. + "Skipped_DefaultDiscovery_RecordsFilteredComposites": { analyzeOn: AnalyzeOnSpecChange, want: want{ Skipped: true, Summary: dt.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, + Impacts: []string{"filtered manual-xr"}, }, }, "Skipped_ResourceMode_NamesFilteredComposites": { - analyzeOn: AnalyzeOnSpecChange, - surfaceFiltered: true, + analyzeOn: AnalyzeOnSpecChange, + scoped: true, want: want{ Skipped: true, Summary: dt.AffectedResourcesSummary{Total: 2, FilteredByPolicy: 1}, @@ -1967,9 +1980,9 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { // --include-manual keeps the Manual composite, so nothing is filtered — but it stays pinned by // compositionRevisionRef, so only the Automatic composite re-points. "Skipped_IncludeManual_ManualCompositeDoesNotRepoint": { - analyzeOn: AnalyzeOnSpecChange, - includeManual: true, - surfaceFiltered: true, + analyzeOn: AnalyzeOnSpecChange, + includeManual: true, + scoped: true, want: want{ Skipped: true, Summary: dt.AffectedResourcesSummary{Total: 2}, @@ -1977,15 +1990,16 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { }, }, // The same tally when the analysis does run: both composites are evaluated, one re-points. - // Evaluated, so unlike every skipped case they carry a verdict. + // Evaluated, so unlike every skipped case they carry a verdict, and the one that does not + // re-point is marked Pinned. "Analysed_IncludeManual_ManualCompositeDoesNotRepoint": { - analyzeOn: AnalyzeOnAnyChange, - includeManual: true, - surfaceFiltered: true, + analyzeOn: AnalyzeOnAnyChange, + includeManual: true, + scoped: true, want: want{ Summary: dt.AffectedResourcesSummary{Total: 2, Unchanged: 2}, RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, - Impacts: []string{"unchanged xr-1", "unchanged manual-xr"}, + Impacts: []string{"unchanged xr-1", "unchanged manual-xr pinned"}, }, }, } @@ -1999,7 +2013,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { proc := newCompProcessorForTest(t, client, tt.includeManual) proc.config.AnalyzeOn = tt.analyzeOn - got, err := proc.processSingleComposition(t.Context(), metaOnlyComp, []*un.Unstructured{xr1, manualXR}, tt.surfaceFiltered) + got, err := proc.processSingleComposition(t.Context(), metaOnlyComp, []*un.Unstructured{xr1, manualXR}, tt.scoped) if err != nil { t.Fatalf("processSingleComposition: %v", err) } @@ -2007,7 +2021,12 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { impacts := make([]string, 0, len(got.ImpactAnalysis)) for _, impact := range got.ImpactAnalysis { - impacts = append(impacts, fmt.Sprintf("%s %s", impact.Status, impact.Name)) + entry := fmt.Sprintf("%s %s", impact.Status, impact.Name) + if impact.Pinned { + entry += " pinned" + } + + impacts = append(impacts, entry) } // The predicted name's derivation is pinned by TestRevisionIdentity; this matrix only @@ -2057,7 +2076,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { proc := newCompProcessorForTest(t, client, false /* IncludeManual */) - got, err := proc.processSingleComposition(t.Context(), labeledComp, []*un.Unstructured{selectorMismatchXR}, true /* surfaceFiltered */) + got, err := proc.processSingleComposition(t.Context(), labeledComp, []*un.Unstructured{selectorMismatchXR}, true /* scoped */) if err != nil { t.Fatalf("processSingleComposition: %v", err) } diff --git a/internal/diff/renderer/comp_diff_renderer.go b/internal/diff/renderer/comp_diff_renderer.go index 11f0bbf3..3ab7784d 100644 --- a/internal/diff/renderer/comp_diff_renderer.go +++ b/internal/diff/renderer/comp_diff_renderer.go @@ -209,13 +209,14 @@ func skippedMessage(impact dt.RevisionImpact) string { // renderAffectedResourcesList prints, which would be unearned here — those composites went // unevaluated. // -// As elsewhere, --resource mode names each composite (the user asked about them by name, and their -// FilterReason reached ImpactAnalysis) while default discovery reports the per-reason breakdown. +// As elsewhere, --resource mode names each composite (the user asked about them by name) while +// default discovery reports the per-reason breakdown (see listedImpacts). // -// ImpactAnalysis is listed as-is, not re-filtered by status: under a skip it holds only the filtered -// entries, because the kept composites were never rendered. That is the processor's guarantee, pinned -// by its SkipReportsFilterConsequences test. Re-filtering here would not add safety — it would hide an -// unearned changed/unchanged verdict from this output while it still reached the structured output. +// The listed impacts are not re-filtered by status: under a skip ImpactAnalysis holds only the +// filtered entries, because the kept composites were never rendered. That is the processor's +// guarantee, pinned by its SkipReportsFilterConsequences test. Re-filtering here would not add safety +// — it would hide an unearned changed/unchanged verdict from this output while it still reached the +// structured output. func (r *DefaultCompDiffRenderer) renderFilteredUnderSkip(comp *dt.CompositionDiff) error { filtered := totalFiltered(comp.AffectedResources) if filtered == 0 { @@ -229,8 +230,8 @@ func (r *DefaultCompDiffRenderer) renderFilteredUnderSkip(comp *dt.CompositionDi return errors.Wrap(err, "cannot write filtered XRs summary") } - if len(comp.ImpactAnalysis) > 0 { - if _, err := fmt.Fprint(stdout, r.buildXRStatusList(comp.ImpactAnalysis)); err != nil { + if impacts := listedImpacts(comp); len(impacts) > 0 { + if _, err := fmt.Fprint(stdout, r.buildXRStatusList(impacts)); err != nil { return errors.Wrap(err, "cannot write filtered XRs list") } } @@ -344,11 +345,32 @@ func (r *DefaultCompDiffRenderer) renderMinimizedCompositionChanges(comp *dt.Com return nil } +// listedImpacts is the part of a composition's ImpactAnalysis the output lists one by one. The +// processor records the filtered composites in both modes, but only --resource mode (Scoped) names +// them: the user asked about those composites by name. Default discovery reports them only through +// the AffectedResources filter counters. +func listedImpacts(comp *dt.CompositionDiff) []dt.XRImpact { + if comp.Scoped { + return comp.ImpactAnalysis + } + + listed := make([]dt.XRImpact, 0, len(comp.ImpactAnalysis)) + + for _, impact := range comp.ImpactAnalysis { + if impact.Status != dt.XRStatusFiltered { + listed = append(listed, impact) + } + } + + return listed +} + // renderAffectedResourcesList renders the affected XRs list with status indicators. func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *dt.CompositionDiff) error { stdout := r.opts.Stdout + impacts := listedImpacts(comp) - if len(comp.ImpactAnalysis) == 0 { + if len(impacts) == 0 { // No XRs surfaced. Either none were found, or all matched-by-name XRs were filtered out // (by Manual policy, revision-selector mismatch, and/or being deleted); report the // breakdown if so. @@ -367,7 +389,7 @@ func (r *DefaultCompDiffRenderer) renderAffectedResourcesList(comp *dt.Compositi } // Build the XR list with status indicators - xrList := r.buildXRStatusList(comp.ImpactAnalysis) + xrList := r.buildXRStatusList(impacts) // Generate summary line summary := formatXRStatusSummary( @@ -665,7 +687,7 @@ func buildStructuredCompOutput(output *dt.CompDiffOutput) *compDiffWireV2 { jsonComp.CompositionChanges = resourceDiffToChangeDetail(comp.CompositionDiff) } - for _, impact := range comp.ImpactAnalysis { + for _, impact := range listedImpacts(&comp) { entry := toXREntry(impact.ObjectReference, impact.Status, impact.Diffs, impact.Error) entry.FilterReason = impact.FilterReason entry.FilterDetail = impact.FilterDetail diff --git a/internal/diff/renderer/comp_diff_renderer_test.go b/internal/diff/renderer/comp_diff_renderer_test.go index 801be593..d7a56946 100644 --- a/internal/diff/renderer/comp_diff_renderer_test.go +++ b/internal/diff/renderer/comp_diff_renderer_test.go @@ -325,20 +325,59 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { } }, }, + // Discovery records the filtered composites in ImpactAnalysis but reports only their count. "FilteredByPolicy": { output: &dt.CompDiffOutput{ Compositions: []dt.CompositionDiff{{ Name: "test-comp", - AffectedResources: dt.AffectedResourcesSummary{FilteredByPolicy: 3}, - ImpactAnalysis: []dt.XRImpact{}, + AffectedResources: dt.AffectedResourcesSummary{Total: 1, FilteredByPolicy: 1}, + ImpactAnalysis: []dt.XRImpact{{ + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonManualPolicy, + FilterDetail: "compositionRevisionRef: test-comp-abc1234", + }}, + }}, + }, + colorize: false, + validate: func(t *testing.T, result string) { + t.Helper() + + if !strings.Contains(result, "All 1 XR(s) using composition test-comp have Manual update policy (use --include-manual to see them)") { + t.Errorf("Expected Manual update policy message, got:\n%s", result) + } + + if strings.Contains(result, "manual-xr") || strings.Contains(result, "=== Affected Composite Resources ===") { + t.Errorf("Expected discovery to leave the filtered composite unnamed, got:\n%s", result) + } + }, + }, + // With evaluated composites beside a filtered one, discovery lists only the evaluated ones. + "DiscoveryListsOnlyEvaluatedComposites": { + output: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ + Name: "test-comp", + AffectedResources: dt.AffectedResourcesSummary{Total: 2, Unchanged: 1, FilteredBySelector: 1}, + ImpactAnalysis: []dt.XRImpact{ + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonRevisionSelectorMismatch, + }, + {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "xr-1", Namespace: "ns"}, Status: dt.XRStatusUnchanged}, + }, }}, }, colorize: false, validate: func(t *testing.T, result string) { t.Helper() - if !strings.Contains(result, "Manual update policy") { - t.Error("Expected Manual update policy message") + if !strings.Contains(result, "XR/xr-1 (namespace: ns)") { + t.Errorf("Expected the evaluated composite listed, got:\n%s", result) + } + + if strings.Contains(result, "selector-xr") { + t.Errorf("Expected discovery to leave the filtered composite unnamed, got:\n%s", result) } }, }, @@ -460,13 +499,32 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { ImpactAnalysisSkipped: true, RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, AffectedResources: dt.AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredByDeletion: 1}, - ImpactAnalysis: []dt.XRImpact{}, + ImpactAnalysis: []dt.XRImpact{ + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "my-manual-xr", Namespace: "default"}, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonManualPolicy, + }, + { + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "my-deleting-xr", Namespace: "default"}, + Status: dt.XRStatusFiltered, + FilterReason: dt.FilterReasonDeleting, + FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", + }, + }, }}, }, colorize: false, validate: func(t *testing.T, result string) { t.Helper() + // Discovery reports the breakdown, not the names. + for _, unwanted := range []string{"my-manual-xr", "my-deleting-xr"} { + if strings.Contains(result, unwanted) { + t.Errorf("did not expect discovery to name %q, got:\n%s", unwanted, result) + } + } + for _, want := range []string{ "Impact analysis skipped", "2 of 3 XR(s) using composition test-comp would not adopt this revision", @@ -493,6 +551,7 @@ func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { output: &dt.CompDiffOutput{ Compositions: []dt.CompositionDiff{{ Name: "test-comp", + Scoped: true, ImpactAnalysisSkipped: true, RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true}, AffectedResources: dt.AffectedResourcesSummary{Total: 1, FilteredByDeletion: 1}, @@ -835,6 +894,7 @@ func TestXRStatusFiltered_JSON(t *testing.T) { output := &dt.CompDiffOutput{ Compositions: []dt.CompositionDiff{{ Name: "test-comp", + Scoped: true, AffectedResources: dt.AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, ImpactAnalysis: []dt.XRImpact{ { @@ -924,6 +984,28 @@ func TestXRStatusFiltered_JSON(t *testing.T) { if got := parsed.Compositions[0].AffectedResources.FilteredByDeletion; got != 1 { t.Errorf("filteredByDeletion: got %d, want 1", got) } + + // Default discovery records the same filtered composites but, as in the human output, reports + // them only through the counters. + output.Compositions[0].Scoped = false + jsonBuf.Reset() + + if err := r.RenderCompDiff(output); err != nil { + t.Fatalf("RenderCompDiff (discovery): %v", err) + } + + var discovery compDiffWire + if err := json.Unmarshal(jsonBuf.Bytes(), &discovery); err != nil { + t.Fatalf("unmarshal (discovery): %v", err) + } + + if got := discovery.Compositions[0].ImpactAnalysis; len(got) != 0 { + t.Errorf("discovery impactAnalysis: got %+v, want none", got) + } + + if got := discovery.Compositions[0].AffectedResources; got != output.Compositions[0].AffectedResources { + t.Errorf("discovery affectedResources: got %+v, want %+v", got, output.Compositions[0].AffectedResources) + } } func TestXRStatusFiltered_TextRenderer(t *testing.T) { @@ -935,6 +1017,7 @@ func TestXRStatusFiltered_TextRenderer(t *testing.T) { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonManualPolicy, + FilterDetail: "compositionRevisionRef: test-comp-abc1234", }, { ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, @@ -959,9 +1042,14 @@ func TestXRStatusFiltered_TextRenderer(t *testing.T) { t.Errorf("expected both XR names in output, got %q", got) } - // Manual-policy exclusion must mention the policy and the escape hatch. - if !strings.Contains(strings.ToLower(got), "manual update policy") { - t.Errorf("expected 'Manual update policy' verbiage, got %q", got) + // Manual-policy exclusion must mention the policy and the escape hatch, and nothing more: the + // revision it stays pinned to is data for structured output, not part of the human line. + if !strings.Contains(got, "manual-xr (namespace: ns) — filtered: Manual update policy (use --include-manual to evaluate)\x1b[0m\n") { + t.Errorf("expected the Manual-policy line unchanged, got %q", got) + } + + if strings.Contains(got, "compositionRevisionRef") { + t.Errorf("expected the Manual-policy line to omit its FilterDetail, got %q", got) } // Selector-mismatch exclusion must distinguish itself from policy and surface the hint. @@ -1305,6 +1393,7 @@ func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { Compositions: []dt.CompositionDiff{ { Name: "xbuckets.example.org", + Scoped: true, AffectedResources: affected, RevisionImpact: revisionImpact, ImpactAnalysis: []dt.XRImpact{ diff --git a/internal/diff/result/comp.go b/internal/diff/result/comp.go index d5de2e5a..ad94cf74 100644 --- a/internal/diff/result/comp.go +++ b/internal/diff/result/comp.go @@ -75,20 +75,26 @@ type CompDiffOutput struct { // This stores rich ResourceDiff data. Conversion to JSON happens in the renderer. type CompositionDiff struct { Name string + // Scoped records that the composites were the ones named by --resource rather than discovered from + // the cluster. ImpactAnalysis holds the filtered composites (XRStatusFiltered) either way; the + // human renderer lists them by name only when Scoped, and otherwise reports only the counts. + Scoped bool // Error is the per-composition processing error (nil if successful). Its ResourceID is // "Composition/". Error *OutputError CompositionDiff *ResourceDiff // the actual composition diff (nil if unchanged) AffectedResources AffectedResourcesSummary - ImpactAnalysis []XRImpact + // ImpactAnalysis holds every affected composite: the filtered ones first, as XRStatusFiltered, + // then the evaluated ones. + ImpactAnalysis []XRImpact // ImpactAnalysisSkipped records that the affected XRs were deliberately not evaluated, because // the composition changed by less than --analyze-on asked to analyse. Distinguishes "we did not // look" from "we looked and found no affected XRs", which would otherwise be indistinguishable. // // It does not mean ImpactAnalysis is empty. Composites excluded by update policy, revision // selector or deletion are identified locally, with no render, so they are reported whether or not - // the analysis ran — in --resource mode as XRStatusFiltered entries here, and always via the - // AffectedResources filter counters (issue #478). What a skip suppresses is the renders, and + // the analysis ran — as XRStatusFiltered entries here, and via the AffectedResources filter + // counters (issue #478). What a skip suppresses is the renders, and // therefore any changed/unchanged/errored verdict about the composites that were kept. // // Why the composites went unevaluated is not recoverable from this field alone — read @@ -223,8 +229,14 @@ type XRImpact struct { // FilterReason explains a Status == XRStatusFiltered outcome; empty otherwise. FilterReason FilterReason // FilterDetail is an optional human-readable explanation for a filtered outcome (e.g. which - // selector failed to match which labels), surfaced to help users self-diagnose the exclusion. + // selector failed to match which labels), surfaced to help users self-diagnose the exclusion. A + // Manual-policy composite names the revision it stays pinned to ("compositionRevisionRef: ") + // when it has a ref. FilterDetail string + // Pinned marks an evaluated composite that would not adopt the composition's resulting revision, + // because its Manual compositionUpdatePolicy pins it to its compositionRevisionRef. Only + // --include-manual evaluates such a composite; without it, the composite is XRStatusFiltered. + Pinned bool // Error is the structured processing error for an XRStatusError impact (nil otherwise), the same // OutputError the command lists in its top-level errors. Error *OutputError From 2a8b154219c723c7138d983ec9b22f530ad84c3c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 21:41:39 -0400 Subject: [PATCH 046/126] feat(simulate): map diff reports to plan.Result 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) Signed-off-by: Jonathan Ogilvie --- internal/plan/output.go | 11 + internal/simulate/report.go | 405 +++++++++++++++++++++++++++ internal/simulate/report_test.go | 464 +++++++++++++++++++++++++++++++ 3 files changed, 880 insertions(+) create mode 100644 internal/simulate/report.go create mode 100644 internal/simulate/report_test.go diff --git a/internal/plan/output.go b/internal/plan/output.go index 1fa9e82a..45c728be 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -113,6 +113,9 @@ type CompositeResult struct { Reason string `json:"reason,omitempty"` // Detail elaborates on Reason, e.g. the pinned revision. Detail string `json:"detail,omitempty"` + // Pinned marks a composite simulated although it is pinned to a + // composition revision, previewing the moment that revision advances. + Pinned bool `json:"pinned,omitempty"` } // Error is an error from a simulate run, optionally anchored to a resource. @@ -152,6 +155,11 @@ type Warning struct { type CompositionResult struct { Ref ResourceRef `json:"ref"` RevisionImpact *RevisionImpact `json:"revisionImpact,omitempty"` + // ImpactAnalysisSkipped means the composites that would adopt the + // Composition's revision were deliberately not simulated, so no + // changed, unchanged or error verdict exists for them. Composites the + // revision would not reach are still listed in Result.Composites. + ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` } // RevisionImpact says whether applying a Composition creates a revision, and @@ -178,6 +186,9 @@ type Result struct { Plans []*ResourcePlan `json:"plans"` Errors []Error `json:"errors,omitempty"` Warnings []Warning `json:"warnings,omitempty"` + // Notes are statements about how the result was produced, e.g. that a + // planning layer did not run. + Notes []string `json:"notes,omitempty"` } // NewResult assembles the serializable Result for the given plans. diff --git a/internal/simulate/report.go b/internal/simulate/report.go new file mode 100644 index 00000000..1ec3f729 --- /dev/null +++ b/internal/simulate/report.go @@ -0,0 +1,405 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "cmp" + "maps" + "reflect" + "slices" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// noteProviderPlanNotRun says the cluster-layer result was not refined by +// provider diff servers, so its plans say nothing about the cloud resources. +const noteProviderPlanNotRun = "Cloud-layer plan not run (provider diff servers not yet wired)." + +// FromXRReport maps an xr or project diff run onto plan.Result: one plan per +// resource diff (the XR's own included), each grouped under its input XR or +// claim, and one Composites entry per input. +func FromXRReport(r *dt.XRReport) plan.Result { + if r == nil { + r = &dt.XRReport{} + } + + a := newAssembler() + owned := make([]dt.OutputError, 0, len(r.Groups)) + + for _, g := range r.Groups { + // The group's XR is the input's identity, so a claim input keeps the + // claim's kind and name. + xr := resourceRef(g.XR) + c := plan.CompositeResult{Ref: xr, Status: plan.CompositeUnchanged} + + switch { + case g.Err != nil: + c.Status = plan.CompositeError + a.addError(xr, *g.Err, &xr) + owned = append(owned, *g.Err) + case hasChanges(g.Diffs): + c.Status = plan.CompositeChanged + } + + a.composites = append(a.composites, c) + a.addDiffs(g.Diffs, &xr) + } + + a.addRunErrors(r.Errors, owned) + + return a.result(slices.Concat(r.Warnings, dt.XRDryRunWarnings(r.Groups))) +} + +// FromCompReport maps a composition diff run onto plan.Result: a plan and a +// Compositions entry per Composition, and a Composites entry per affected +// composite, with that composite's downstream plans grouped under it. +func FromCompReport(o *dt.CompDiffOutput) plan.Result { + if o == nil { + o = &dt.CompDiffOutput{} + } + + a := newAssembler() + owned := make([]dt.OutputError, 0, len(o.Errors)) + compositions := make([]plan.CompositionResult, 0, len(o.Compositions)) + + for i := range o.Compositions { + comp := &o.Compositions[i] + ref := plan.ResourceRef{ + APIVersion: apiextensionsv1.CompositionGroupVersionKind.GroupVersion().String(), + Kind: apiextensionsv1.CompositionKind, + Name: comp.Name, + } + + cr := plan.CompositionResult{Ref: ref, ImpactAnalysisSkipped: comp.ImpactAnalysisSkipped} + // A zero RevisionImpact means the comparison never ran; reporting + // it would claim the composition creates no revision. + if ri := comp.RevisionImpact; ri.Determined() { + cr.RevisionImpact = &plan.RevisionImpact{ + ChangeScope: ri.ChangeScope, + CreatesRevision: ri.CreatesRevision, + RepointedComposites: ri.RepointedComposites, + PredictedRevision: ri.PredictedRevisionName, + } + } + compositions = append(compositions, cr) + + switch { + case comp.Error != nil: + // A composition-level error is not in the run's Errors list. + a.addError(ref, *comp.Error, nil) + case comp.CompositionDiff != nil: + a.addDiff(comp.CompositionDiff, nil) + default: + // Unchanged, or changed only in fields masked from the diff. + a.plans = append(a.plans, &plan.ResourcePlan{ + APIVersion: ref.APIVersion, Kind: ref.Kind, Name: ref.Name, Action: plan.ActionNoOp, + }) + } + + for _, impact := range comp.ImpactAnalysis { + xr := resourceRef(impact.ObjectReference) + c := compositeResult(impact) + if impact.Error != nil { + a.addError(xr, *impact.Error, &xr) + owned = append(owned, *impact.Error) + } + if c.Status == plan.CompositeDeferred { + a.deferred++ + } + + a.composites = append(a.composites, c) + a.addDiffs(impact.Diffs, &xr) + } + } + + a.addRunErrors(o.Errors, owned) + + res := a.result(slices.Concat(o.Warnings, dt.CompDryRunWarnings(o))) + res.Compositions = compositions + + return res +} + +// compositeResult maps an affected composite's impact onto its status. A +// Manual-policy composite is deferred: applying changes nothing for it until +// its revision advances. Other filtered composites are skipped. +func compositeResult(impact dt.XRImpact) plan.CompositeResult { + c := plan.CompositeResult{Ref: resourceRef(impact.ObjectReference), Pinned: impact.Pinned} + + switch impact.Status { + case dt.XRStatusChanged: + c.Status = plan.CompositeChanged + case dt.XRStatusUnchanged: + c.Status = plan.CompositeUnchanged + case dt.XRStatusError: + c.Status = plan.CompositeError + case dt.XRStatusFiltered: + c.Status = plan.CompositeSkipped + if impact.FilterReason == dt.FilterReasonManualPolicy { + c.Status = plan.CompositeDeferred + } + c.Reason = string(impact.FilterReason) + c.Detail = impact.FilterDetail + default: + c.Status = plan.CompositeStatus(impact.Status) + } + + return c +} + +// assembler accumulates one run's plans, composites and errors. +type assembler struct { + plans []*plan.ResourcePlan + composites []plan.CompositeResult + errors []plan.Error + deferred int + // listed holds, per resource, the unredacted changes already planned, + // so a change two inputs reach identically is listed once. + listed map[string][]listedChange +} + +type listedChange struct { + action plan.Action + changes []plan.FieldChange + dryRun *plan.Verification +} + +func newAssembler() *assembler { + return &assembler{plans: []*plan.ResourcePlan{}, listed: map[string][]listedChange{}} +} + +// addDiffs adds a plan per diff, in the structured renderer's canonical order. +func (a *assembler) addDiffs(diffs map[string]*dt.ResourceDiff, composite *plan.ResourceRef) { + for _, d := range slices.SortedFunc(maps.Values(diffs), diffOrder) { + a.addDiff(d, composite) + } +} + +// addDiff adds the plan for d under composite, unless an identical change to +// the same resource is already listed (under the first composite reaching +// it). Differing renders of one resource are all listed, as the structured +// renderer's flat view lists them. +func (a *assembler) addDiff(d *dt.ResourceDiff, composite *plan.ResourceRef) { + p := resourcePlan(d) + p.Composite = composite + + key := d.GetDiffKey() + lc := listedChange{action: p.Action, changes: p.Changes, dryRun: p.DryRun} + if slices.ContainsFunc(a.listed[key], func(l listedChange) bool { return reflect.DeepEqual(l, lc) }) { + return + } + a.listed[key] = append(a.listed[key], lc) + + // Redacted after the duplicate check, so two Secrets differing only in + // their redacted values are not mistaken for one change. + p.Changes = redact(d.Gvk, p.Changes) + a.plans = append(a.plans, p) +} + +// addError records e, failing the plan for ref. composite is the composite +// the plan groups under, nil for a Composition. +func (a *assembler) addError(ref plan.ResourceRef, e dt.OutputError, composite *plan.ResourceRef) { + a.plans = append(a.plans, &plan.ResourcePlan{ + APIVersion: ref.APIVersion, Kind: ref.Kind, Name: ref.Name, Namespace: ref.Namespace, + Error: e.Message, Composite: composite, + }) + a.errors = append(a.errors, planError(e, &ref)) +} + +// addRunErrors records the errors in all that no resource owns: all is the +// run's union error list, and owned the errors already recorded against a +// resource. Each owned entry consumes one equal entry of all. +func (a *assembler) addRunErrors(all, owned []dt.OutputError) { + rest := slices.Clone(all) + for _, o := range owned { + if i := slices.IndexFunc(rest, func(e dt.OutputError) bool { return reflect.DeepEqual(e, o) }); i >= 0 { + rest = slices.Delete(rest, i, i+1) + } + } + + for _, e := range rest { + a.errors = append(a.errors, planError(e, nil)) + } +} + +func (a *assembler) result(warnings []dt.OutputWarning) plan.Result { + res := plan.NewResult(a.plans) + res.Summary.Deferred = a.deferred + res.Composites = a.composites + res.Errors = a.errors + res.Errored = res.Errored || len(a.errors) > 0 + res.Notes = []string{noteProviderPlanNotRun} + + for _, w := range warnings { + res.Warnings = append(res.Warnings, plan.Warning{Message: w.Message, Context: w.Context}) + } + + return res +} + +// resourcePlan maps one diff onto a plan. The engine's DiffType is the +// verdict: it already discounts defaulting noise, so the action never depends +// on whether any field changes remain. +func resourcePlan(d *dt.ResourceDiff) *plan.ResourcePlan { + p := &plan.ResourcePlan{ + APIVersion: d.Gvk.GroupVersion().String(), + Kind: d.Gvk.Kind, + // ResourceName carries the "(generated)" display name. + Name: d.ResourceName, + Namespace: d.Namespace, + DryRun: verification(d.DryRun), + } + + switch d.DiffType { + case dt.DiffTypeAdded: + p.Action = plan.ActionCreate + p.Changes = fieldChanges(nil, d.Desired.Clean) + case dt.DiffTypeModified: + p.Action = plan.ActionUpdate + if d.Current.Clean != nil && d.Desired.Clean != nil { + p.Changes = fieldChanges(d.Current.Clean, d.Desired.Clean) + } + case dt.DiffTypeRemoved: + // A delete carries what is removed, like Terraform's "before". + p.Action = plan.ActionDelete + p.Changes = fieldChanges(d.Current.Clean, nil) + case dt.DiffTypeEqual: + // Clean is nil for an equal diff. + p.Action = plan.ActionNoOp + } + + return p +} + +// fieldChanges diffs the Clean views, which have ignored paths, status and +// server-managed metadata already stripped, minus the fields the plan's +// header already shows. +func fieldChanges(actual, planned *unstructured.Unstructured) []plan.FieldChange { + changes := plan.FieldChanges(object(actual), object(planned)) + + return slices.DeleteFunc(changes, func(c plan.FieldChange) bool { + switch c.Path { + case "apiVersion", "kind", "metadata.name", "metadata.namespace": + return true + } + + return false + }) +} + +func object(u *unstructured.Unstructured) map[string]any { + if u == nil { + return nil + } + + return u.Object +} + +// redact marks a Secret's data and stringData values sensitive. +func redact(gvk schema.GroupVersionKind, changes []plan.FieldChange) []plan.FieldChange { + if gvk.Group != "" || gvk.Kind != "Secret" { + return changes + } + + out := slices.Clone(changes) + for i, c := range out { + if !isSecretValue(c.Path) { + continue + } + if c.Actual != nil { + out[i].Actual = &plan.Value{Sensitive: true} + } + if c.Planned != nil { + out[i].Planned = &plan.Value{Sensitive: true} + } + } + + return out +} + +func isSecretValue(path string) bool { + for _, field := range []string{"data", "stringData"} { + if rest, ok := strings.CutPrefix(path, field); ok && (rest == "" || rest[0] == '.' || rest[0] == '[') { + return true + } + } + + return false +} + +// diffOrder is the structured renderer's canonical order: kind, then name, +// then the full key. +func diffOrder(a, b *dt.ResourceDiff) int { + return cmp.Or( + cmp.Compare(a.Gvk.Kind, b.Gvk.Kind), + cmp.Compare(a.ResourceName, b.ResourceName), + cmp.Compare(a.GetDiffKey(), b.GetDiffKey()), + ) +} + +// hasChanges reports whether any diff is not equal, the structured +// renderer's rule for a changed XR. +func hasChanges(diffs map[string]*dt.ResourceDiff) bool { + for _, d := range diffs { + if d.DiffType != dt.DiffTypeEqual { + return true + } + } + + return false +} + +func resourceRef(o corev1.ObjectReference) plan.ResourceRef { + return plan.ResourceRef{APIVersion: o.APIVersion, Kind: o.Kind, Name: o.Name, Namespace: o.Namespace} +} + +func verification(d *dt.DryRunInfo) *plan.Verification { + if d == nil { + return nil + } + + return &plan.Verification{ + Performed: d.Performed, + SkipReason: string(d.SkipReason), + Detail: d.Detail, + StorageVersion: d.StorageVersion, + } +} + +func planError(e dt.OutputError, resource *plan.ResourceRef) plan.Error { + out := plan.Error{Resource: resource, Message: e.Message} + + for _, f := range e.ValidationFailures { + vf := plan.ValidationFailure{ + APIVersion: f.APIVersion, Kind: f.Kind, Name: f.Name, Namespace: f.Namespace, Status: f.Status, + } + for _, fe := range f.Errors { + vf.Errors = append(vf.Errors, plan.FieldValidationError{Type: fe.Type, Field: fe.Field, Message: fe.Message, Value: fe.Value}) + } + out.ValidationFailures = append(out.ValidationFailures, vf) + } + + return out +} diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go new file mode 100644 index 00000000..9d7d84bb --- /dev/null +++ b/internal/simulate/report_test.go @@ -0,0 +1,464 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "maps" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +var ( + xnetGVK = schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XNetwork"} + cmGVK = schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"} + secGVK = schema.GroupVersionKind{Version: "v1", Kind: "Secret"} + + xrRef = corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod"} + xrPlan = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod"} + compPlan = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"} +) + +func obj(gvk schema.GroupVersionKind, name string, fields map[string]any) *unstructured.Unstructured { + u := &unstructured.Unstructured{Object: map[string]any{}} + maps.Copy(u.Object, fields) + u.SetGroupVersionKind(gvk) + u.SetName(name) + return u +} + +func rdiff(gvk schema.GroupVersionKind, name string, t dt.DiffType, current, desired map[string]any) *dt.ResourceDiff { + d := &dt.ResourceDiff{Gvk: gvk, ResourceName: name, DiffType: t} + if current != nil { + d.Current.Clean = obj(gvk, name, current) + } + if desired != nil { + d.Desired.Clean = obj(gvk, name, desired) + } + return d +} + +func diffs(ds ...*dt.ResourceDiff) map[string]*dt.ResourceDiff { + m := map[string]*dt.ResourceDiff{} + for _, d := range ds { + m[d.GetDiffKey()] = d + } + return m +} + +//go:fix inline +func ref(p plan.ResourceRef) *plan.ResourceRef { return new(p) } + +func notes() []string { return []string{noteProviderPlanNotRun} } + +func TestFromXRReport(t *testing.T) { + xrEqual := rdiff(xnetGVK, "prod", dt.DiffTypeEqual, nil, nil) + validation := dt.OutputError{ + ResourceID: "XNetwork/prod", + Message: "schema validation failed", + ValidationFailures: []dt.ResourceValidationFailure{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Status: "invalid", + Errors: []dt.FieldValidationError{{Type: "schema", Field: "data.k", Message: "must be a string", Value: 7.0}}, + }}, + } + + unverified := rdiff(cmGVK, "cm", dt.DiffTypeAdded, nil, map[string]any{}) + unverified.DryRun = &dt.DryRunInfo{SkipReason: dt.DryRunSkipForbidden, Detail: "no create"} + dryRunReport := &dt.XRReport{ + Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(unverified)}}, + Warnings: []dt.OutputWarning{{Message: "heads up", Context: map[string]string{"k": "v"}}}, + } + // The derived summary's wording belongs to the result package; the mapper + // only has to carry it after the raised warnings. + derived := dt.XRDryRunWarnings(dryRunReport.Groups) + if len(derived) == 0 { + t.Fatal("XRDryRunWarnings(...): want a summary of the unverified addition") + } + dryRunWarnings := make([]plan.Warning, 0, len(derived)) + for _, w := range derived { + dryRunWarnings = append(dryRunWarnings, plan.Warning{Message: w.Message, Context: w.Context}) + } + + cases := map[string]struct { + reason string + in *dt.XRReport + want plan.Result + }{ + "EachDiffType": { + reason: "Each DiffType maps to its action; a delete carries actual-only changes; the XR's own equal diff is a no-op plan.", + in: &dt.XRReport{Groups: []dt.XRDiffGroup{{ + XR: xrRef, + Diffs: diffs( + xrEqual, + rdiff(cmGVK, "added", dt.DiffTypeAdded, nil, map[string]any{"data": map[string]any{"k": "v"}}), + rdiff(cmGVK, "modified", dt.DiffTypeModified, + map[string]any{"data": map[string]any{"k": "old"}}, + map[string]any{"data": map[string]any{"k": "new"}}), + rdiff(cmGVK, "removed", dt.DiffTypeRemoved, map[string]any{"data": map[string]any{"k": "gone"}}, nil), + ), + }}}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Create: 1, Update: 1, Destroy: 1, NoOp: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged}}, + Plans: []*plan.ResourcePlan{ + { + APIVersion: "v1", Kind: "ConfigMap", Name: "added", Action: plan.ActionCreate, Composite: &xrPlan, + Changes: []plan.FieldChange{{Path: "data.k", Planned: &plan.Value{Value: "v"}}}, + }, + { + APIVersion: "v1", Kind: "ConfigMap", Name: "modified", Action: plan.ActionUpdate, Composite: &xrPlan, + Changes: []plan.FieldChange{{Path: "data.k", Actual: &plan.Value{Value: "old"}, Planned: &plan.Value{Value: "new"}}}, + }, + { + APIVersion: "v1", Kind: "ConfigMap", Name: "removed", Action: plan.ActionDelete, Composite: &xrPlan, + Changes: []plan.FieldChange{{Path: "data.k", Actual: &plan.Value{Value: "gone"}}}, + }, + {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Action: plan.ActionNoOp, Composite: &xrPlan}, + }, + Notes: notes(), + }, + }, + "Unchanged": { + reason: "An XR whose only diff is its own equal one is unchanged.", + in: &dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(xrEqual)}}}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{NoOp: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeUnchanged}}, + Plans: []*plan.ResourcePlan{ + {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Action: plan.ActionNoOp, Composite: &xrPlan}, + }, + Notes: notes(), + }, + }, + "XRErrorWithValidationFailures": { + reason: "A failed XR is an errored composite with an error plan; its typed error keeps the validation failures and is not repeated as a run error.", + in: &dt.XRReport{ + Groups: []dt.XRDiffGroup{{XR: xrRef, Err: &validation}}, + Errors: []dt.OutputError{validation}, + }, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Errored: true, + Summary: plan.Summary{Errored: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeError}}, + Plans: []*plan.ResourcePlan{ + {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Error: "schema validation failed", Composite: &xrPlan}, + }, + Errors: []plan.Error{{ + Resource: &xrPlan, + Message: "schema validation failed", + ValidationFailures: []plan.ValidationFailure{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Status: "invalid", + Errors: []plan.FieldValidationError{{Type: "schema", Field: "data.k", Message: "must be a string", Value: 7.0}}, + }}, + }}, + Notes: notes(), + }, + }, + "RunErrors": { + reason: "Errors no group owns (overlaps, interruption) become resource-less errors and mark the result errored.", + in: &dt.XRReport{ + Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(xrEqual)}}, + Errors: []dt.OutputError{{Message: "inputs overlap"}, {Message: "interrupted"}}, + }, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Errored: true, + Summary: plan.Summary{NoOp: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeUnchanged}}, + Plans: []*plan.ResourcePlan{ + {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Action: plan.ActionNoOp, Composite: &xrPlan}, + }, + Errors: []plan.Error{{Message: "inputs overlap"}, {Message: "interrupted"}}, + Notes: notes(), + }, + }, + "Claim": { + reason: "A claim input keeps the claim's identity in its composite ref.", + in: &dt.XRReport{Groups: []dt.XRDiffGroup{{ + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "Network", Name: "mine", Namespace: "team"}, + Diffs: diffs(rdiff(cmGVK, "cm", dt.DiffTypeAdded, nil, map[string]any{})), + }}}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Create: 1}, + Composites: []plan.CompositeResult{{ + Ref: plan.ResourceRef{APIVersion: "example.org/v1", Kind: "Network", Name: "mine", Namespace: "team"}, + Status: plan.CompositeChanged, + }}, + Plans: []*plan.ResourcePlan{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Action: plan.ActionCreate, + Composite: new(plan.ResourceRef{APIVersion: "example.org/v1", Kind: "Network", Name: "mine", Namespace: "team"}), + }}, + Notes: notes(), + }, + }, + "SecretRedaction": { + reason: "A Secret's data and stringData values are sensitive; its other fields are not.", + in: &dt.XRReport{Groups: []dt.XRDiffGroup{{ + XR: xrRef, + Diffs: diffs(rdiff(secGVK, "creds", dt.DiffTypeModified, + map[string]any{"data": map[string]any{"pw": "b2xk"}, "type": "Opaque"}, + map[string]any{"data": map[string]any{"pw": "bmV3"}, "stringData": map[string]any{"u": "me"}, "type": "kv"})), + }}}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Update: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged}}, + Plans: []*plan.ResourcePlan{{ + APIVersion: "v1", Kind: "Secret", Name: "creds", Action: plan.ActionUpdate, Composite: &xrPlan, + Changes: []plan.FieldChange{ + {Path: "data.pw", Actual: &plan.Value{Sensitive: true}, Planned: &plan.Value{Sensitive: true}}, + {Path: "stringData.u", Planned: &plan.Value{Sensitive: true}}, + {Path: "type", Actual: &plan.Value{Value: "Opaque"}, Planned: &plan.Value{Value: "kv"}}, + }, + }}, + Notes: notes(), + }, + }, + "Dedupe": { + reason: "A change two inputs reach identically is listed once, under the first; a differing render of it is still listed.", + in: &dt.XRReport{Groups: []dt.XRDiffGroup{ + {XR: xrRef, Diffs: diffs(rdiff(cmGVK, "shared", dt.DiffTypeAdded, nil, map[string]any{"data": map[string]any{"k": "v"}}))}, + { + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "dev"}, + Diffs: diffs(rdiff(cmGVK, "shared", dt.DiffTypeAdded, nil, map[string]any{"data": map[string]any{"k": "v"}})), + }, + { + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "qa"}, + Diffs: diffs(rdiff(cmGVK, "shared", dt.DiffTypeAdded, nil, map[string]any{"data": map[string]any{"k": "other"}})), + }, + }}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Create: 2}, + Composites: []plan.CompositeResult{ + {Ref: xrPlan, Status: plan.CompositeChanged}, + {Ref: plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "dev"}, Status: plan.CompositeChanged}, + {Ref: plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "qa"}, Status: plan.CompositeChanged}, + }, + Plans: []*plan.ResourcePlan{ + { + APIVersion: "v1", Kind: "ConfigMap", Name: "shared", Action: plan.ActionCreate, Composite: &xrPlan, + Changes: []plan.FieldChange{{Path: "data.k", Planned: &plan.Value{Value: "v"}}}, + }, + { + APIVersion: "v1", Kind: "ConfigMap", Name: "shared", Action: plan.ActionCreate, + Composite: new(plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "qa"}), + Changes: []plan.FieldChange{{Path: "data.k", Planned: &plan.Value{Value: "other"}}}, + }, + }, + Notes: notes(), + }, + }, + "DryRunAndWarnings": { + reason: "An unverified addition carries its DryRun, and the run's warnings precede the derived dry-run summary.", + in: dryRunReport, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Create: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged}}, + Plans: []*plan.ResourcePlan{{ + APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Action: plan.ActionCreate, Composite: &xrPlan, + DryRun: &plan.Verification{SkipReason: "forbidden", Detail: "no create"}, + }}, + Warnings: append([]plan.Warning{{Message: "heads up", Context: map[string]string{"k": "v"}}}, dryRunWarnings...), + Notes: notes(), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := FromXRReport(tc.in) + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nFromXRReport(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestFromCompReport(t *testing.T) { + composition := func(c dt.CompositionDiff) *dt.CompDiffOutput { + if c.Name == "" { + c.Name = "net" + } + return &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{c}} + } + compNoOp := &plan.ResourcePlan{APIVersion: compPlan.APIVersion, Kind: compPlan.Kind, Name: compPlan.Name, Action: plan.ActionNoOp} + filtered := func(name string, reason dt.FilterReason, detail string) dt.XRImpact { + return dt.XRImpact{ + ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: name}, + Status: dt.XRStatusFiltered, FilterReason: reason, FilterDetail: detail, + } + } + named := func(name string) plan.ResourceRef { + return plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: name} + } + xrErr := dt.OutputError{ResourceID: "XNetwork/prod", Message: "render failed"} + + cases := map[string]struct { + reason string + in *dt.CompDiffOutput + want plan.Result + }{ + "CompositionAndRevisionImpact": { + reason: "A changed Composition is an ungrouped plan with a Compositions entry carrying its revision impact; affected XRs group their plans.", + in: composition(dt.CompositionDiff{ + CompositionDiff: rdiff(schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, "net", dt.DiffTypeModified, + map[string]any{"spec": map[string]any{"mode": "Pipeline"}}, + map[string]any{"spec": map[string]any{"mode": "Resources"}}), + RevisionImpact: dt.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1, PredictedRevisionName: "net-abc1234"}, + ImpactAnalysis: []dt.XRImpact{{ + ObjectReference: xrRef, Status: dt.XRStatusChanged, + Diffs: diffs(rdiff(cmGVK, "cm", dt.DiffTypeRemoved, map[string]any{}, nil)), + }}, + }), + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Update: 1, Destroy: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged}}, + Compositions: []plan.CompositionResult{{ + Ref: compPlan, + RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1, PredictedRevision: "net-abc1234"}, + }}, + Plans: []*plan.ResourcePlan{ + { + APIVersion: compPlan.APIVersion, Kind: "Composition", Name: "net", Action: plan.ActionUpdate, + Changes: []plan.FieldChange{{Path: "spec.mode", Actual: &plan.Value{Value: "Pipeline"}, Planned: &plan.Value{Value: "Resources"}}}, + }, + {APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Action: plan.ActionDelete, Composite: &xrPlan}, + }, + Notes: notes(), + }, + }, + "FilterReasons": { + reason: "A Manual-policy XR is deferred and counted; selector and deletion filters are skipped. Each keeps its reason and detail.", + in: composition(dt.CompositionDiff{ + Scoped: false, + ImpactAnalysis: []dt.XRImpact{ + filtered("pinned", dt.FilterReasonManualPolicy, "compositionRevisionRef: net-6"), + filtered("selector", dt.FilterReasonRevisionSelectorMismatch, "channel=stable"), + filtered("deleting", dt.FilterReasonDeleting, ""), + }, + }), + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{NoOp: 1, Deferred: 1}, + Composites: []plan.CompositeResult{ + {Ref: named("pinned"), Status: plan.CompositeDeferred, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6"}, + {Ref: named("selector"), Status: plan.CompositeSkipped, Reason: "revision_selector_mismatch", Detail: "channel=stable"}, + {Ref: named("deleting"), Status: plan.CompositeSkipped, Reason: "deleting"}, + }, + Compositions: []plan.CompositionResult{{Ref: compPlan}}, + Plans: []*plan.ResourcePlan{compNoOp}, + Notes: notes(), + }, + }, + "Pinned": { + reason: "A pinned XR simulated with --include-manual keeps its status and is marked pinned.", + in: composition(dt.CompositionDiff{ + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusUnchanged, Pinned: true}}, + }), + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{NoOp: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeUnchanged, Pinned: true}}, + Compositions: []plan.CompositionResult{{Ref: compPlan}}, + Plans: []*plan.ResourcePlan{compNoOp}, + Notes: notes(), + }, + }, + "AnalysisSkippedMaskedOnly": { + reason: "A masked-only Composition is a no-op plan; a skipped analysis is flagged on its Compositions entry.", + in: composition(dt.CompositionDiff{ + MaskedChangesOnly: true, + ImpactAnalysisSkipped: true, + RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 3}, + }), + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{NoOp: 1}, + Compositions: []plan.CompositionResult{{ + Ref: compPlan, + RevisionImpact: &plan.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 3}, + ImpactAnalysisSkipped: true, + }}, + Plans: []*plan.ResourcePlan{compNoOp}, + Notes: notes(), + }, + }, + "CompositionError": { + reason: "A failed Composition is an error plan and a typed error, with no revision impact.", + in: composition(dt.CompositionDiff{ + Error: &dt.OutputError{ResourceID: "Composition/net", Message: "cannot load"}, + }), + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Errored: true, + Summary: plan.Summary{Errored: 1}, + Compositions: []plan.CompositionResult{{Ref: compPlan}}, + Plans: []*plan.ResourcePlan{ + {APIVersion: compPlan.APIVersion, Kind: "Composition", Name: "net", Error: "cannot load"}, + }, + Errors: []plan.Error{{Resource: &compPlan, Message: "cannot load"}}, + Notes: notes(), + }, + }, + "XRErrorAndRunErrors": { + reason: "An errored XR's error is anchored to it and not repeated; the interruption is a run error.", + in: &dt.CompDiffOutput{ + Compositions: []dt.CompositionDiff{{ + Name: "net", + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusError, Error: &xrErr}}, + }}, + Errors: []dt.OutputError{xrErr, {Message: "interrupted"}}, + }, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Errored: true, + Summary: plan.Summary{NoOp: 1, Errored: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeError}}, + Compositions: []plan.CompositionResult{{Ref: compPlan}}, + Plans: []*plan.ResourcePlan{ + compNoOp, + {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Error: "render failed", Composite: &xrPlan}, + }, + Errors: []plan.Error{{Resource: &xrPlan, Message: "render failed"}, {Message: "interrupted"}}, + Notes: notes(), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := FromCompReport(tc.in) + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nFromCompReport(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} From 3811f1b13e04e790eaf129c3f290a3b977a63771 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 22:14:12 -0400 Subject: [PATCH 047/126] feat(plan): print revision impact and notes in plan text 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) Signed-off-by: Jonathan Ogilvie --- internal/plan/output.go | 61 +++++++++++++++++++++++++++++------- internal/plan/output_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 12 deletions(-) diff --git a/internal/plan/output.go b/internal/plan/output.go index 45c728be..ccd50dd8 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -247,29 +247,62 @@ func (yamlRenderer) Render(w io.Writer, r Result) error { } // planRenderer renders the human-readable plan format: a header and change -// tree per resource, then a summary with warnings. When any plan or -// composite names a composite, plans print grouped under an -// "XR: Kind.group name" header. Run-level Errors and Warnings are not -// printed; the command reports them on stderr. +// tree per resource, then a summary with warnings, then any Notes. When any +// plan or composite names a composite, plans print grouped under an +// "XR: Kind.group name" header. A Composition's revision impact prints as +// one line under its plan. Run-level Errors and Warnings are not printed; +// the command reports them on stderr. type planRenderer struct{} // Render writes the result to w in the human-readable plan format. func (planRenderer) Render(w io.Writer, r Result) error { + impacts := revisionImpactLines(r.Compositions) if len(r.Composites) == 0 && !slices.ContainsFunc(r.Plans, func(p *ResourcePlan) bool { return p.Composite != nil }) { for _, p := range r.Plans { - renderPlan(w, p) + renderPlan(w, p, impacts[p.ResourceRef()]) } } else { - renderGrouped(w, r) + renderGrouped(w, r, impacts) } renderSummary(w, r.Plans, r.Summary) + if len(r.Notes) > 0 { + _, _ = fmt.Fprintln(w) + for _, n := range r.Notes { + _, _ = fmt.Fprintf(w, "Note: %s\n", n) + } + } return nil } +// revisionImpactLines returns, per Composition, the line saying whether +// applying it creates a CompositionRevision and how many composites would +// adopt it, and whether their impact went unanalyzed. A Composition with +// neither to say has no line. +func revisionImpactLines(comps []CompositionResult) map[ResourceRef]string { + lines := map[ResourceRef]string{} + for _, c := range comps { + var parts []string + switch ri := c.RevisionImpact; { + case ri == nil: + case ri.CreatesRevision: + parts = append(parts, fmt.Sprintf("Applying this creates a new CompositionRevision, which %s would adopt.", pluralize(ri.RepointedComposites, "composite", "composites"))) + default: + parts = append(parts, "Applying this creates no new CompositionRevision.") + } + if c.ImpactAnalysisSkipped { + parts = append(parts, "Impact analysis skipped.") + } + if len(parts) > 0 { + lines[c.Ref] = strings.Join(parts, " ") + } + } + return lines +} + // renderGrouped prints ungrouped plans first, then each composite (in // Composites order, then any only plans name) with its plans indented // beneath. A composite's own plan is omitted when it is a no-op. -func renderGrouped(w io.Writer, r Result) { +func renderGrouped(w io.Writer, r Result, impacts map[ResourceRef]string) { var refs []ResourceRef byRef := map[ResourceRef][]*ResourcePlan{} status := map[ResourceRef]CompositeResult{} @@ -281,7 +314,7 @@ func renderGrouped(w io.Writer, r Result) { } for _, p := range r.Plans { if p.Composite == nil { - renderPlan(w, p) + renderPlan(w, p, impacts[p.ResourceRef()]) continue } ref := *p.Composite @@ -321,7 +354,7 @@ func renderComposite(w io.Writer, c CompositeResult, ref ResourceRef, plans []*R if p.Action == ActionNoOp && p.Error == "" && p.ResourceRef() == ref { continue } - renderPlan(&block, p) + renderPlan(&block, p, "") } for line := range strings.Lines(block.String()) { if line != "\n" { @@ -356,10 +389,14 @@ func compositeHeader(c CompositeResult, ref ResourceRef) string { return h } -// renderPlan prints one resource's header and change tree. A delete prints -// only its header, Terraform-style, though it carries actual-only changes. -func renderPlan(w io.Writer, p *ResourcePlan) { +// renderPlan prints one resource's header, note when not empty, and change +// tree. A delete prints only its header and note, Terraform-style, though it +// carries actual-only changes. +func renderPlan(w io.Writer, p *ResourcePlan, note string) { _, _ = fmt.Fprintln(w, header(p)) + if note != "" { + _, _ = fmt.Fprintf(w, " %s\n", note) + } if p.Error != "" { _, _ = fmt.Fprintf(w, " cannot plan: %s\n", p.Error) _, _ = fmt.Fprintln(w) diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 5b4a358d..01320853 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -276,6 +276,65 @@ Plan: 0 to add, 1 to change, 1 to destroy. } } +func TestRenderPlanCompositions(t *testing.T) { + comp := ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"} + same := ResourceRef{APIVersion: comp.APIVersion, Kind: comp.Kind, Name: "same"} + unknown := ResourceRef{APIVersion: comp.APIVersion, Kind: comp.Kind, Name: "unknown"} + xr := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} + r := Result{ + Summary: Summary{Update: 2, NoOp: 2}, + Composites: []CompositeResult{{Ref: xr, Status: CompositeChanged}}, + Compositions: []CompositionResult{ + {Ref: comp, RevisionImpact: &RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}}, + {Ref: same, RevisionImpact: &RevisionImpact{ChangeScope: "none"}, ImpactAnalysisSkipped: true}, + // No determined impact prints no line. + {Ref: unknown}, + }, + Plans: []*ResourcePlan{ + { + APIVersion: comp.APIVersion, Kind: comp.Kind, Name: comp.Name, Action: ActionUpdate, + Changes: []FieldChange{{Path: "spec.mode", Actual: &Value{Value: "Resources"}, Planned: &Value{Value: "Pipeline"}}}, + }, + {APIVersion: same.APIVersion, Kind: same.Kind, Name: same.Name, Action: ActionNoOp}, + {APIVersion: unknown.APIVersion, Kind: unknown.Kind, Name: unknown.Name, Action: ActionNoOp}, + { + APIVersion: "ec2.aws.upbound.io/v1beta1", Kind: "VPC", Name: "prod-network", + Action: ActionUpdate, Composite: &xr, + Changes: []FieldChange{{Path: "spec.forProvider.region", Actual: &Value{Value: "us-west-1"}, Planned: &Value{Value: "us-east-1"}}}, + }, + }, + Notes: []string{"Cloud-layer plan not run."}, + } + want := `[~] Composition.apiextensions.crossplane.io net (update) + Applying this creates a new CompositionRevision, which 1 composite would adopt. + spec: + ~ mode: "Resources" -> "Pipeline" + +[=] Composition.apiextensions.crossplane.io same (no-op) + Applying this creates no new CompositionRevision. Impact analysis skipped. + +[=] Composition.apiextensions.crossplane.io unknown (no-op) + +XR: XNetwork.example.org prod-network + [~] VPC.ec2.aws.upbound.io prod-network (update) + spec: + forProvider: + ~ region: "us-west-1" -> "us-east-1" + +Plan: 0 to add, 2 to change, 0 to destroy. + +Note: Cloud-layer plan not run. +` + var buf bytes.Buffer + pr, _ := NewRenderer(FormatPlan) + if err := pr.Render(&buf, r); err != nil { + t.Fatalf("Render(plan): unexpected error: %v", err) + } + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("Render(plan): -want, +got:\n%s", diff) + } +} + func TestRenderJSONProjectFields(t *testing.T) { xr := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} in := Result{ From 37ba607a44e63e78d3dea807c5599a3df2d52e58 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 22:25:02 -0400 Subject: [PATCH 048/126] test(simulate): drop the unused ref helper Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/report_test.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index 9d7d84bb..679c2f12 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -67,9 +67,6 @@ func diffs(ds ...*dt.ResourceDiff) map[string]*dt.ResourceDiff { return m } -//go:fix inline -func ref(p plan.ResourceRef) *plan.ResourceRef { return new(p) } - func notes() []string { return []string{noteProviderPlanNotRun} } func TestFromXRReport(t *testing.T) { From 159cc8a2a359edc15a3cbbab0204d9bd206ce731 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 22:33:04 -0400 Subject: [PATCH 049/126] feat(project)!: default project simulate to -o plan 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) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 44 ++- cmd/crossplane/project/simulate.go | 101 ++++--- cmd/crossplane/project/simulate_test.go | 163 ++++++++++- internal/diff/integration/plan_output_test.go | 260 ++++++++++++++++++ .../diff/integration/simulate_harness_test.go | 20 +- 5 files changed, 531 insertions(+), 57 deletions(-) create mode 100644 internal/diff/integration/plan_output_test.go diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index b3a9ae2f..e76d70aa 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -42,6 +42,36 @@ composite resources a Composition change reaches, so composite resource files don't take them. `--max-concurrency` and `--cache-dir` configure the project's function builds, so only a project file takes them. +The `-o` flag picks the output format: + +- `plan`, the default, prints each change as a field-level plan, grouped under + the composite resource it belongs to. A changed Composition prints first, + with whether applying it creates a new Composition revision. +- `json` and `yaml` print the same plan as a structured result, for CI and + other tools. +- `diff` prints the line diff `crossplane-diff` prints. + +For example, a changed Composition prints a plan like this: + +``` +[~] Composition.apiextensions.crossplane.io xnetworks.example.org (update) + Applying this creates a new CompositionRevision, which 1 composite would adopt. + spec: + pipeline[0]: + input: + ~ region: "us-west-1" -> "us-east-1" + +XR: XNetwork.example.org team-a/prod-network + [~] VPC.ec2.aws.upbound.io prod-network (update) + spec: + forProvider: + ~ region: "us-west-1" -> "us-east-1" + +Plan: 0 to add, 2 to change, 0 to destroy. + +Note: Cloud-layer plan not run (provider diff servers not yet wired). +``` + The `--timeout` flag bounds the whole simulation, not each composite resource. It doesn't include the time spent building functions. @@ -56,12 +86,10 @@ The command accepts the same diff-tuning flags as `crossplane-diff`: - `--analyze-on` sets the smallest Composition change that triggers impact analysis: `spec-change`, `any-change`, or `always`. - `--minimize-composition` collapses each changed Composition to one line in - the diff output. + `-o diff` output. - `--eventual-state` shows the state after all reconciliation cycles complete. - `--max-recv-message-size` raises the function gRPC message limit, in MB. -- `--no-color` and `--compact` change how the diff prints. -- `--output-version` picks the schema version of JSON and YAML output: `v1`, - the default, or `v2`. +- `--no-color` and `--compact` change how `-o diff` prints. The command exits with one of these statuses, so a CI pipeline can gate on the result: @@ -95,12 +123,18 @@ Simulate a single namespaced composite resource: crossplane project simulate -n team-a --name=my-database ``` -Print the result as JSON: +Print the plan as JSON: ```shell crossplane project simulate -o json ``` +Print the line diff `crossplane-diff` prints: + +```shell +crossplane project simulate -o diff +``` + Simulate a changed Composition without a project: ```shell diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index f645223f..81d41885 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -19,6 +19,7 @@ package project import ( "context" "encoding/json" + "fmt" "io" "os" "path/filepath" @@ -48,10 +49,13 @@ import ( xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/docker" "github.com/crossplane/cli/v2/internal/kube" + "github.com/crossplane/cli/v2/internal/plan" "github.com/crossplane/cli/v2/internal/project" "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" @@ -75,20 +79,23 @@ type simulateCmd struct { IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` - Output string `default:"diff" enum:"diff,json,yaml" help:"Output format. One of: diff, json, yaml." short:"o"` - OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2). Has no effect on -o diff."` + Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` + // ponytail: OutputVersion keeps crossplane-diff's v1/v2 structured schema + // reachable while the integration tests still assert on it. It goes once + // they assert on plan.Result. + OutputVersion string `default:"" enum:",v1,v2" help:"Emit crossplane-diff's structured schema (v1 or v2) for -o json|yaml instead of the plan result." hidden:""` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` - MaxNestedDepth int `default:"10" help:"Maximum depth for nested XR recursion."` + MaxNestedDepth int `default:"10" help:"Maximum depth for nested XR recursion."` IgnorePaths []string `help:"Paths to ignore in diffs (e.g., 'metadata.annotations[argocd.argoproj.io/tracking-id]')."` - FunctionCredentials FunctionCredentials `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." placeholder:"PATH"` + FunctionCredentials FunctionCredentials `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." placeholder:"PATH"` FunctionRegistryOverride string `help:"Override the registry for all function images (e.g., 'my-company.registry.io')."` EventualState bool `help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)."` - MaxRecvMessageSize int `env:"MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4MB if undefined)."` - MinimizeComposition bool `help:"Collapse each changed composition to a single marker line (human-readable output only; JSON/YAML keeps full detail; errors and no-change compositions still print in full)."` - AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` - DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` + MaxRecvMessageSize int `env:"MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4MB if undefined)."` + MinimizeComposition bool `help:"Collapse each changed composition to a single marker line in -o diff output (errors and no-change compositions still print in full)."` + AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` // CrossplaneVersion and CrossplaneImage select the crossplane render // backend, as they do for crossplane-diff. When neither is set the render @@ -186,6 +193,9 @@ func (c *simulateCmd) Validate() error { if c.MaxIterations < 1 { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } + if c.OutputVersion != "" && c.Output != plan.FormatJSON && c.Output != plan.FormatYAML { + return errors.Errorf("--output-version selects a structured schema, so it applies only with -o json or -o yaml, not -o %s", c.Output) + } // The two flags are mutually exclusive (kong xor), so at most one of // these has anything to check. @@ -247,10 +257,9 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin if in.mode == xrMode { // Compositions and functions come from the cluster, as in - // crossplane-diff's xr command. A claim is simulated as its XR; the - // design's printing of the result under the claim's name - // (crossplane/cli#367) belongs in the renderer, keyed by the claim - // labels the engine copies onto the XR. + // crossplane-diff's xr command. A claim is simulated as its XR, but + // the report names the input, so its result is listed under the + // claim's name (crossplane/cli#367). return c.simulate(ctx, logger, status, clients.Initialize, xrProc, func(ctx context.Context) (bool, error) { if err := checkComposites(ctx, clients.XpClients.Definition, in.resources); err != nil { return false, err @@ -261,8 +270,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin // are shown and structured output stays valid. The renderer // includes the errors in structured output. if report != nil { - diffRenderer := newDiffRenderer(logger, c.diffOptions(k)) - if renderErr := diffRenderer.RenderDiffs(report.Groups, report.Errors, report.Warnings); renderErr != nil { + if renderErr := c.renderXRReport(k, logger, report); renderErr != nil { logger.Debug("Failed to render diffs", "error", renderErr) err = withRenderError(err, errors.Wrap(renderErr, "failed to render diffs")) } @@ -330,10 +338,7 @@ func (c *simulateCmd) diffCompositions(k *kong.Context, logger logging.Logger, p // composition failed), so structured output stays valid. A render // failure takes precedence over the processing error. if output != nil { - opts := c.diffOptions(k) - opts.MinimizeComposition = c.MinimizeComposition - - if renderErr := newCompDiffRenderer(logger, opts).RenderCompDiff(output); renderErr != nil { + if renderErr := c.renderCompReport(k, logger, output); renderErr != nil { err = errors.Wrap(renderErr, "failed to render composition diff") } } @@ -652,28 +657,52 @@ func (c *simulateCmd) diffOptions(k *kong.Context) renderer.DiffOptions { return opts } -// newDiffRenderer returns the renderer for an xr report in the format opts select. -func newDiffRenderer(log logging.Logger, opts renderer.DiffOptions) renderer.DiffRenderer { - switch opts.Format { - case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - return renderer.NewStructuredDiffRenderer(log, opts) - case renderer.OutputFormatDiff: - return renderer.NewDiffRenderer(log, opts) - default: - return renderer.NewDiffRenderer(log, opts) +// renderXRReport writes an xr or project run's report in the format -o +// selects: the engine's human diff for diff, its v1/v2 structured schema for +// json or yaml when --output-version is set, and plan.Result otherwise. +func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport) error { + opts := c.diffOptions(k) + switch { + case c.Output == string(renderer.OutputFormatDiff): + return renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) + case c.OutputVersion != "": + return renderer.NewStructuredDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) } + return c.renderPlan(k, simulate.FromXRReport(r), dt.XRDryRunWarnings(r.Groups)) } -// newCompDiffRenderer returns the renderer for a comp report in the format opts select. -func newCompDiffRenderer(log logging.Logger, opts renderer.DiffOptions) renderer.CompDiffRenderer { - switch opts.Format { - case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - return renderer.NewStructuredCompDiffRenderer(log, opts) - case renderer.OutputFormatDiff: - fallthrough - default: - return renderer.NewDefaultCompDiffRenderer(log, renderer.NewDiffRenderer(log, opts), opts) +// renderCompReport writes a composition run's report in the format -o +// selects, as renderXRReport does. +func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput) error { + opts := c.diffOptions(k) + opts.MinimizeComposition = c.MinimizeComposition + switch { + case c.Output == string(renderer.OutputFormatDiff): + return renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts).RenderCompDiff(o) + case c.OutputVersion != "": + return renderer.NewStructuredCompDiffRenderer(logger, opts).RenderCompDiff(o) + } + return c.renderPlan(k, simulate.FromCompReport(o), dt.CompDryRunWarnings(o)) +} + +// renderPlan writes res to stdout in the plan, json or yaml format, then +// writes derived, the warnings derived from the report, to stderr, as the +// engine's renderers do. The run's other warnings went to stderr when they +// were raised. +func (c *simulateCmd) renderPlan(k *kong.Context, res plan.Result, derived []dt.OutputWarning) error { + r, err := plan.NewRenderer(c.Output) + if err != nil { + return err + } + if err := r.Render(k.Stdout, res); err != nil { + return err + } + for _, w := range derived { + if _, err := fmt.Fprintln(k.Stderr, w.FormatWarning()); err != nil { + return errors.Wrap(err, "cannot write warning to stderr") + } } + return nil } // withRenderError adds a render failure to a run's error. A joined run error is flattened first, so diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 460b211d..8568af50 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -19,6 +19,7 @@ package project import ( "bytes" "context" + "encoding/json" "errors" "os" "path/filepath" @@ -29,11 +30,13 @@ import ( "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/sergi/go-diff/diffmatchpatch" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/yaml" xperrors "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -45,6 +48,7 @@ import ( "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/plan" ) // parseSimulate parses args as `simulate` flags, running kong's validation. @@ -90,8 +94,7 @@ func TestSimulateFlags(t *testing.T) { MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, - Output: "diff", - OutputVersion: "v1", + Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", }}, @@ -132,8 +135,7 @@ func TestSimulateFlags(t *testing.T) { MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, - Output: "diff", - OutputVersion: "v1", + Output: "plan", NoColor: true, Compact: true, MaxNestedDepth: 3, @@ -154,8 +156,7 @@ func TestSimulateFlags(t *testing.T) { MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, - Output: "diff", - OutputVersion: "v1", + Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", FunctionCredentials: FunctionCredentials{Path: creds, Secrets: []corev1.Secret{{ @@ -192,8 +193,7 @@ func TestSimulateFlags(t *testing.T) { MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, - Output: "diff", - OutputVersion: "v1", + Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", }}, @@ -204,8 +204,37 @@ func TestSimulateFlags(t *testing.T) { want: want{err: true}, }, "UnknownOutput": { - reason: "Output formats other than diff, json and yaml should be rejected.", - args: []string{"-o", "plan"}, + reason: "Output formats other than plan, diff, json and yaml should be rejected.", + args: []string{"-o", "xml"}, + want: want{err: true}, + }, + "OutputVersionWithStructuredOutput": { + reason: "--output-version should be accepted with -o yaml.", + args: []string{"-o", "yaml", "--output-version", "v1"}, + want: want{cmd: &simulateCmd{ + Input: []string{"crossplane-project.yaml"}, + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: time.Minute, + Output: "yaml", + OutputVersion: "v1", + MaxNestedDepth: 10, + DryRunOn: "all", + }}, + }, + "OutputVersionWithDefaultOutput": { + reason: "--output-version should be rejected with the default -o plan, which has no structured schema.", + args: []string{"--output-version", "v1"}, + want: want{err: true}, + }, + "OutputVersionWithPlan": { + reason: "--output-version should be rejected with -o plan.", + args: []string{"-o", "plan", "--output-version", "v2"}, + want: want{err: true}, + }, + "OutputVersionWithDiff": { + reason: "--output-version should be rejected with -o diff.", + args: []string{"-o", "diff", "--output-version", "v1"}, want: want{err: true}, }, "ZeroIterations": { @@ -231,8 +260,7 @@ func TestSimulateFlags(t *testing.T) { MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, Timeout: time.Minute, - Output: "diff", - OutputVersion: "v1", + Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", CrossplaneImage: "example.com/crossplane:stable", @@ -466,6 +494,113 @@ func TestSimulateDiffOptions(t *testing.T) { } } +// TestSimulateRenderReports pins which renderer each -o value selects, for +// both report types: plan.Result for plan, json and yaml, the engine's human +// diff for diff, and its v1/v2 schema only under --output-version. +func TestSimulateRenderReports(t *testing.T) { + xr := corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod"} + cm := &un.Unstructured{Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "cm"}, "data": map[string]any{"k": "v"}}} + added := &dt.ResourceDiff{ + Gvk: cm.GroupVersionKind(), ResourceName: "cm", DiffType: dt.DiffTypeAdded, + Desired: dt.ResourceViews{Raw: cm, Clean: cm}, + LineDiffs: []diffmatchpatch.Diff{{Type: diffmatchpatch.DiffInsert, Text: "data:\n k: v\n"}}, + } + xrReport := &dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xr, Diffs: map[string]*dt.ResourceDiff{added.GetDiffKey(): added}}}} + compReport := &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{{ + Name: "net", + RevisionImpact: dt.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}, + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xr, Status: dt.XRStatusUnchanged}}, + }}} + + // isPlanResult checks out decodes as a plan.Result naming the composite. + isPlanResult := func(unmarshal func([]byte, any) error) func(*testing.T, string) { + return func(t *testing.T, out string) { + t.Helper() + var r plan.Result + if err := unmarshal([]byte(out), &r); err != nil { + t.Fatalf("output is not a plan.Result: %v\n%s", err, out) + } + if r.FormatVersion != plan.ResultFormatVersion || len(r.Composites) != 1 || r.Composites[0].Ref.Name != "prod" { + t.Errorf("want a plan.Result for composite prod, got:\n%s", out) + } + } + } + contains := func(subs ...string) func(*testing.T, string) { + return func(t *testing.T, out string) { + t.Helper() + for _, sub := range subs { + if !strings.Contains(out, sub) { + t.Errorf("want output containing %q, got:\n%s", sub, out) + } + } + } + } + + cases := map[string]struct { + reason string + cmd simulateCmd + wantXR func(*testing.T, string) + wantComp func(*testing.T, string) + }{ + "Plan": { + reason: "-o plan, the default, should print the plan text, with the revision impact under the Composition.", + cmd: simulateCmd{Output: "plan"}, + wantXR: contains("XR: XNetwork.example.org prod\n [+] ConfigMap. cm (create)\n data:\n + k: \"v\"", "Plan: 1 to add", "Note: "), + wantComp: contains("[=] Composition.apiextensions.crossplane.io net (no-op)\n Applying this creates a new CompositionRevision, which 1 composite would adopt.", "XR: XNetwork.example.org prod", "Note: "), + }, + "JSON": { + reason: "-o json should print plan.Result as JSON.", + cmd: simulateCmd{Output: "json"}, + wantXR: isPlanResult(json.Unmarshal), + wantComp: isPlanResult(json.Unmarshal), + }, + "YAML": { + reason: "-o yaml should print plan.Result as YAML.", + cmd: simulateCmd{Output: "yaml"}, + wantXR: isPlanResult(func(b []byte, v any) error { return yaml.Unmarshal(b, v) }), + wantComp: isPlanResult(func(b []byte, v any) error { return yaml.Unmarshal(b, v) }), + }, + "Diff": { + reason: "-o diff should print the engine's human diff.", + cmd: simulateCmd{Output: "diff"}, + wantXR: contains("+++ ConfigMap/cm", "Summary: 1 added"), + wantComp: contains("=== Composition Changes ===", "=== Affected Composite Resources ==="), + }, + "JSONWithOutputVersion": { + reason: "-o json with --output-version should print the engine's structured schema at that version.", + cmd: simulateCmd{Output: "json", OutputVersion: "v2"}, + wantXR: contains(`"outputVersion": "v2"`), + wantComp: contains(`"outputVersion": "v2"`), + }, + "YAMLWithOutputVersion": { + reason: "-o yaml with --output-version should print the engine's structured schema at that version.", + cmd: simulateCmd{Output: "yaml", OutputVersion: "v1"}, + wantXR: contains("outputVersion: v1"), + wantComp: contains("outputVersion: v1"), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + tc.cmd.NoColor = true + for kind, run := range map[string]struct { + render func(*kong.Context) error + want func(*testing.T, string) + }{ + "xr": {func(k *kong.Context) error { return tc.cmd.renderXRReport(k, logging.NewNopLogger(), xrReport) }, tc.wantXR}, + "comp": {func(k *kong.Context) error { return tc.cmd.renderCompReport(k, logging.NewNopLogger(), compReport) }, tc.wantComp}, + } { + stdout := &bytes.Buffer{} + k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: &bytes.Buffer{}}} + if err := run.render(k); err != nil { + t.Fatalf("\n%s\nrender %s report: %v", tc.reason, kind, err) + } + t.Run(kind, func(t *testing.T) { run.want(t, stdout.String()) }) + } + }) + } +} + // TestWithRenderError pins that a render failure reads exactly as it did when the processor joined it // with the run's own errors: one flat list, not a list nested in a list. func TestWithRenderError(t *testing.T) { @@ -640,6 +775,10 @@ func TestSimulateRun(t *testing.T) { cancel() } + // kong sets -o's default when it parses the flags. + if tc.args.cmd.Output == "" { + tc.args.cmd.Output = "plan" + } k := &kong.Context{Kong: &kong.Kong{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}} diff := tc.args.cmd.diffCompositions(k, logging.NewNopLogger(), tc.args.proc, []*un.Unstructured{{}}, compositionMode) status := &common.ExitStatus{} diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go new file mode 100644 index 00000000..b1297088 --- /dev/null +++ b/internal/diff/integration/plan_output_test.go @@ -0,0 +1,260 @@ +//go:build integration + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/google/go-cmp/cmp" + + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/plan" +) + +// TestSimulatePlanOutput runs project simulate's own output formats, -o plan +// and -o json's plan.Result, which crossplane-diff's cases cannot express. +// Each case passes simulate args directly rather than crossplane-diff's. +func TestSimulatePlanOutput(t *testing.T) { + t.Parallel() + + tu.SetupKubeTestLogger(t) + + xrCase := IntegrationTestCase{ + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/diff/resources/existing-xr.yaml", "testdata/diff/resources/existing-downstream-resource.yaml"), + }, + } + compCase := IntegrationTestCase{ + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/comp/resources/existing-xr-1.yaml", "testdata/comp/resources/existing-downstream-1.yaml"), + composes("testdata/comp/resources/existing-xr-2.yaml", "testdata/comp/resources/existing-downstream-2.yaml"), + }, + } + xrArgs := []string{"-f", "testdata/diff/modified-xr.yaml"} + compArgs := []string{"-f", "testdata/comp/updated-composition.yaml", "--namespace=default"} + + xnop := plan.ResourceRef{APIVersion: "ns.diff.example.org/v1alpha1", Kind: "XNopResource", Namespace: "default", Name: "test-resource"} + another := xnop + another.Name = "another-resource" + comp := plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "xnopresources.diff.example.org"} + + cases := map[string]struct { + reason string + testType DiffTestType + tt IntegrationTestCase + args []string + // wantText is the -o plan output; empty for a json case. + wantText string + // wantResult is the -o json output, reduced by summarize. + wantResult *planShape + }{ + "XRPlanText": { + reason: "-o plan, the default, prints an XR's updates grouped under it, then the summary and notes.", + testType: XRDiffTest, + tt: xrCase, + args: xrArgs, + wantText: `XR: XNopResource.ns.diff.example.org default/test-resource + [~] XDownstreamResource.ns.nop.example.org default/test-resource (update) + spec: + forProvider: + ~ configData: "existing-value" -> "modified-value" + + [~] XNopResource.ns.diff.example.org default/test-resource (update) + spec: + ~ coolField: "existing-value" -> "modified-value" + +Plan: 0 to add, 2 to change, 0 to destroy. + +Note: Cloud-layer plan not run (provider diff servers not yet wired). +`, + }, + "XRPlanJSON": { + reason: "-o json emits plan.Result: the XR as a changed composite, with both its plans grouped under it.", + testType: XRDiffTest, + tt: xrCase, + args: append([]string{"-o", "json"}, xrArgs...), + wantResult: &planShape{ + Summary: plan.Summary{Update: 2}, + Composites: []plan.CompositeResult{{Ref: xnop, Status: plan.CompositeChanged}}, + Plans: []planEntry{ + {Kind: "XDownstreamResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + {Kind: "XNopResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + }, + }, + }, + "CompPlanText": { + reason: "-o plan prints the Composition's plan with its revision impact, then each affected XR's plans under it.", + testType: CompositionDiffTest, + tt: compCase, + args: compArgs, + wantText: `[~] Composition.apiextensions.crossplane.io xnopresources.diff.example.org (update) + Applying this creates a new CompositionRevision, which 2 composites would adopt. + spec: + pipeline[0]: + input: + inline: + ~ template: "apiVersion: ns.nop.example.org/v1alpha1\nkind: XDownstreamResource\nmetadata:\n name: {{ .observed.composite.resource.metadata.name }}\n namespace: {{ .observed.composite.resource.metadata.namespace }}\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: nop-resource\nspec:\n forProvider:\n configData: {{ .observed.composite.resource.spec.coolField }}\n resourceTier: basic\n" -> "apiVersion: ns.nop.example.org/v1alpha1\nkind: XDownstreamResource\nmetadata:\n name: {{ .observed.composite.resource.metadata.name }}\n namespace: {{ .observed.composite.resource.metadata.namespace }}\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: nop-resource\nspec:\n forProvider:\n configData: updated-{{ .observed.composite.resource.spec.coolField }}\n resourceTier: premium\n" + +XR: XNopResource.ns.diff.example.org default/another-resource + [~] XDownstreamResource.ns.nop.example.org default/another-resource (update) + spec: + forProvider: + ~ configData: "another-existing-value" -> "updated-another-existing-value" + ~ resourceTier: "basic" -> "premium" + +XR: XNopResource.ns.diff.example.org default/test-resource + [~] XDownstreamResource.ns.nop.example.org default/test-resource (update) + spec: + forProvider: + ~ configData: "existing-value" -> "updated-existing-value" + ~ resourceTier: "basic" -> "premium" + +Plan: 0 to add, 3 to change, 0 to destroy. + +Note: Cloud-layer plan not run (provider diff servers not yet wired). +`, + }, + "CompPlanJSON": { + reason: "-o json emits plan.Result: the Composition's revision impact, both affected XRs as changed composites, and their plans.", + testType: CompositionDiffTest, + tt: compCase, + args: append([]string{"-o", "json"}, compArgs...), + wantResult: &planShape{ + Summary: plan.Summary{Update: 3, NoOp: 2}, + Composites: []plan.CompositeResult{{Ref: another, Status: plan.CompositeChanged}, {Ref: xnop, Status: plan.CompositeChanged}}, + Compositions: []plan.CompositionResult{{ + Ref: comp, + RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2}, + }}, + Plans: []planEntry{ + {Kind: "Composition", Name: comp.Name, Action: plan.ActionUpdate}, + {Kind: "XDownstreamResource", Name: "another-resource", Action: plan.ActionUpdate, Composite: "another-resource"}, + {Kind: "XNopResource", Name: "another-resource", Action: plan.ActionNoOp, Composite: "another-resource"}, + {Kind: "XDownstreamResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + {Kind: "XNopResource", Name: "test-resource", Action: plan.ActionNoOp, Composite: "test-resource"}, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + cfg, _, _, _ := startIntegrationCluster(t, tc.testType, tc.tt, defaultTimeout) + + var stdout, stderr bytes.Buffer + argv := append([]string{"project", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) + code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) + if err != nil { + t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) + } + if code != dp.ExitCodeDiffDetected { + t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, dp.ExitCodeDiffDetected, code) + } + + if tc.wantResult == nil { + if diff := cmp.Diff(tc.wantText, stdout.String()); diff != "" { + t.Errorf("\n%s\nsimulate -o plan: -want, +got:\n%s", tc.reason, diff) + } + return + } + + var res plan.Result + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("\n%s\nsimulate -o json: output is not a plan.Result: %v\n%s", tc.reason, err, stdout.String()) + } + if res.FormatVersion != plan.ResultFormatVersion || res.Errored { + t.Errorf("\n%s\nsimulate -o json: want formatVersion %q and not errored, got %q, errored %t", tc.reason, plan.ResultFormatVersion, res.FormatVersion, res.Errored) + } + if diff := cmp.Diff(*tc.wantResult, summarize(res)); diff != "" { + t.Errorf("\n%s\nsimulate -o json: -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// planShape is the part of a plan.Result these cases pin: everything but +// the field changes, dry-run details and notes, which the plan text cases and +// the mapper's unit tests cover. +type planShape struct { + Summary plan.Summary + Composites []plan.CompositeResult + Compositions []plan.CompositionResult + Plans []planEntry +} + +// planEntry is one plan's identity, action and composite name. +type planEntry struct { + Kind, Name string + Action plan.Action + Composite string +} + +func summarize(r plan.Result) planShape { + s := planShape{Summary: r.Summary, Composites: r.Composites, Compositions: r.Compositions} + for _, p := range r.Plans { + e := planEntry{Kind: p.Kind, Name: p.Name, Action: p.Action} + if p.Composite != nil { + e.Composite = p.Composite.Name + } + s.Plans = append(s.Plans, e) + } + // The revision's predicted name is a hash of the Composition's content. + for i := range s.Compositions { + if ri := s.Compositions[i].RevisionImpact; ri != nil { + c := *ri + c.PredictedRevision = "" + s.Compositions[i].RevisionImpact = &c + } + } + return s +} + +// integrationEnvGap returns why the environment cannot run an integration +// case, as runIntegrationTest's env: skips do, or "" when it can. +func integrationEnvGap() string { + switch { + case !dockerAvailable(): + return "env: no Docker daemon for the function render engine" + case !envtestBinariesAvailable(): + return "env: no envtest binaries (KUBEBUILDER_ASSETS unset)" + case os.Getenv("CROSSPLANE_CRDS_DIR") == "": + return "env: CROSSPLANE_CRDS_DIR unset (run via nix run .#test or set it to Crossplane's cluster/crds)" + } + return "" +} diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 3521fb2c..66795024 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -120,7 +120,10 @@ func simulateGap(_ DiffTestType, a *simArgs) string { // simulateArgv translates the parsed crossplane-diff args into project // simulate's: the input files as -f, a single --resource as --name (and // --namespace), --include-manual as --include-pinned, and -// --analyze-unchanged as its replacement --analyze-on=always. +// --analyze-unchanged as its replacement --analyze-on=always. The output +// stays crossplane-diff's: its default diff is passed explicitly, since +// simulate defaults to -o plan, and json or yaml always carry +// --output-version, which selects its structured schema over plan.Result. func simulateArgv(t *testing.T, a *simArgs) []string { t.Helper() @@ -128,9 +131,11 @@ func simulateArgv(t *testing.T, a *simArgs) []string { "project", "simulate", "--timeout=" + a.Timeout.String(), "--output=" + a.Output, - "--output-version=" + a.OutputVersion, "--dry-run-on=" + a.DryRunOn, } + if a.Output != "diff" { + argv = append(argv, "--output-version="+a.OutputVersion) + } for _, f := range a.Files { argv = append(argv, "-f", f) } @@ -186,6 +191,14 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s a := parseSimulateArgs(t, testType, args) + return runSimulateArgv(t, cfg, simulateArgv(t, a), a.CrossplaneRenderBinary, stdout, stderr) +} + +// runSimulateArgv runs `crossplane project simulate` with argv against cfg, +// rendering with renderBinary when it is set, as runSimulate does. +func runSimulateArgv(t *testing.T, cfg *rest.Config, argv []string, renderBinary string, stdout, stderr io.Writer) (int, error) { + t.Helper() + var cli struct { Project project.Cmd `cmd:""` } @@ -205,7 +218,6 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s t.Fatalf("failed to create kong parser: %v", err) } - argv := simulateArgv(t, a) kctx, err := parser.Parse(argv) if err != nil { t.Fatalf("failed to parse project simulate args %v: %v", argv, err) @@ -216,7 +228,7 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s // when there is one, and colorized unless --no-color. cli.Project.Simulate.Hooks = &project.SimulateHooks{ RESTConfig: cfg, - RenderBinary: a.CrossplaneRenderBinary, + RenderBinary: renderBinary, Terminal: true, } From 3a9d8b7bd15f2aee8eaf1b498d26f1f2555c2eae Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Tue, 20 Oct 2026 23:09:51 -0400 Subject: [PATCH 050/126] fix(plan): quote map keys containing dots in field paths 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/plan_output_test.go | 25 +++++ internal/plan/fallback.go | 9 +- internal/plan/output.go | 4 +- internal/plan/path.go | 106 ++++++++++++++++++ internal/plan/path_test.go | 76 +++++++++++++ 5 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 internal/plan/path.go create mode 100644 internal/plan/path_test.go diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index b1297088..cf9b45a4 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -95,6 +95,31 @@ func TestSimulatePlanOutput(t *testing.T) { Plan: 0 to add, 2 to change, 0 to destroy. +Note: Cloud-layer plan not run (provider diff servers not yet wired). +`, + }, + "XRCreatePlanText": { + reason: "-o plan prints a dotted annotation or label key, such as crossplane.io/composite, whole as one level.", + testType: XRDiffTest, + tt: IntegrationTestCase{setupFiles: xrCase.setupFiles}, + args: []string{"-f", "testdata/diff/new-xr.yaml"}, + wantText: `XR: XNopResource.ns.diff.example.org default/test-resource + [+] XDownstreamResource.ns.nop.example.org default/test-resource (create) + metadata: + annotations: + + crossplane.io/composition-resource-name: "nop-resource" + labels: + + crossplane.io/composite: "test-resource" + spec: + forProvider: + + configData: "new-value" + + [+] XNopResource.ns.diff.example.org default/test-resource (create) + spec: + + coolField: "new-value" + +Plan: 2 to add, 0 to change, 0 to destroy. + Note: Cloud-layer plan not run (provider diff servers not yet wired). `, }, diff --git a/internal/plan/fallback.go b/internal/plan/fallback.go index 35fe4622..f0525578 100644 --- a/internal/plan/fallback.go +++ b/internal/plan/fallback.go @@ -22,7 +22,6 @@ import ( "fmt" "maps" "slices" - "strings" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "sigs.k8s.io/structured-merge-diff/v6/fieldpath" @@ -86,7 +85,7 @@ func newCursor(root string) cursor { } func (c cursor) field(k string) cursor { - next := cursor{str: c.str + "." + k, fp: c.fp, inList: c.inList} + next := cursor{str: JoinKey(c.str, k), fp: c.fp, inList: c.inList} if !c.inList { next.fp = append(slices.Clip(c.fp), fieldpath.PathElement{FieldName: &k}) } @@ -109,11 +108,7 @@ func (c cursor) removable(owned *fieldpath.Set) bool { // actual-only changes, as for a delete; a nil actual yields planned-only // changes, as for a create. func FieldChanges(actual, planned map[string]any) []FieldChange { - out := diffMaps(cursor{}, actual, planned, nil) - for i := range out { - out[i].Path = strings.TrimPrefix(out[i].Path, ".") - } - return out + return diffMaps(cursor{}, actual, planned, nil) } // createChanges flattens a desired map into planned-only field changes, so a diff --git a/internal/plan/output.go b/internal/plan/output.go index ccd50dd8..34949796 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -460,7 +460,7 @@ func renderChangeTree(w io.Writer, changes []FieldChange) { // leave a replace with nothing that explains it. continue } - segs := strings.Split(c.Path, ".") + segs := PathSegments(c.Path) if len(segs) == 0 { continue } @@ -565,7 +565,7 @@ func replaceFields(plans []*ResourcePlan) []string { func lastSegments(paths []string) []string { out := make([]string, 0, len(paths)) for _, p := range paths { - segs := strings.Split(p, ".") + segs := PathSegments(p) out = append(out, segs[len(segs)-1]) } return out diff --git a/internal/plan/path.go b/internal/plan/path.go new file mode 100644 index 00000000..088aa6eb --- /dev/null +++ b/internal/plan/path.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plan + +import ( + "strconv" + "strings" +) + +// A FieldChange path joins map keys that are plain identifiers with ".", +// writes any other key as ["key"] (Go-quoted), and list indexes as [N]: +// +// metadata.annotations["crossplane.io/composite"] +// spec.pipeline[0].step + +// JoinKey appends the map key k to path. +func JoinKey(path, k string) string { + if !isIdent(k) { + return path + "[" + strconv.Quote(k) + "]" + } + if path == "" { + return k + } + return path + "." + k +} + +// isIdent reports whether k can be written bare: letters, digits, '_' and +// '-', not starting with a digit. +func isIdent(k string) bool { + if k == "" || (k[0] >= '0' && k[0] <= '9') { + return false + } + for _, r := range k { + switch { + case r == '_', r == '-', r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': + default: + return false + } + } + return true +} + +// PathSegments splits path into display levels: each map key unquoted, with +// any list indexes kept on the key they follow (e.g. "pipeline[0]"). A +// malformed quoted key is kept as written from that point on. +func PathSegments(path string) []string { + var segs []string + cur := "" + flush := func() { + if cur != "" { + segs = append(segs, cur) + } + cur = "" + } + for i := 0; i < len(path); { + switch { + case path[i] == '.': + flush() + i++ + case strings.HasPrefix(path[i:], `["`): + q, err := strconv.QuotedPrefix(path[i+1:]) + end := i + 1 + len(q) + if err != nil || end >= len(path) || path[end] != ']' { + cur += path[i:] + i = len(path) + continue + } + k, _ := strconv.Unquote(q) + flush() + cur = k + i = end + 1 + case path[i] == '[': + j := strings.IndexByte(path[i:], ']') + if j < 0 { + cur += path[i:] + i = len(path) + continue + } + cur += path[i : i+j+1] + i += j + 1 + default: + j := strings.IndexAny(path[i:], ".[") + if j < 0 { + j = len(path) - i + } + cur += path[i : i+j] + i += j + } + } + flush() + return segs +} diff --git a/internal/plan/path_test.go b/internal/plan/path_test.go new file mode 100644 index 00000000..60811141 --- /dev/null +++ b/internal/plan/path_test.go @@ -0,0 +1,76 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package plan + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestPathRoundTrip(t *testing.T) { + cases := map[string]struct { + keys []string + idx string // appended after the last key, e.g. "[0]" + path string + segs []string + }{ + "Plain": {keys: []string{"spec", "forProvider", "region"}, path: "spec.forProvider.region", segs: []string{"spec", "forProvider", "region"}}, + "Dots": {keys: []string{"metadata", "annotations", "crossplane.io/composite"}, path: `metadata.annotations["crossplane.io/composite"]`, segs: []string{"metadata", "annotations", "crossplane.io/composite"}}, + "Quotes": {keys: []string{"data", `a"b`}, path: `data["a\"b"]`, segs: []string{"data", `a"b`}}, + "Brackets": {keys: []string{"data", "a[0]"}, path: `data["a[0]"]`, segs: []string{"data", "a[0]"}}, + "Digit": {keys: []string{"data", "1st"}, path: `data["1st"]`, segs: []string{"data", "1st"}}, + "FirstKey": {keys: []string{"a.b", "c"}, path: `["a.b"].c`, segs: []string{"a.b", "c"}}, + "ListIndex": {keys: []string{"spec", "pipeline"}, idx: "[0]", path: "spec.pipeline[0]", segs: []string{"spec", "pipeline[0]"}}, + "Nested": {keys: []string{"spec", "tags", "k8s.io/x", "y_z-1"}, path: `spec.tags["k8s.io/x"].y_z-1`, segs: []string{"spec", "tags", "k8s.io/x", "y_z-1"}}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := "" + for _, k := range tc.keys { + p = JoinKey(p, k) + } + p += tc.idx + if p != tc.path { + t.Errorf("JoinKey: got %q, want %q", p, tc.path) + } + if diff := cmp.Diff(tc.segs, PathSegments(p)); diff != "" { + t.Errorf("PathSegments(%q): -want, +got:\n%s", p, diff) + } + }) + } +} + +func TestPathSegmentsIndexAfterQuotedKey(t *testing.T) { + want := []string{"spec", "a.b[2]", "c"} + if diff := cmp.Diff(want, PathSegments(`spec["a.b"][2].c`)); diff != "" { + t.Errorf("-want, +got:\n%s", diff) + } +} + +func TestRenderChangeTreeDottedKey(t *testing.T) { + changes := FieldChanges(nil, map[string]any{ + "metadata": map[string]any{"annotations": map[string]any{"crossplane.io/composite": "my-xr"}}, + }) + var buf bytes.Buffer + renderChangeTree(&buf, changes) + want := " metadata:\n annotations:\n + crossplane.io/composite: \"my-xr\"\n" + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("-want, +got:\n%s", diff) + } +} From e60df12767675d2ee57ce261a7a26ae05c9c1717 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 01:47:38 -0400 Subject: [PATCH 051/126] fix(diff): key the function provider cache by pipeline Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../diff/diffprocessor/function_provider.go | 23 +++++++++-- .../diffprocessor/function_provider_test.go | 41 +++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/internal/diff/diffprocessor/function_provider.go b/internal/diff/diffprocessor/function_provider.go index 4b26ce7d..21d3aeba 100644 --- a/internal/diff/diffprocessor/function_provider.go +++ b/internal/diff/diffprocessor/function_provider.go @@ -51,7 +51,8 @@ type FunctionProvider interface { // inside a Docker container (e.g. a GitHub Actions container job). const EnvDockerNetwork = "CROSSPLANE_DIFF_DOCKER_NETWORK" -// CachedFunctionProvider lazy-loads functions and caches them by composition name. +// CachedFunctionProvider lazy-loads functions and caches them by composition name and pipeline +// (see functionCacheKey). // This is appropriate for the comp command, where many XRs use the same composition: // the cluster is asked for a composition's functions once, however many XRs render it. // The function containers themselves are started, reused across renders and removed @@ -79,13 +80,14 @@ func NewCachedFunctionProvider(fnClient xp.FunctionClient, logger logging.Logger // GetFunctionsForComposition fetches and caches functions on first call per composition. func (p *CachedFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { compName := comp.GetName() + key := functionCacheKey(comp) // Held across the fetch, which only reads the function client's in-memory cache, so each // composition is fetched once. p.mu.Lock() defer p.mu.Unlock() - if cached, ok := p.cache[compName]; ok { + if cached, ok := p.cache[key]; ok { p.logger.Debug("Using cached functions", "composition", compName, "count", len(cached)) return deepCopyFunctions(cached), nil } @@ -100,11 +102,26 @@ func (p *CachedFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv p.logger.Debug("Fetched functions for caching", "composition", compName, "count", len(fns)) - p.cache[compName] = fns + p.cache[key] = fns return deepCopyFunctions(fns), nil } +// functionCacheKey keys the cache by composition name plus the ordered pipeline function names, the +// only inputs the fetch depends on. The name alone is not enough: a CompositionRevision is rendered +// under its Composition's name but may run a different pipeline. Kubernetes names cannot contain +// "/", so the join is unambiguous. +func functionCacheKey(comp *apiextensionsv1.Composition) string { + parts := make([]string, 0, len(comp.Spec.Pipeline)+1) + parts = append(parts, comp.GetName()) + + for _, step := range comp.Spec.Pipeline { + parts = append(parts, step.FunctionRef.Name) + } + + return strings.Join(parts, "/") +} + // deepCopyFunctions returns a copy of fns that shares no maps or slices with it. func deepCopyFunctions(fns []pkgv1.Function) []pkgv1.Function { out := make([]pkgv1.Function, len(fns)) diff --git a/internal/diff/diffprocessor/function_provider_test.go b/internal/diff/diffprocessor/function_provider_test.go index d220d3ab..c0f045db 100644 --- a/internal/diff/diffprocessor/function_provider_test.go +++ b/internal/diff/diffprocessor/function_provider_test.go @@ -256,6 +256,47 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_MultipleCompositions( } } +func TestCachedFunctionProvider_GetFunctionsForComposition_SameNameDifferentPipelinesNotShared(t *testing.T) { + // A CompositionRevision and its Composition share a name but may differ in pipeline. + fnClient := tu.NewMockFunctionClient(). + WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns := make([]pkgv1.Function, 0, len(comp.Spec.Pipeline)) + for _, step := range comp.Spec.Pipeline { + fns = append(fns, pkgv1.Function{ObjectMeta: metav1.ObjectMeta{Name: step.FunctionRef.Name}}) + } + + return fns, nil + }). + Build() + + provider := NewCachedFunctionProvider(fnClient, tu.TestLogger(t, false)) + + withPipeline := func(fnNames ...string) *apiextensionsv1.Composition { + comp := &apiextensionsv1.Composition{ObjectMeta: metav1.ObjectMeta{Name: "shared-name"}} + for _, n := range fnNames { + comp.Spec.Pipeline = append(comp.Spec.Pipeline, apiextensionsv1.PipelineStep{ + Step: n, + FunctionRef: apiextensionsv1.FunctionReference{Name: n}, + }) + } + + return comp + } + + if _, err := provider.GetFunctionsForComposition(withPipeline("function-a")); err != nil { + t.Fatalf("GetFunctionsForComposition(function-a) error = %v", err) + } + + got, err := provider.GetFunctionsForComposition(withPipeline("function-b")) + if err != nil { + t.Fatalf("GetFunctionsForComposition(function-b) error = %v", err) + } + + if len(got) != 1 || got[0].Name != "function-b" { + t.Errorf("GetFunctionsForComposition(function-b) = %v, want the function-b pipeline's functions", got) + } +} + func TestReplaceRegistry(t *testing.T) { tests := map[string]struct { pkg string From b1eb7480ee7c26111449b73f0761e8335fb0aeaf Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 01:49:48 -0400 Subject: [PATCH 052/126] feat(diff): let changed functions trigger composition impact analysis Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/comp_processor.go | 34 +++++++++- .../diff/diffprocessor/comp_processor_test.go | 66 +++++++++++++++++++ .../diff/diffprocessor/processor_config.go | 19 ++++++ 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 8de5bafb..2cb15ad2 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -472,10 +472,19 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // or a modeling artifact of this tool — which we cannot tell apart, so reporting it as this // composition's "impact" would attribute cluster state to a change that does not exist. // See issues #453 and #472. - if !comparison.scope.triggersAnalysis(p.config.AnalyzeOn) { + // + // A composition whose pipeline calls a changed function renders differently without creating a + // revision, so it is analysed as at least a metadata-scope change: any-change analyses it, + // spec-change still skips it. Only the decision is raised; RevisionImpact still reports the real scope. + analysisScope := comparison.scope + if analysisScope == ChangeScopeNone && callsChangedFunction(newComp, p.config.ChangedFunctions) { + analysisScope = ChangeScopeMetadata + } + + if !analysisScope.triggersAnalysis(p.config.AnalyzeOn) { p.config.Logger.Debug("Skipping impact analysis", "composition", newComp.GetName(), - "changeScope", string(comparison.scope), + "changeScope", string(analysisScope), "analyzeOn", string(p.config.AnalyzeOn), "affectedXRCount", len(affectedXRs)) @@ -897,6 +906,27 @@ func withoutLastApplied(obj *un.Unstructured) *un.Unstructured { return out } +// callsChangedFunction reports whether comp's pipeline references any function in changed. +func callsChangedFunction(comp *un.Unstructured, changed map[string]bool) bool { + if len(changed) == 0 { + return false + } + + steps, _, _ := un.NestedSlice(comp.Object, "spec", "pipeline") + for _, s := range steps { + step, ok := s.(map[string]any) + if !ok { + continue + } + + if name, _, _ := un.NestedString(step, "functionRef", "name"); changed[name] { + return true + } + } + + return false +} + // compositionChangeScope classifies how much of a composition differs from its in-cluster version, // in the terms Crossplane uses to decide whether a new CompositionRevision is needed. `anyChange` // says whether the two differ at all with every display mask lifted; this function only has to diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 9d690f5b..68f21de7 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -2156,3 +2156,69 @@ func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.O return stdout.String() } + +// A composition identical to the cluster's still renders differently when its pipeline calls a +// changed function, so it is analysed at any-change but still skipped at spec-change. +func TestDefaultCompDiffProcessor_processSingleComposition_ChangedFunctions(t *testing.T) { + builder := func() *tu.CompositionBuilder { + return tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step-a", "function-a", nil) + } + + xr := tu.NewResource("example.org/v1", "XR", "xr-1"). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + Build() + + type want struct { + Skipped bool + ChangeScope string + } + + cases := map[string]struct { + changed []string + analyzeOn AnalyzeOn + want want + }{ + "NoChangedFunctionsSkips": { + want: want{Skipped: true, ChangeScope: "none"}, + }, + "FunctionOnlyChangeTriggersAnalysis": { + changed: []string{"function-a"}, + want: want{ChangeScope: "none"}, + }, + "FunctionOnlyChangeUnreferencedFunctionStillSkips": { + changed: []string{"function-b"}, + want: want{Skipped: true, ChangeScope: "none"}, + }, + "FunctionOnlyChangeSpecChangeStillSkips": { + changed: []string{"function-a"}, + analyzeOn: AnalyzeOnSpecChange, + want: want{Skipped: true, ChangeScope: "none"}, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + client := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(builder().Build()). + Build() + + proc := newCompProcessorForTest(t, client, false) + proc.config.AnalyzeOn = tt.analyzeOn + WithChangedFunctions(tt.changed...)(&proc.config) + + got, err := proc.processSingleComposition(t.Context(), builder().BuildAsUnstructured(), []*un.Unstructured{xr}, false) + if err != nil { + t.Fatalf("processSingleComposition: %v", err) + } + + gotWant := want{Skipped: got.ImpactAnalysisSkipped, ChangeScope: got.RevisionImpact.ChangeScope} + if diff := gcmp.Diff(tt.want, gotWant); diff != "" { + t.Errorf("processSingleComposition() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 0221c6ef..0b82c9f5 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -134,6 +134,11 @@ type ProcessorConfig struct { // or simulating eventual state. Higher values may be needed for complex pipelines. MaxRenderIterations int + // ChangedFunctions names functions whose behaviour differs from the cluster's. A composition whose + // pipeline calls one is analysed as at least a metadata-scope change, even if it is otherwise + // identical. Empty by default, which changes nothing. + ChangedFunctions map[string]bool + // IgnorePaths is a list of paths to ignore when calculating diffs IgnorePaths []string @@ -242,6 +247,20 @@ func WithAnalyzeOn(analyzeOn AnalyzeOn) ProcessorOption { } } +// WithChangedFunctions names functions whose behaviour differs from the cluster's (see +// ProcessorConfig.ChangedFunctions). +func WithChangedFunctions(names ...string) ProcessorOption { + return func(config *ProcessorConfig) { + if config.ChangedFunctions == nil { + config.ChangedFunctions = make(map[string]bool, len(names)) + } + + for _, n := range names { + config.ChangedFunctions[n] = true + } + } +} + // WithDryRunOn sets which resources are round-tripped through the apiserver. An empty value leaves // the config's default in place, matching WithAnalyzeOn. func WithDryRunOn(dryRunOn DryRunOn) ProcessorOption { From dc4bdec3f60fba76ed9a9bb000d07e3bb0ba3608 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 02:55:16 -0400 Subject: [PATCH 053/126] feat(plan): name each composite's composition in comp results 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/plan_output_test.go | 2 +- internal/plan/output.go | 3 ++ internal/simulate/report.go | 1 + internal/simulate/report_test.go | 34 +++++++++++++++---- 4 files changed, 33 insertions(+), 7 deletions(-) diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index cf9b45a4..cc7987e9 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -176,7 +176,7 @@ Note: Cloud-layer plan not run (provider diff servers not yet wired). args: append([]string{"-o", "json"}, compArgs...), wantResult: &planShape{ Summary: plan.Summary{Update: 3, NoOp: 2}, - Composites: []plan.CompositeResult{{Ref: another, Status: plan.CompositeChanged}, {Ref: xnop, Status: plan.CompositeChanged}}, + Composites: []plan.CompositeResult{{Ref: another, Status: plan.CompositeChanged, Composition: &comp}, {Ref: xnop, Status: plan.CompositeChanged, Composition: &comp}}, Compositions: []plan.CompositionResult{{ Ref: comp, RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2}, diff --git a/internal/plan/output.go b/internal/plan/output.go index 34949796..60f4a06c 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -116,6 +116,9 @@ type CompositeResult struct { // Pinned marks a composite simulated although it is pinned to a // composition revision, previewing the moment that revision advances. Pinned bool `json:"pinned,omitempty"` + // Composition is the Composition whose change reaches this composite, + // set only for a composition simulate run. + Composition *ResourceRef `json:"composition,omitempty"` } // Error is an error from a simulate run, optionally anchored to a resource. diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 1ec3f729..306811c8 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -121,6 +121,7 @@ func FromCompReport(o *dt.CompDiffOutput) plan.Result { for _, impact := range comp.ImpactAnalysis { xr := resourceRef(impact.ObjectReference) c := compositeResult(impact) + c.Composition = &ref if impact.Error != nil { a.addError(xr, *impact.Error, &xr) owned = append(owned, *impact.Error) diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index 679c2f12..13579f6e 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -316,6 +316,7 @@ func TestFromCompReport(t *testing.T) { return plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: name} } xrErr := dt.OutputError{ResourceID: "XNetwork/prod", Message: "render failed"} + dbPlan := plan.ResourceRef{APIVersion: compPlan.APIVersion, Kind: compPlan.Kind, Name: "db"} cases := map[string]struct { reason string @@ -337,7 +338,7 @@ func TestFromCompReport(t *testing.T) { want: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{Update: 1, Destroy: 1}, - Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged}}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged, Composition: &compPlan}}, Compositions: []plan.CompositionResult{{ Ref: compPlan, RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1, PredictedRevision: "net-abc1234"}, @@ -366,9 +367,9 @@ func TestFromCompReport(t *testing.T) { FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1, Deferred: 1}, Composites: []plan.CompositeResult{ - {Ref: named("pinned"), Status: plan.CompositeDeferred, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6"}, - {Ref: named("selector"), Status: plan.CompositeSkipped, Reason: "revision_selector_mismatch", Detail: "channel=stable"}, - {Ref: named("deleting"), Status: plan.CompositeSkipped, Reason: "deleting"}, + {Ref: named("pinned"), Status: plan.CompositeDeferred, Composition: &compPlan, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6"}, + {Ref: named("selector"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "revision_selector_mismatch", Detail: "channel=stable"}, + {Ref: named("deleting"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "deleting"}, }, Compositions: []plan.CompositionResult{{Ref: compPlan}}, Plans: []*plan.ResourcePlan{compNoOp}, @@ -383,7 +384,7 @@ func TestFromCompReport(t *testing.T) { want: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1}, - Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeUnchanged, Pinned: true}}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeUnchanged, Composition: &compPlan, Pinned: true}}, Compositions: []plan.CompositionResult{{Ref: compPlan}}, Plans: []*plan.ResourcePlan{compNoOp}, Notes: notes(), @@ -425,6 +426,27 @@ func TestFromCompReport(t *testing.T) { Notes: notes(), }, }, + "CompositesNameTheirComposition": { + reason: "Each affected composite names the Composition whose change reaches it.", + in: &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{ + {Name: "net", ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusUnchanged}}}, + {Name: "db", ImpactAnalysis: []dt.XRImpact{filtered("qa", dt.FilterReasonDeleting, "")}}, + }}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{NoOp: 2}, + Composites: []plan.CompositeResult{ + {Ref: xrPlan, Status: plan.CompositeUnchanged, Composition: &compPlan}, + {Ref: named("qa"), Status: plan.CompositeSkipped, Composition: &dbPlan, Reason: "deleting"}, + }, + Compositions: []plan.CompositionResult{{Ref: compPlan}, {Ref: dbPlan}}, + Plans: []*plan.ResourcePlan{ + compNoOp, + {APIVersion: dbPlan.APIVersion, Kind: dbPlan.Kind, Name: dbPlan.Name, Action: plan.ActionNoOp}, + }, + Notes: notes(), + }, + }, "XRErrorAndRunErrors": { reason: "An errored XR's error is anchored to it and not repeated; the interruption is a run error.", in: &dt.CompDiffOutput{ @@ -438,7 +460,7 @@ func TestFromCompReport(t *testing.T) { FormatVersion: plan.ResultFormatVersion, Errored: true, Summary: plan.Summary{NoOp: 1, Errored: 1}, - Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeError}}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeError, Composition: &compPlan}}, Compositions: []plan.CompositionResult{{Ref: compPlan}}, Plans: []*plan.ResourcePlan{ compNoOp, From 388b773d321eaf4af66bcb570182d5d39fbd9f22 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 02:55:16 -0400 Subject: [PATCH 054/126] test(simulate): assert integration JSON output on plan.Result 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) Signed-off-by: Jonathan Ogilvie --- .../diff/diffprocessor/comp_processor_test.go | 51 +- internal/diff/integration/integration_test.go | 23 +- .../diff/integration/simulate_harness_test.go | 11 +- internal/diff/testutils/plan_assertions.go | 984 ++++++++++++ .../diff/testutils/structured_assertions.go | 1343 ----------------- .../testutils/structured_assertions_test.go | 990 ++++-------- 6 files changed, 1317 insertions(+), 2085 deletions(-) create mode 100644 internal/diff/testutils/plan_assertions.go diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 68f21de7..c722a334 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -41,6 +41,7 @@ import ( dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/diff/types" + "github.com/crossplane/cli/v2/internal/simulate" ) func TestDefaultCompDiffProcessor_findResourcesUsingComposition(t *testing.T) { @@ -199,12 +200,11 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { } tests := map[string]struct { - compositions []*un.Unstructured - namespace string - analyzeOn AnalyzeOn - outputFormat renderer.OutputFormat - outputVersion renderer.OutputVersion - setupMocks func() xp.Clients + compositions []*un.Unstructured + namespace string + analyzeOn AnalyzeOn + outputFormat renderer.OutputFormat + setupMocks func() xp.Clients // verifyOutput runs whether or not DiffComposition returns an error, since a failed composition // is still reported, and so still rendered. verifyOutput func(t *testing.T, output string) @@ -212,18 +212,16 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { // wantErrContains, when set, must appear in the returned error. wantErrContains string }{ - // In v2 a composition that fails to process carries its error as a structured errors[] entry, - // and the top-level errors[] lists it too. - "FailedCompositionReportsStructuredErrorsV2": { - namespace: "default", - compositions: []*un.Unstructured{malformedComp()}, - outputFormat: renderer.OutputFormatJSON, - outputVersion: renderer.OutputVersionV2, - setupMocks: singleXRMocks, + // A composition that fails to process carries its error on its plan, and the top-level errors[] + // lists it too. + "FailedCompositionReportsStructuredErrors": { + namespace: "default", + compositions: []*un.Unstructured{malformedComp()}, + outputFormat: renderer.OutputFormatJSON, + setupMocks: singleXRMocks, verifyOutput: func(t *testing.T, output string) { t.Helper() tu.AssertStructuredCompDiff(t, output, tu.ExpectCompDiff(). - WithOutputVersion("v2"). WithError("Composition/test-composition"). WithComposition("test-composition"). WithCompositionErrorContaining("cannot convert composition")) @@ -486,7 +484,7 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { } if tt.verifyOutput != nil { - tt.verifyOutput(t, renderCompReport(t, output, tt.outputFormat, tt.outputVersion)) + tt.verifyOutput(t, renderCompReport(t, output, tt.outputFormat)) } }) } @@ -2112,16 +2110,25 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { }) } -// renderCompReport renders a DiffComposition report the way the comp command does, in the given format -// (empty means the human-readable diff) and structured output version (empty means the default), and -// returns what it wrote to stdout. -func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.OutputFormat, version renderer.OutputVersion) string { +// renderCompReport renders a DiffComposition report in the given format (empty means the +// human-readable diff) and returns what it wrote to stdout. JSON is project simulate's: the report +// mapped onto plan.Result. +func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.OutputFormat) string { t.Helper() if output == nil { return "" } + if format == renderer.OutputFormatJSON { + out, err := json.Marshal(simulate.FromCompReport(output)) + if err != nil { + t.Fatalf("json.Marshal(): %v", err) + } + + return string(out) + } + var stdout bytes.Buffer opts := renderer.DefaultDiffOptions() @@ -2133,10 +2140,6 @@ func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.O opts.Format = format } - if version != "" { - opts.OutputVersion = version - } - logger := tu.TestLogger(t, false) var compRenderer renderer.CompDiffRenderer diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 1f70e721..83e43fba 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -115,8 +115,6 @@ type IntegrationTestCase struct { // For XR tests, populate expectedStructuredOutput. For CompositionDiffTest tests, // populate expectedStructuredCompOutput. Only one should be set per test case. outputFormat string // "json" or "" (default=visual diff) - // outputVersion, when set, is passed as --output-version (the structured output schema version). - outputVersion string // Typed as the assertion interfaces rather than the concrete root builders. The assert functions // have accepted any builder level since those interfaces were introduced, specifically so a chain // need not climb back to the root — but concrete field types here made that unreachable, which is @@ -356,10 +354,6 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC args = append(args, "--output=json") } - if tt.outputVersion != "" { - args = append(args, fmt.Sprintf("--output-version=%s", tt.outputVersion)) - } - // Add ignore-paths if specified if len(tt.ignorePaths) > 0 { for _, path := range tt.ignorePaths { @@ -997,9 +991,8 @@ func TestDiffIntegration(t *testing.T) { expectedExitCode: dp.ExitCodeDiffDetected, }, "MultipleXRsGroupedByInputXRV2": { - reason: "With --output-version=v2, the xrs[] entries carry each input's changes and the deprecated flat changes[] is gone", - outputFormat: "json", - outputVersion: "v2", + reason: "The composites[] entries group each input's changes", + outputFormat: "json", inputFiles: []string{ "testdata/diff/new-xr.yaml", "testdata/diff/new-xr-second.yaml", @@ -1010,8 +1003,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/functions.yaml", }, expectedStructuredOutput: tu.ExpectDiff(). - WithOutputVersion("v2"). - // The top-level summary stays in v2: it counts distinct resources. + // The top-level summary counts distinct resources. WithSummary(4, 0, 0). WithXRs( tu.XR("XNopResource", "test-resource", "default"). @@ -5428,12 +5420,10 @@ Summary: 1 modified`, WithXRImpact("XCycleA", "test-cycle", "default", "error"). WithErrorContaining("maximum nesting depth exceeded"), }, - // The same failure under --output-version=v2: the impact entry is the shared per-XR entry, and - // its errors[] holds the same OutputError as the top-level errors[]. + // The same failure, asserting the composite's error is recorded against it in the top-level errors[]. "CyclicCompositionStopsAtMaxNestedDepthV2": { - reason: "With --output-version=v2, a failing composite's impactAnalysis entry carries its error as a structured errors[] entry", - outputFormat: "json", - outputVersion: "v2", + reason: "A failing composite's error is a structured errors[] entry recorded against it", + outputFormat: "json", setupFiles: []string{ "testdata/diff/resources/cycle/xrds.yaml", "testdata/diff/resources/cycle/compositions.yaml", @@ -5447,7 +5437,6 @@ Summary: 1 modified`, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, expectedStructuredCompOutput: tu.ExpectCompDiff(). - WithOutputVersion("v2"). WithError("XCycleA/test-cycle"). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 66795024..c18c359b 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -70,8 +70,7 @@ type simArgs struct { Timeout time.Duration `default:"1m" help:"How long to run before timing out."` CrossplaneRenderBinary string `help:"Local crossplane binary to render with." name:"crossplane-render-binary"` Namespace string `help:"Namespace of the composite resources."` - Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` - OutputVersion string `default:"v1" enum:"v1,v2" help:"Schema version of structured (-o json|yaml) output (v1 or v2)." name:"output-version"` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` NoColor bool `help:"Disable colorized output." name:"no-color"` IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` FunctionCredentials string `help:"Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` @@ -121,9 +120,8 @@ func simulateGap(_ DiffTestType, a *simArgs) string { // simulate's: the input files as -f, a single --resource as --name (and // --namespace), --include-manual as --include-pinned, and // --analyze-unchanged as its replacement --analyze-on=always. The output -// stays crossplane-diff's: its default diff is passed explicitly, since -// simulate defaults to -o plan, and json or yaml always carry -// --output-version, which selects its structured schema over plan.Result. +// is passed explicitly: crossplane-diff's default diff, since simulate +// defaults to -o plan, and json or yaml as plan.Result. func simulateArgv(t *testing.T, a *simArgs) []string { t.Helper() @@ -133,9 +131,6 @@ func simulateArgv(t *testing.T, a *simArgs) []string { "--output=" + a.Output, "--dry-run-on=" + a.DryRunOn, } - if a.Output != "diff" { - argv = append(argv, "--output-version="+a.OutputVersion) - } for _, f := range a.Files { argv = append(argv, "-f", f) } diff --git a/internal/diff/testutils/plan_assertions.go b/internal/diff/testutils/plan_assertions.go new file mode 100644 index 00000000..757d0af9 --- /dev/null +++ b/internal/diff/testutils/plan_assertions.go @@ -0,0 +1,984 @@ +package testutils + +import ( + "encoding/json" + "fmt" + "reflect" + "regexp" + "slices" + "strconv" + "strings" + "testing" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// The assertions below read project simulate's structured output, plan.Result, behind the builder API +// in structured_assertions.go. The builders speak crossplane-diff's vocabulary (added/modified/removed +// changes, xrs[] entries, impactAnalysis[] entries); this file maps each onto its plan.Result home: +// +// - a change is a ResourcePlan, its type the plan's Action (create/update/delete); +// - a field value or old/new pair is read from the plan's leaf FieldChanges, a non-leaf path being +// re-assembled from the leaves under it; +// - an xrs[] or impactAnalysis[] entry is a Composites entry, its changes the plans grouped under it; +// - a composition is a Compositions entry plus its own (ungrouped) plan; +// - an error's resourceID ("Kind/name") is the Kind and Name of its Errors[].resource. + +// changeAction maps a builder change word onto its plan action, empty for an unknown word. +func changeAction(changeType string) plan.Action { + switch changeType { + case dt.DiffTypeWordAdded: + return plan.ActionCreate + case dt.DiffTypeWordModified: + return plan.ActionUpdate + case dt.DiffTypeWordRemoved: + return plan.ActionDelete + default: + return "" + } +} + +// statusFiltered is crossplane-diff's status for a composite left out of impact analysis, which +// plan.Result splits into deferred (Manual policy) and skipped (any other reason). +const statusFiltered = "filtered" + +// ParseResult parses project simulate's -o json output. +func ParseResult(jsonOutput string) (plan.Result, error) { + var res plan.Result + if err := json.Unmarshal([]byte(jsonOutput), &res); err != nil { + return res, fmt.Errorf("failed to parse plan result JSON output: %w", err) + } + + return res, nil +} + +// AssertStructuredDiff checks an xr run's plan.Result against expected. Accepts any builder level, so +// callers don't need to call And() to return to the root. +func AssertStructuredDiff(t *testing.T, jsonOutput string, e DiffExpectation) { + t.Helper() + + expected := e.expectation() + + res, err := ParseResult(jsonOutput) + if err != nil { + t.Fatalf("%v\nOutput was:\n%s", err, jsonOutput) + } + + if s := expected.summary; s != nil { + assertSummary(t, "Summary", s, res.Summary.Create, res.Summary.Update, res.Summary.Destroy) + } + + changes := changePlans(res.Plans, func(*plan.ResourcePlan) bool { return true }) + assertResourceExpectations(t, "", changes, expected.resources) + + if len(expected.resources) > 0 && len(changes) != len(expected.resources) { + t.Errorf("Expected %d changes, got %d: %v", len(expected.resources), len(changes), planIDs(changes)) + } + + for _, want := range expected.errors { + got := findMatchingError(res.Errors, want.resourceID) + if got == nil { + t.Errorf("Expected error for resourceID %q not found in output. Actual error IDs: %v", + want.resourceID, errorIDs(res.Errors)) + + continue + } + + assertErrorMatch(t, *got, want) + } + + // Pinning any error expectations pins the count too. Zero expectations = no opinion on errors. + if len(expected.errors) > 0 && len(res.Errors) != len(expected.errors) { + t.Errorf("Expected %d errors in structured output, got %d", len(expected.errors), len(res.Errors)) + } + + assertWarnings(t, res.Warnings, warningMatches(expected.warnings, func(w *WarningExpectation) warningMatch { return w.warningMatch }), expected.noWarnings) + + assertXRExpectations(t, res, expected.xrs) +} + +// AssertStructuredCompDiff checks a comp run's plan.Result against expected. Accepts any builder level, +// so callers don't need to call And()/AndXR()/AndComp(). +func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e CompDiffExpectation) { + t.Helper() + + expected := e.compExpectation() + + res, err := ParseResult(jsonOutput) + if err != nil { + t.Fatalf("%v\nOutput was:\n%s", err, jsonOutput) + } + + assertWarnings(t, res.Warnings, warningMatches(expected.warnings, func(w *CompWarningExpectation) warningMatch { return w.warningMatch }), expected.noWarnings) + + for _, id := range expected.errorIDs { + if findMatchingError(res.Errors, id) == nil { + t.Errorf("Expected top-level error with resourceID %q not found. Actual error IDs: %v", id, errorIDs(res.Errors)) + } + } + + for _, want := range expected.compositions { + assertComposition(t, res, want) + } +} + +func assertComposition(t *testing.T, res plan.Result, want *CompositionExpectation) { + t.Helper() + + i := slices.IndexFunc(res.Compositions, func(c plan.CompositionResult) bool { return c.Ref.Name == want.name }) + if i < 0 { + names := make([]string, 0, len(res.Compositions)) + for _, c := range res.Compositions { + names = append(names, c.Ref.Name) + } + + t.Errorf("Expected composition %s not found in output. Actual compositions: %v", want.name, names) + + return + } + + comp := res.Compositions[i] + label := "Composition " + want.name + compPlan := findPlan(res.Plans, comp.Ref, nil) + + if want.errorContains != "" { + got := "" + if compPlan != nil { + got = compPlan.Error + } + + if !strings.Contains(got, want.errorContains) { + t.Errorf("%s: expected error containing %q, got %q", label, want.errorContains, got) + } + } + + composites := slices.DeleteFunc(slices.Clone(res.Composites), func(c plan.CompositeResult) bool { + return c.Composition == nil || *c.Composition != comp.Ref + }) + + assertAffected(t, label, composites, want) + + if want.impactAnalysisSkipped != nil && comp.ImpactAnalysisSkipped != *want.impactAnalysisSkipped { + t.Errorf("%s: ImpactAnalysisSkipped: expected %t, got %t", label, *want.impactAnalysisSkipped, comp.ImpactAnalysisSkipped) + } + + assertRevisionImpact(t, label, comp.RevisionImpact, want) + + if want.compositionChangeType != "" { + switch { + case compPlan == nil: + t.Errorf("%s: expected composition changes but found no plan for it", label) + case compPlan.Action != changeAction(want.compositionChangeType): + t.Errorf("%s: expected action %s, got %q (error %q)", label, changeAction(want.compositionChangeType), compPlan.Action, compPlan.Error) + default: + assertFields(t, label, compPlan, want.compositionChangeType, nil, want.compositionFieldChanges, nil) + } + } + + for _, wantXR := range want.xrImpacts { + assertXRImpact(t, res, label, composites, wantXR) + } +} + +// assertAffected checks the composition's affected-composite counts. Total counts every composite the +// composition reaches, filtered ones included. +func assertAffected(t *testing.T, label string, composites []plan.CompositeResult, want *CompositionExpectation) { + t.Helper() + + count := func(f func(plan.CompositeResult) bool) int { + n := 0 + for _, c := range composites { + if f(c) { + n++ + } + } + + return n + } + status := func(s plan.CompositeStatus) func(plan.CompositeResult) bool { + return func(c plan.CompositeResult) bool { return c.Status == s } + } + + check := func(name string, want *int, got int) { + if want != nil && got != *want { + t.Errorf("%s: AffectedResources.%s: expected %d, got %d", label, name, *want, got) + } + } + + check("Total", want.affectedTotal, len(composites)) + check("WithChanges", want.affectedWithChanges, count(status(plan.CompositeChanged))) + check("Unchanged", want.affectedUnchanged, count(status(plan.CompositeUnchanged))) + check("WithErrors", want.affectedWithErrors, count(status(plan.CompositeError))) + check("FilteredByDeletion", want.affectedFilteredByDeletion, count(func(c plan.CompositeResult) bool { + return c.Status == plan.CompositeSkipped && c.Reason == string(dt.FilterReasonDeleting) + })) +} + +func assertRevisionImpact(t *testing.T, label string, got *plan.RevisionImpact, want *CompositionExpectation) { + t.Helper() + + if want.revisionImpact == nil && want.predictedRevisionName == nil { + return + } + + if got == nil { + if want.revisionImpact != nil || want.predictedRevisionName.pattern != "" { + t.Errorf("%s: expected a revisionImpact, but none was reported", label) + } + + return + } + + if w := want.revisionImpact; w != nil { + if got.ChangeScope != w.changeScope { + t.Errorf("%s: RevisionImpact.ChangeScope: expected %q, got %q", label, w.changeScope, got.ChangeScope) + } + + if got.CreatesRevision != w.createsRevision { + t.Errorf("%s: RevisionImpact.CreatesRevision: expected %t, got %t", label, w.createsRevision, got.CreatesRevision) + } + + if got.RepointedComposites != w.repointedComposites { + t.Errorf("%s: RevisionImpact.RepointedComposites: expected %d, got %d", label, w.repointedComposites, got.RepointedComposites) + } + } + + w := want.predictedRevisionName + switch { + case w == nil: + case w.pattern == "": + if got.PredictedRevision != "" { + t.Errorf("%s: RevisionImpact.PredictedRevision: expected absent, got %q", label, got.PredictedRevision) + } + default: + matched, err := regexp.MatchString(w.pattern, got.PredictedRevision) + + switch { + case err != nil: + t.Errorf("%s: RevisionImpact.PredictedRevision: invalid pattern %q: %v", label, w.pattern, err) + case !matched: + t.Errorf("%s: RevisionImpact.PredictedRevision: %q does not match pattern %q", label, got.PredictedRevision, w.pattern) + } + } +} + +func assertXRImpact(t *testing.T, res plan.Result, label string, composites []plan.CompositeResult, want *XRImpactExpectation) { + t.Helper() + + i := slices.IndexFunc(composites, func(c plan.CompositeResult) bool { + return refMatches(c.Ref, want.kind, want.name, want.namespace, nil, want.anyNameAllowed, false) + }) + if i < 0 { + t.Errorf("%s: Expected XR impact %s/%s (ns=%s) not found. Actual XRs: %v", + label, want.kind, want.name, want.namespace, compositeIDs(composites)) + + return + } + + got := composites[i] + label = fmt.Sprintf("%s: XR %s/%s", label, want.kind, got.Ref.Name) + + if !statusMatches(got.Status, want.status) { + t.Errorf("%s: expected status %s, got %s", label, want.status, got.Status) + } + + if want.filterReason != "" && got.Reason != want.filterReason { + t.Errorf("%s: expected filterReason %s, got %s", label, want.filterReason, got.Reason) + } + + errs := slices.DeleteFunc(slices.Clone(res.Errors), func(e plan.Error) bool { return e.Resource == nil || *e.Resource != got.Ref }) + + if want.errorContains != "" { + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Message) + } + + if text := strings.Join(msgs, "; "); !strings.Contains(text, want.errorContains) { + t.Errorf("%s: expected error containing %q, got %q", label, want.errorContains, text) + } + } + + for _, id := range want.errorIDs { + if findMatchingError(errs, id) == nil { + t.Errorf("%s: expected error with resourceID %q not found. Actual error IDs: %v", label, id, errorIDs(errs)) + } + } + + downstream := changePlans(res.Plans, groupedUnder(got.Ref)) + + if s := want.downstreamSummary; s != nil { + create, update, destroy := countActions(downstream) + assertSummary(t, label+": downstream summary", s, create, update, destroy) + } + + for _, wantRes := range want.downstreamResources { + p := findMatchingPlan(downstream, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace, wantRes.namePattern, wantRes.anyNameAllowed) + if p == nil { + t.Errorf("%s: expected downstream %s resource %s/%s not found. Actual: %v", + label, wantRes.changeType, wantRes.kind, wantRes.name, planIDs(downstream)) + + continue + } + + id := fmt.Sprintf("%s: downstream %s %s/%s", label, wantRes.changeType, wantRes.kind, p.Name) + assertDryRun(t, id, p.DryRun, wantRes.dryRun) + assertFields(t, id, p, wantRes.changeType, wantRes.fieldValues, wantRes.fieldChanges, wantRes.fieldValuePatterns) + } +} + +// statusMatches compares a composite's status with crossplane-diff's, whose "filtered" plan.Result +// splits into deferred and skipped. +func statusMatches(got plan.CompositeStatus, want string) bool { + if want == statusFiltered { + return got == plan.CompositeDeferred || got == plan.CompositeSkipped + } + + return string(got) == want +} + +// assertXRExpectations checks the xrs[] expectations against Composites. +// +// Each expectation consumes the entry it matches, so two expectations can never both resolve to the +// same entry, and the entry count is pinned once the test has an opinion on xrs[] at all. Without those +// guards a duplicated identity satisfied every expectation that matched it (issues #477, #488). +func assertXRExpectations(t *testing.T, res plan.Result, expected []*XRExpectation) { + t.Helper() + + if len(expected) == 0 { + return + } + + if len(res.Composites) != len(expected) { + t.Errorf("Expected %d xrs[] entries, got %d: %v", len(expected), len(res.Composites), compositeIDs(res.Composites)) + } + + used := make([]bool, len(res.Composites)) + + for _, want := range expected { + i := -1 + for j, c := range res.Composites { + if !used[j] && refMatches(c.Ref, want.kind, want.name, want.namespace, want.namePattern, want.anyName, true) { + i = j + break + } + } + + if i < 0 { + t.Errorf("Expected xrs[] entry %s/%s (ns=%s) not found. Actual: %v", + want.kind, want.name, want.namespace, compositeIDs(res.Composites)) + + continue + } + + used[i] = true + got := res.Composites[i] + label := fmt.Sprintf("%s/%s", got.Ref.Kind, got.Ref.Name) + + if want.status != "" && !statusMatches(got.Status, want.status) { + t.Errorf("xr %s: status: expected %q, got %q", label, want.status, got.Status) + } + + changes := changePlans(res.Plans, groupedUnder(got.Ref)) + + if s := want.summary; s != nil { + create, update, destroy := countActions(changes) + assertSummary(t, "xr "+label+": summary", s, create, update, destroy) + } + + assertResourceExpectations(t, label, changes, want.changes) + + // Pin the entry's change count too, so a change the test did not ask for cannot hide inside a + // matched entry. + if len(want.changes) > 0 && len(changes) != len(want.changes) { + t.Errorf("xr %s: expected %d changes, got %d: %v", label, len(want.changes), len(changes), planIDs(changes)) + } + + for _, id := range want.errorIDs { + e := findMatchingError(res.Errors, id) + if e == nil || e.Resource == nil || *e.Resource != got.Ref { + t.Errorf("xr %s: expected error with resourceID %q not found against it. Actual error IDs: %v", label, id, errorIDs(res.Errors)) + } + } + } +} + +// assertResourceExpectations checks each expectation against a set of change plans. scope is "" for +// the flat view, or an XR's Kind/name, and is used only in failure messages. +func assertResourceExpectations(t *testing.T, scope string, changes []*plan.ResourcePlan, resources []*ResourceExpectation) { + t.Helper() + + prefix := "" + if scope != "" { + prefix = "xr " + scope + ": " + } + + for _, want := range resources { + p := findMatchingPlan(changes, want.changeType, want.kind, want.name, want.namespace, want.namePattern, want.anyNameAllowed) + if p == nil { + t.Errorf("%sExpected %s resource %s/%s (ns=%s) not found. Actual resources: %v", + prefix, want.changeType, want.kind, want.name, want.namespace, planIDs(changes)) + + continue + } + + id := fmt.Sprintf("%s%s %s/%s", prefix, want.changeType, want.kind, want.name) + assertDryRun(t, id, p.DryRun, want.dryRun) + assertFields(t, id, p, want.changeType, want.fieldValues, want.fieldChanges, want.fieldValuePatterns) + + if want.specMatch != nil { + if spec := valueAt(t, p.Changes, want.changeType != dt.DiffTypeWordRemoved, "spec"); !reflect.DeepEqual(spec, want.specMatch) { + t.Errorf("%s: spec mismatch: expected %v, got %v", id, want.specMatch, spec) + } + } + } +} + +// assertFields checks a plan's field expectations. values and patterns read the planned side, or the +// actual side of a delete, which carries what is removed. changes compare both sides. +func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType string, values map[string]any, changes map[string][2]any, patterns map[string]*regexp.Regexp) { + t.Helper() + + planned := changeType != dt.DiffTypeWordRemoved + + for path, want := range values { + if got := valueAt(t, p.Changes, planned, path); !reflect.DeepEqual(got, want) { + t.Errorf("%s: field %s: expected %v, got %v", id, path, want, got) + } + } + + for path, want := range changes { + // FieldChanges are leaf changes only, so a field rendered identically on both sides has no + // plan.Result home: all that can be asserted is that nothing at or under it changed. + if want[0] != nil && reflect.DeepEqual(want[0], want[1]) { + if c := changesUnder(t, p.Changes, path); len(c) > 0 { + t.Errorf("%s: field %s: expected unchanged (%v), got changes %+v", id, path, want[0], c) + } + + continue + } + + if got := valueAt(t, p.Changes, false, path); !reflect.DeepEqual(got, want[0]) { + t.Errorf("%s: field %s old value: expected %v, got %v", id, path, want[0], got) + } + + if got := valueAt(t, p.Changes, true, path); !reflect.DeepEqual(got, want[1]) { + t.Errorf("%s: field %s new value: expected %v, got %v", id, path, want[1], got) + } + } + + for path, pattern := range patterns { + got := fmt.Sprintf("%v", valueAt(t, p.Changes, planned, path)) + if !pattern.MatchString(got) { + t.Errorf("%s: field %s value %q does not match pattern %q", id, path, got, pattern.String()) + } + } +} + +// assertDryRun checks a plan's dry-run verification: either that its desired state was reported as NOT +// verified against the apiserver (for a reason, optionally with the cluster's own explanation), or that +// no such report is present at all. +func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunExpectation) { + t.Helper() + + if want.mustBePerformed { + if got != nil { + t.Errorf("%s: expected the desired state to have been dry-run against the apiserver, but it reports skipReason %q (detail: %q)", + id, got.SkipReason, got.Detail) + } + + return + } + + if want.skipReason == "" { + return + } + + if got == nil { + t.Errorf("%s: expected dryRun.skipReason %q, but no dryRun was reported (absence means the desired state went through the apiserver)", + id, want.skipReason) + + return + } + + if got.SkipReason != want.skipReason { + t.Errorf("%s: dryRun.skipReason: expected %q, got %q", id, want.skipReason, got.SkipReason) + } + + // Performed must be false whenever the object is present; see dt.DryRunInfo. + if got.Performed { + t.Errorf("%s: dryRun.performed was true on a reported skip, which is contradictory", id) + } + + if want.detailSubstring != "" && !strings.Contains(got.Detail, want.detailSubstring) { + t.Errorf("%s: dryRun.detail %q does not contain %q — the cluster's own explanation must be propagated, not swallowed", + id, got.Detail, want.detailSubstring) + } + + if want.storageVersion != "" && got.StorageVersion != want.storageVersion { + t.Errorf("%s: dryRun.storageVersion: expected %q, got %q", id, want.storageVersion, got.StorageVersion) + } +} + +// assertWarnings checks each expectation against got, then — if the test pinned any expectations, or +// asserted there are none — verifies no extras were emitted, mirroring the errors[] guard. Without the +// count check, warning duplication is untestable: a regression that emits the same advisory once per +// XR still satisfies every find-first-match expectation. No expectations and no noWarnings = no opinion. +func assertWarnings(t *testing.T, got []plan.Warning, want []warningMatch, noWarnings bool) { + t.Helper() + + for _, w := range want { + assertWarningExpectation(t, got, w) + } + + if (len(want) > 0 || noWarnings) && len(got) != len(want) { + t.Errorf("Expected %d warnings in structured output, got %d: %v", len(want), len(got), warningMessages(got)) + } +} + +// assertWarningExpectation finds a warning whose message contains want's substring and, when the +// expectation pins one, whose context map is exactly want's. Both conditions select the warning, since +// several warnings legitimately share a message and differ only in context. +func assertWarningExpectation(t *testing.T, got []plan.Warning, want warningMatch) { + t.Helper() + + var contextsSeen []map[string]string + + for _, w := range got { + if !strings.Contains(w.Message, want.messageContains) { + continue + } + + if want.context == nil || reflect.DeepEqual(want.context, w.Context) { + return + } + + contextsSeen = append(contextsSeen, w.Context) + } + + if len(contextsSeen) > 0 { + t.Errorf("warning %q context mismatch: expected %v, got only %v", want.messageContains, want.context, contextsSeen) + return + } + + t.Errorf("expected a warning containing %q in structured output; got %v", want.messageContains, warningMessages(got)) +} + +// assertErrorMatch checks a single error against its expectation. +func assertErrorMatch(t *testing.T, got plan.Error, want *ErrorExpectation) { + t.Helper() + + if want.messageContains != "" && !strings.Contains(got.Message, want.messageContains) { + t.Errorf("error %q: message %q does not contain %q", want.resourceID, got.Message, want.messageContains) + } + + for _, wantVF := range want.validationFailures { + i := slices.IndexFunc(got.ValidationFailures, func(v plan.ValidationFailure) bool { + return v.APIVersion == wantVF.apiVersion && v.Kind == wantVF.kind && v.Name == wantVF.name && + (wantVF.namespace == "" || v.Namespace == wantVF.namespace) + }) + if i < 0 { + actual := make([]string, 0, len(got.ValidationFailures)) + for _, vf := range got.ValidationFailures { + actual = append(actual, fmt.Sprintf("%s/%s %s/%s", vf.APIVersion, vf.Kind, vf.Namespace, vf.Name)) + } + + t.Errorf("error %q: expected validationFailure %s/%s %s/%s not found. Actual: %v", + want.resourceID, wantVF.apiVersion, wantVF.kind, wantVF.namespace, wantVF.name, actual) + + continue + } + + assertValidationFailureMatch(t, got.ValidationFailures[i], wantVF) + } + + if len(want.validationFailures) > 0 && len(got.ValidationFailures) != len(want.validationFailures) { + t.Errorf("error %q: expected %d validationFailures, got %d", + want.resourceID, len(want.validationFailures), len(got.ValidationFailures)) + } +} + +func assertValidationFailureMatch(t *testing.T, got plan.ValidationFailure, want *ValidationFailureExpectation) { + t.Helper() + + label := fmt.Sprintf("validationFailure %s/%s %s", want.apiVersion, want.kind, want.name) + + if want.status != "" && got.Status != want.status { + t.Errorf("%s: status: expected %q, got %q", label, want.status, got.Status) + } + + for _, wantFE := range want.errors { + i := slices.IndexFunc(got.Errors, func(fe plan.FieldValidationError) bool { + return fe.Type == wantFE.errType && fe.Field == wantFE.field + }) + if i < 0 { + actual := make([]string, 0, len(got.Errors)) + for _, fe := range got.Errors { + actual = append(actual, fmt.Sprintf("[%s] %s", fe.Type, fe.Field)) + } + + t.Errorf("%s: expected fieldError [%s] %s not found. Actual: %v", label, wantFE.errType, wantFE.field, actual) + + continue + } + + gotFE := got.Errors[i] + + if wantFE.messageContains != "" && !strings.Contains(gotFE.Message, wantFE.messageContains) { + t.Errorf("%s: fieldError [%s] %s: message %q does not contain %q", + label, wantFE.errType, wantFE.field, gotFE.Message, wantFE.messageContains) + } + + if wantFE.hasValue && !reflect.DeepEqual(gotFE.Value, wantFE.value) { + t.Errorf("%s: fieldError [%s] %s: value: expected %v (%T), got %v (%T)", + label, wantFE.errType, wantFE.field, wantFE.value, wantFE.value, gotFE.Value, gotFE.Value) + } + } + + if len(want.errors) > 0 && len(got.Errors) != len(want.errors) { + t.Errorf("%s: expected %d field errors, got %d", label, len(want.errors), len(got.Errors)) + } +} + +func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy int) { + t.Helper() + + if create != want.added || update != want.modified || destroy != want.removed { + t.Errorf("%s: expected {added:%d modified:%d removed:%d}, got {added:%d modified:%d removed:%d}", + label, want.added, want.modified, want.removed, create, update, destroy) + } +} + +// changePlans returns the plans that change something, i.e. neither failed nor no-op, and satisfy f. +func changePlans(plans []*plan.ResourcePlan, f func(*plan.ResourcePlan) bool) []*plan.ResourcePlan { + var out []*plan.ResourcePlan + + for _, p := range plans { + if p.Error == "" && p.Action != plan.ActionNoOp && f(p) { + out = append(out, p) + } + } + + return out +} + +func groupedUnder(ref plan.ResourceRef) func(*plan.ResourcePlan) bool { + return func(p *plan.ResourcePlan) bool { return p.Composite != nil && *p.Composite == ref } +} + +func countActions(plans []*plan.ResourcePlan) (create, update, destroy int) { + for _, p := range plans { + switch p.Action { + case plan.ActionCreate: + create++ + case plan.ActionUpdate: + update++ + case plan.ActionDelete: + destroy++ + case plan.ActionReplace, plan.ActionNoOp: + } + } + + return create, update, destroy +} + +// findPlan returns the plan for ref grouped under composite. +func findPlan(plans []*plan.ResourcePlan, ref plan.ResourceRef, composite *plan.ResourceRef) *plan.ResourcePlan { + for _, p := range plans { + if p.APIVersion == ref.APIVersion && p.Kind == ref.Kind && p.Name == ref.Name && p.Namespace == ref.Namespace && + reflect.DeepEqual(p.Composite, composite) { + return p + } + } + + return nil +} + +// findMatchingPlan returns the first plan with changeType's action, kind and (when given) namespace, +// whose name is any, matches namePattern, or equals name. +func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namespace string, namePattern *regexp.Regexp, anyName bool) *plan.ResourcePlan { + for _, p := range plans { + if p.Action != changeAction(changeType) { + continue + } + + ref := plan.ResourceRef{Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} + if refMatches(ref, kind, name, namespace, namePattern, anyName, false) { + return p + } + } + + return nil +} + +// refMatches reports whether ref has kind and the expected name (any, matching namePattern, or equal). +// An empty namespace matches any namespace unless exactNamespace is set. +func refMatches(ref plan.ResourceRef, kind, name, namespace string, namePattern *regexp.Regexp, anyName, exactNamespace bool) bool { + if ref.Kind != kind || ((exactNamespace || namespace != "") && ref.Namespace != namespace) { + return false + } + + switch { + case anyName: + return true + case namePattern != nil: + return namePattern.MatchString(ref.Name) + default: + return ref.Name == name + } +} + +// findMatchingError returns the error whose resource is resourceID ("Kind/name"). +func findMatchingError(errs []plan.Error, resourceID string) *plan.Error { + for i := range errs { + if errorID(errs[i]) == resourceID { + return &errs[i] + } + } + + return nil +} + +// errorID is crossplane-diff's resourceID for an error: its resource's Kind/name, empty for a run +// error no resource owns. +func errorID(e plan.Error) string { + if e.Resource == nil { + return "" + } + + return e.Resource.Kind + "/" + e.Resource.Name +} + +func errorIDs(errs []plan.Error) []string { + ids := make([]string, 0, len(errs)) + for _, e := range errs { + ids = append(ids, errorID(e)) + } + + return ids +} + +func planIDs(plans []*plan.ResourcePlan) []string { + ids := make([]string, 0, len(plans)) + for _, p := range plans { + ids = append(ids, fmt.Sprintf("%s %s/%s (ns=%s)", p.Action, p.Kind, p.Name, p.Namespace)) + } + + return ids +} + +func compositeIDs(cs []plan.CompositeResult) []string { + ids := make([]string, 0, len(cs)) + for _, c := range cs { + ids = append(ids, fmt.Sprintf("%s/%s (ns=%s, status=%s)", c.Ref.Kind, c.Ref.Name, c.Ref.Namespace, c.Status)) + } + + return ids +} + +func warningMessages(ws []plan.Warning) []string { + msgs := make([]string, 0, len(ws)) + for _, w := range ws { + msgs = append(msgs, w.Message) + } + + return msgs +} + +func warningMatches[W any](ws []W, f func(W) warningMatch) []warningMatch { + out := make([]warningMatch, 0, len(ws)) + for _, w := range ws { + out = append(out, f(w)) + } + + return out +} + +// pathToken is one step of a field path: a map key, or a list index. +type pathToken struct { + key string + index int + isIndex bool +} + +// parsePath splits a field path into its steps. It reads both plan.Result's form (keys joined by ".", +// other keys as ["key"], indexes as [N]) and the test expectations' ['key'] form. +func parsePath(path string) ([]pathToken, error) { + var toks []pathToken + + for i := 0; i < len(path); { + switch { + case path[i] == '.': + i++ + case strings.HasPrefix(path[i:], `["`): + q, err := strconv.QuotedPrefix(path[i+1:]) + end := i + 1 + len(q) + + if err != nil || end >= len(path) || path[end] != ']' { + return nil, fmt.Errorf("malformed quoted key at %d in %q", i, path) + } + + k, _ := strconv.Unquote(q) + toks = append(toks, pathToken{key: k}) + i = end + 1 + case strings.HasPrefix(path[i:], `['`): + j := strings.Index(path[i+2:], `']`) + if j < 0 { + return nil, fmt.Errorf("unterminated quoted key at %d in %q", i, path) + } + + toks = append(toks, pathToken{key: path[i+2 : i+2+j]}) + i += 2 + j + 2 + case path[i] == '[': + j := strings.IndexByte(path[i:], ']') + if j < 0 { + return nil, fmt.Errorf("unterminated index at %d in %q", i, path) + } + + n, err := strconv.Atoi(path[i+1 : i+j]) + if err != nil { + return nil, fmt.Errorf("bad index at %d in %q: %w", i, path, err) + } + + toks = append(toks, pathToken{index: n, isIndex: true}) + i += j + 1 + default: + j := strings.IndexAny(path[i:], ".[") + if j < 0 { + j = len(path) - i + } + + toks = append(toks, pathToken{key: path[i : i+j]}) + i += j + } + } + + return toks, nil +} + +func mustParsePath(t *testing.T, path string) []pathToken { + t.Helper() + + toks, err := parsePath(path) + if err != nil { + t.Fatalf("field path: %v", err) + } + + return toks +} + +// under returns the steps of toks below prefix, and whether toks is at or under prefix at all. +func under(toks, prefix []pathToken) ([]pathToken, bool) { + if len(toks) < len(prefix) || !slices.Equal(toks[:len(prefix)], prefix) { + return nil, false + } + + return toks[len(prefix):], true +} + +// changesUnder returns the changes at or under path. +func changesUnder(t *testing.T, changes []plan.FieldChange, path string) []plan.FieldChange { + t.Helper() + + prefix := mustParsePath(t, path) + + var out []plan.FieldChange + + for _, c := range changes { + if _, ok := under(mustParsePath(t, c.Path), prefix); ok { + out = append(out, c) + } + } + + return out +} + +// valueAt returns the value at path on one side of a plan's changes: the planned side, or else the +// actual. A leaf is its change's value; a non-leaf is re-assembled from the changed leaves under it. +// Nil means no change sets anything at or under path on that side. +func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string) any { + t.Helper() + + prefix := mustParsePath(t, path) + + var root any + + for _, c := range changes { + v := c.Actual + if planned { + v = c.Planned + } + + if v == nil { + continue + } + + rel, ok := under(mustParsePath(t, c.Path), prefix) + if !ok { + continue + } + + if len(rel) == 0 { + return v.Value + } + + root = insert(root, rel, v.Value) + } + + return assemble(root) +} + +// insert sets v at toks below node, building maps as it goes. Lists are built as index maps, which +// assemble turns into slices. +func insert(node any, toks []pathToken, v any) any { + if len(toks) == 0 { + return v + } + + if toks[0].isIndex { + m, _ := node.(map[int]any) + if m == nil { + m = map[int]any{} + } + + m[toks[0].index] = insert(m[toks[0].index], toks[1:], v) + + return m + } + + m, _ := node.(map[string]any) + if m == nil { + m = map[string]any{} + } + + m[toks[0].key] = insert(m[toks[0].key], toks[1:], v) + + return m +} + +// assemble turns insert's index maps into slices. +func assemble(node any) any { + switch n := node.(type) { + case map[int]any: + size := 0 + for i := range n { + size = max(size, i+1) + } + + out := make([]any, size) + for i, v := range n { + out[i] = assemble(v) + } + + return out + case map[string]any: + for k, v := range n { + n[k] = assemble(v) + } + + return n + default: + return node + } +} diff --git a/internal/diff/testutils/structured_assertions.go b/internal/diff/testutils/structured_assertions.go index 788c9d98..76cf4d28 100644 --- a/internal/diff/testutils/structured_assertions.go +++ b/internal/diff/testutils/structured_assertions.go @@ -2,84 +2,11 @@ package testutils import ( - "encoding/json" - "fmt" - "reflect" "regexp" - "strings" - "testing" - - corev1 "k8s.io/api/core/v1" - "k8s.io/client-go/util/jsonpath" dt "github.com/crossplane/cli/v2/internal/diff/result" ) -// StructuredDiffOutput mirrors the renderer's xr document to avoid import cycles: v1's -// renderer.StructuredDiffOutput, and v2's structuredDiffOutputV2, which is the same document without -// changes (Changes is then nil). These types are used only for test assertions. -type StructuredDiffOutput struct { - OutputVersion string `json:"outputVersion"` - Summary Summary `json:"summary"` - Changes []ChangeDetail `json:"changes"` - Errors []OutputError `json:"errors,omitempty"` - Warnings []OutputWarning `json:"warnings,omitempty"` - Xrs []XRDiffWire `json:"xrs"` -} - -// OutputWarning mirrors result.OutputWarning: a non-fatal advisory carried in structured -// output alongside (but distinct from) errors. -type OutputWarning struct { - Message string `json:"message"` - Context map[string]string `json:"context,omitempty"` -} - -// XREntryWire mirrors the renderer's v2 per-XR entry (xrEntryWire), which both xr's xrs[] and comp's -// v2 impactAnalysis[] hold. XR is a corev1.ObjectReference nested under "xr", matching the renderer — -// asserting into the same platform type here also exercises that the "xr" object round-trips into an -// ObjectReference. -type XREntryWire struct { - XR corev1.ObjectReference `json:"xr"` - Status string `json:"status"` - FilterReason string `json:"filterReason,omitempty"` - FilterDetail string `json:"filterDetail,omitempty"` - Summary Summary `json:"summary"` - Changes []ChangeDetail `json:"changes"` - Errors []OutputError `json:"errors,omitempty"` -} - -// XRDiffWire mirrors the renderer's v1 xrs[] entry (xrDiffWire). Its fields are a subset of the v2 -// entry's, the rest of which xr never sets, so one mirror serves both versions. -type XRDiffWire = XREntryWire - -// Summary mirrors renderer.Summary. -type Summary struct { - Added int `json:"added"` - Modified int `json:"modified"` - Removed int `json:"removed"` -} - -// ChangeDetail mirrors renderer.ChangeDetail. -type ChangeDetail struct { - Type string `json:"type"` - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Name string `json:"name"` - Namespace string `json:"namespace,omitempty"` - Diff map[string]any `json:"diff"` - DryRun *DryRunInfo `json:"dryRun,omitempty"` -} - -// DryRunInfo mirrors dt.DryRunInfo. Absent (nil) means the resource's desired -// state was verified against the apiserver; see WithDryRunSkipped / -// WithDryRunPerformed. -type DryRunInfo struct { - Performed bool `json:"performed"` - SkipReason string `json:"skipReason,omitempty"` - Detail string `json:"detail,omitempty"` - StorageVersion string `json:"storageVersion,omitempty"` -} - // DiffExpectation is an interface that allows AssertStructuredDiff to accept // either *ExpectedDiff or *ResourceExpectation, eliminating the need for And(). type DiffExpectation interface { @@ -95,8 +22,6 @@ type ExpectedDiff struct { // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". noWarnings bool xrs []*XRExpectation - // outputVersion, when set, asserts the document's outputVersion and the shape that implies. - outputVersion string } // warningMatch describes one expected entry in warnings[], shared by the xr and comp builders. Matched @@ -550,14 +475,6 @@ func (x *XRExpectation) xrExpectation() *XRExpectation { return x } func (r *ResourceExpectation) xrExpectation() *XRExpectation { return r.xrParent } -// WithOutputVersion asserts the document's outputVersion ("v1" or "v2") and its shape: v1 carries the -// flat changes[], v2 does not. Under v2, assert changes per XR with WithXRs; the top-level resource -// expectations (WithAddedResource and friends) read the flat view and so are rejected. -func (e *ExpectedDiff) WithOutputVersion(version string) *ExpectedDiff { - e.outputVersion = version - return e -} - // WithXRs attaches per-input-XR expectations (built via XR(...)) to assert // against the xrs[] grouped view, in order. func (e *ExpectedDiff) WithXRs(xrs ...xrSpec) *ExpectedDiff { @@ -624,838 +541,6 @@ func (x *XRExpectation) Change(changeType, kind, name, namespace string) *Resour return r } -// ParseStructuredOutput parses JSON output into StructuredDiffOutput. -func ParseStructuredOutput(jsonOutput string) (StructuredDiffOutput, error) { - var output StructuredDiffOutput - if err := json.Unmarshal([]byte(jsonOutput), &output); err != nil { - return output, fmt.Errorf("failed to parse JSON output: %w", err) - } - - return output, nil -} - -// AssertStructuredDiff compares actual JSON output against expected. -// Accepts DiffExpectation interface so callers don't need to call And() to return to root. -func AssertStructuredDiff(t *testing.T, jsonOutput string, e DiffExpectation) { - t.Helper() - - expected := e.expectation() - - output, err := ParseStructuredOutput(jsonOutput) - if err != nil { - t.Fatalf("Failed to parse structured output: %v\nOutput was:\n%s", err, jsonOutput) - } - - // Check summary if specified - if expected.summary != nil { - if output.Summary.Added != expected.summary.added { - t.Errorf("Summary.Added: expected %d, got %d", expected.summary.added, output.Summary.Added) - } - - if output.Summary.Modified != expected.summary.modified { - t.Errorf("Summary.Modified: expected %d, got %d", expected.summary.modified, output.Summary.Modified) - } - - if output.Summary.Removed != expected.summary.removed { - t.Errorf("Summary.Removed: expected %d, got %d", expected.summary.removed, output.Summary.Removed) - } - } - - assertXROutputVersion(t, output, expected) - - // Check each resource expectation against the flat changes[] view. - assertResourceExpectations(t, "", output.Changes, expected.resources) - - // Check for unexpected resources - if len(expected.resources) > 0 && len(output.Changes) != len(expected.resources) { - t.Errorf("Expected %d changes, got %d", len(expected.resources), len(output.Changes)) - } - - // Check each error expectation against output.Errors. - for _, want := range expected.errors { - got := findMatchingError(output.Errors, want.resourceID) - if got == nil { - actualIDs := make([]string, 0, len(output.Errors)) - for _, e := range output.Errors { - actualIDs = append(actualIDs, e.ResourceID) - } - - t.Errorf("Expected error for resourceID %q not found in output. Actual error IDs: %v", - want.resourceID, actualIDs) - - continue - } - - assertErrorMatch(t, *got, want) - } - - // If the test pinned a specific number of error expectations, verify - // no extras were emitted. Zero expectations = no opinion on errors. - if len(expected.errors) > 0 && len(output.Errors) != len(expected.errors) { - t.Errorf("Expected %d errors in structured output, got %d", len(expected.errors), len(output.Errors)) - } - - wantWarnings := make([]warningMatch, 0, len(expected.warnings)) - for _, w := range expected.warnings { - wantWarnings = append(wantWarnings, w.warningMatch) - } - - assertWarnings(t, output.Warnings, wantWarnings, expected.noWarnings) - - // Check each xrs[] entry expectation. - assertXRExpectations(t, output.Xrs, expected.xrs) -} - -// assertXROutputVersion checks the xr document's outputVersion and the presence of the flat changes[] -// it implies, when the test pinned a version. -func assertXROutputVersion(t *testing.T, output StructuredDiffOutput, expected *ExpectedDiff) { - t.Helper() - - if expected.outputVersion == "" { - return - } - - if output.OutputVersion != expected.outputVersion { - t.Errorf("outputVersion: expected %q, got %q", expected.outputVersion, output.OutputVersion) - } - - switch expected.outputVersion { - case "v2": - if output.Changes != nil { - t.Errorf("v2 output carries the v1 flat changes[]: %d entries", len(output.Changes)) - } - - if len(expected.resources) > 0 { - t.Errorf("v2 output has no flat changes[]: assert changes per XR with WithXRs instead") - } - default: - if output.Changes == nil { - t.Errorf("v1 output lacks the flat changes[]") - } - } -} - -// assertWarnings checks each expectation against got, then — if the test pinned any expectations, or -// asserted there are none — verifies no extras were emitted, mirroring the errors[] guard. Without the -// count check, warning duplication is untestable: a regression that emits the same advisory once per -// XR still satisfies every find-first-match expectation. No expectations and no noWarnings = no opinion. -func assertWarnings(t *testing.T, got []OutputWarning, want []warningMatch, noWarnings bool) { - t.Helper() - - for _, w := range want { - assertWarningExpectation(t, got, w) - } - - if (len(want) > 0 || noWarnings) && len(got) != len(want) { - messages := make([]string, 0, len(got)) - for _, w := range got { - messages = append(messages, w.Message) - } - - t.Errorf("Expected %d warnings in structured output, got %d: %v", len(want), len(got), messages) - } -} - -// assertWarningExpectation finds a warning whose message contains want's substring and, when the -// expectation pins one, whose context map is exactly want's. -// -// Both conditions select the warning; context is not merely checked against the first message match. -// Several warnings legitimately share a message and differ only in context (one degraded-dry-run -// warning per GVK + namespace + cause, say), and an expectation for any one of them has to be able to -// find it rather than being compared against whichever happened to be raised first. -func assertWarningExpectation(t *testing.T, got []OutputWarning, want warningMatch) { - t.Helper() - - var contextsSeen []map[string]string - - for _, w := range got { - if !strings.Contains(w.Message, want.messageContains) { - continue - } - - if want.context == nil || reflect.DeepEqual(want.context, w.Context) { - return - } - - contextsSeen = append(contextsSeen, w.Context) - } - - if len(contextsSeen) > 0 { - t.Errorf("warning %q context mismatch: expected %v, got only %v", - want.messageContains, want.context, contextsSeen) - - return - } - - messages := make([]string, 0, len(got)) - for _, w := range got { - messages = append(messages, w.Message) - } - - t.Errorf("expected a warning containing %q in structured output; got %v", want.messageContains, messages) -} - -// assertResourceExpectations validates a set of ResourceExpectations against a -// changes[] slice. scope is a label ("" for the flat view, or an XR name) used -// only in failure messages. Extracted so both the flat changes[] and each -// xrs[] entry's changes[] can be validated with identical logic. -func assertResourceExpectations(t *testing.T, scope string, changes []ChangeDetail, resources []*ResourceExpectation) { - t.Helper() - - prefix := "" - if scope != "" { - prefix = "xr " + scope + ": " - } - - for _, expectRes := range resources { - found := findMatchingChange(changes, expectRes) - if found == nil { - actualResources := make([]string, 0, len(changes)) - for _, c := range changes { - actualResources = append(actualResources, fmt.Sprintf("%s %s/%s (ns=%s)", c.Type, c.Kind, c.Name, c.Namespace)) - } - - t.Errorf("%sExpected %s resource %s/%s (ns=%s) not found. Actual resources: %v", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, expectRes.namespace, actualResources) - - continue - } - - assertChangeFields(t, prefix, found, expectRes) - } -} - -// assertDryRun validates a change's dryRun expectations: either that the -// desired state was reported as NOT verified against the apiserver (with a -// specific reason, and optionally that the cluster's own explanation survived), -// or that no such report is present at all. -// id identifies the change under assertion in failure messages. -func assertDryRun(t *testing.T, id string, found *ChangeDetail, expect dryRunExpectation) { - t.Helper() - - if expect.mustBePerformed { - if found.DryRun != nil { - t.Errorf("%s: expected the desired state to have been dry-run against the apiserver, but it reports skipReason %q (detail: %q)", - id, found.DryRun.SkipReason, found.DryRun.Detail) - } - - return - } - - if expect.skipReason == "" { - return - } - - if found.DryRun == nil { - t.Errorf("%s: expected dryRun.skipReason %q, but no dryRun was reported (absence means the desired state went through the apiserver)", - id, expect.skipReason) - - return - } - - if found.DryRun.SkipReason != expect.skipReason { - t.Errorf("%s: dryRun.skipReason: expected %q, got %q", - id, expect.skipReason, found.DryRun.SkipReason) - } - - // Performed must be false whenever the object is present; see dt.DryRunInfo. - if found.DryRun.Performed { - t.Errorf("%s: dryRun.performed was true on a reported skip, which is contradictory", id) - } - - if expect.detailSubstring != "" && !strings.Contains(found.DryRun.Detail, expect.detailSubstring) { - t.Errorf("%s: dryRun.detail %q does not contain %q — the cluster's own explanation must be propagated, not swallowed", - id, found.DryRun.Detail, expect.detailSubstring) - } - - if expect.storageVersion != "" && found.DryRun.StorageVersion != expect.storageVersion { - t.Errorf("%s: dryRun.storageVersion: expected %q, got %q", - id, expect.storageVersion, found.DryRun.StorageVersion) - } -} - -// assertChangeFields validates the field-level expectations (exact values, -// old/new changes, value patterns, and strict spec match) of a single matched -// change. Split out of assertResourceExpectations to keep that function's -// cognitive complexity in check. -func assertChangeFields(t *testing.T, prefix string, found *ChangeDetail, expectRes *ResourceExpectation) { - t.Helper() - - assertDryRun(t, - fmt.Sprintf("%s%s %s/%s", prefix, expectRes.changeType, expectRes.kind, expectRes.name), - found, expectRes.dryRun) - - // Validate field values for added/removed resources - for path, expectedValue := range expectRes.fieldValues { - actualValue := getFieldFromDiff(found.Diff, expectRes.changeType, path) - if !reflect.DeepEqual(actualValue, expectedValue) { - t.Errorf("%s%s %s/%s: field %s: expected %v, got %v", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, expectedValue, actualValue) - } - } - - // Validate field changes for modified resources - for path, change := range expectRes.fieldChanges { - oldVal := getFieldFromDiff(found.Diff, dt.DiffKeyOld, path) - newVal := getFieldFromDiff(found.Diff, dt.DiffKeyNew, path) - - if !reflect.DeepEqual(oldVal, change[0]) { - t.Errorf("%s%s %s/%s: field %s old value: expected %v, got %v", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, change[0], oldVal) - } - - if !reflect.DeepEqual(newVal, change[1]) { - t.Errorf("%s%s %s/%s: field %s new value: expected %v, got %v", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, change[1], newVal) - } - } - - // Validate field value patterns - for path, pattern := range expectRes.fieldValuePatterns { - actualValue := getNewFieldValue(found.Diff, expectRes.changeType, path) - - actualStr := fmt.Sprintf("%v", actualValue) - if !pattern.MatchString(actualStr) { - t.Errorf("%s%s %s/%s: field %s value %q does not match pattern %q", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, path, actualStr, pattern.String()) - } - } - - // Validate spec match if specified - if expectRes.specMatch != nil { - spec := getFieldFromDiff(found.Diff, expectRes.changeType, "spec") - if specMap, ok := spec.(map[string]any); ok { - if !reflect.DeepEqual(specMap, expectRes.specMatch) { - t.Errorf("%s%s %s/%s: spec mismatch: expected %v, got %v", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, expectRes.specMatch, specMap) - } - } else { - t.Errorf("%s%s %s/%s: spec is not a map: %v", - prefix, expectRes.changeType, expectRes.kind, expectRes.name, spec) - } - } -} - -// assertXRExpectations validates xrs[] entry expectations against the actual -// grouped output. -// -// Each expectation consumes the entry it matches, so two expectations can never -// both resolve to the same entry, and the entry count is pinned once the test -// has an opinion on xrs[] at all. Without those guards a duplicated identity — -// exactly the symptom of issue #477 — satisfied every expectation that matched -// it and went unnoticed (issue #488). -func assertXRExpectations(t *testing.T, actual []XRDiffWire, expected []*XRExpectation) { - t.Helper() - - if len(expected) == 0 { - return - } - - if len(actual) != len(expected) { - actualXRs := make([]string, 0, len(actual)) - for _, x := range actual { - actualXRs = append(actualXRs, fmt.Sprintf("%s/%s (ns=%s)", x.XR.Kind, x.XR.Name, x.XR.Namespace)) - } - - t.Errorf("Expected %d xrs[] entries, got %d: %v", len(expected), len(actual), actualXRs) - } - - used := make([]bool, len(actual)) - - for _, want := range expected { - got := findMatchingXR(actual, used, want) - if got == nil { - actualXRs := make([]string, 0, len(actual)) - for _, x := range actual { - actualXRs = append(actualXRs, fmt.Sprintf("%s/%s (ns=%s, status=%s)", x.XR.Kind, x.XR.Name, x.XR.Namespace, x.Status)) - } - - t.Errorf("Expected xrs[] entry %s/%s (ns=%s) not found. Actual: %v", - want.kind, want.name, want.namespace, actualXRs) - - continue - } - - label := fmt.Sprintf("%s/%s", got.XR.Kind, got.XR.Name) - - if want.status != "" && got.Status != want.status { - t.Errorf("xr %s: status: expected %q, got %q", label, want.status, got.Status) - } - - if want.summary != nil { - if got.Summary.Added != want.summary.added || got.Summary.Modified != want.summary.modified || got.Summary.Removed != want.summary.removed { - t.Errorf("xr %s: summary: expected {added:%d modified:%d removed:%d}, got {added:%d modified:%d removed:%d}", - label, want.summary.added, want.summary.modified, want.summary.removed, - got.Summary.Added, got.Summary.Modified, got.Summary.Removed) - } - } - - assertResourceExpectations(t, label, got.Changes, want.changes) - - // Pin the entry's change count too, so a change the test did not ask - // for cannot hide inside a matched entry. - if len(want.changes) > 0 && len(got.Changes) != len(want.changes) { - t.Errorf("xr %s: expected %d changes, got %d", label, len(want.changes), len(got.Changes)) - } - - for _, id := range want.errorIDs { - if findMatchingError(got.Errors, id) == nil { - t.Errorf("xr %s: expected error with resourceID %q not found in entry errors", label, id) - } - } - } -} - -// findMatchingXR locates the first not-yet-consumed xrs[] entry matching an -// XRExpectation by kind/namespace and (unless anyName / a name pattern is used) -// exact name, marking it consumed in used. Consuming matters because identities -// need not be unique: without it, N expectations could all be satisfied by one -// entry (or by N byte-identical ones). -func findMatchingXR(actual []XRDiffWire, used []bool, want *XRExpectation) *XRDiffWire { - for i := range actual { - if used[i] { - continue - } - - x := &actual[i] - - if x.XR.Kind != want.kind || x.XR.Namespace != want.namespace { - continue - } - - switch { - case want.anyName: - case want.namePattern != nil: - if !want.namePattern.MatchString(x.XR.Name) { - continue - } - case x.XR.Name != want.name: - continue - } - - used[i] = true - - return x - } - - return nil -} - -// assertErrorMatch validates a single OutputError against its -// expectation. Split out from AssertStructuredDiff so the per-error -// branching doesn't push that function further over the gocognit -// threshold its existing exemption already silences. -func assertErrorMatch(t *testing.T, got OutputError, want *ErrorExpectation) { - t.Helper() - - if want.messageContains != "" && !strings.Contains(got.Message, want.messageContains) { - t.Errorf("error %q: message %q does not contain %q", - want.resourceID, got.Message, want.messageContains) - } - - for _, wantVF := range want.validationFailures { - gotVF := findMatchingValidationFailure(got.ValidationFailures, wantVF) - if gotVF == nil { - actual := make([]string, 0, len(got.ValidationFailures)) - for _, vf := range got.ValidationFailures { - actual = append(actual, fmt.Sprintf("%s/%s %s/%s", vf.APIVersion, vf.Kind, vf.Namespace, vf.Name)) - } - - t.Errorf("error %q: expected validationFailure %s/%s %s/%s not found. Actual: %v", - want.resourceID, wantVF.apiVersion, wantVF.kind, wantVF.namespace, wantVF.name, actual) - - continue - } - - assertValidationFailureMatch(t, *gotVF, wantVF) - } - - if len(want.validationFailures) > 0 && len(got.ValidationFailures) != len(want.validationFailures) { - t.Errorf("error %q: expected %d validationFailures, got %d", - want.resourceID, len(want.validationFailures), len(got.ValidationFailures)) - } -} - -// assertValidationFailureMatch validates a single -// ResourceValidationFailure against its expectation. -func assertValidationFailureMatch(t *testing.T, got ResourceValidationFailure, want *ValidationFailureExpectation) { - t.Helper() - - if want.status != "" && got.Status != want.status { - t.Errorf("validationFailure %s/%s %s: status: expected %q, got %q", - want.apiVersion, want.kind, want.name, want.status, got.Status) - } - - for _, wantFE := range want.errors { - gotFE := findMatchingFieldError(got.Errors, wantFE) - if gotFE == nil { - actual := make([]string, 0, len(got.Errors)) - for _, fe := range got.Errors { - actual = append(actual, fmt.Sprintf("[%s] %s", fe.Type, fe.Field)) - } - - t.Errorf("validationFailure %s/%s %s: expected fieldError [%s] %s not found. Actual: %v", - want.apiVersion, want.kind, want.name, wantFE.errType, wantFE.field, actual) - - continue - } - - if wantFE.messageContains != "" && !strings.Contains(gotFE.Message, wantFE.messageContains) { - t.Errorf("validationFailure %s/%s %s: fieldError [%s] %s: message %q does not contain %q", - want.apiVersion, want.kind, want.name, wantFE.errType, wantFE.field, - gotFE.Message, wantFE.messageContains) - } - - if wantFE.hasValue && !reflect.DeepEqual(gotFE.Value, wantFE.value) { - t.Errorf("validationFailure %s/%s %s: fieldError [%s] %s: value: expected %v (%T), got %v (%T)", - want.apiVersion, want.kind, want.name, wantFE.errType, wantFE.field, - wantFE.value, wantFE.value, gotFE.Value, gotFE.Value) - } - } - - if len(want.errors) > 0 && len(got.Errors) != len(want.errors) { - t.Errorf("validationFailure %s/%s %s: expected %d field errors, got %d", - want.apiVersion, want.kind, want.name, len(want.errors), len(got.Errors)) - } -} - -func findMatchingError(errs []OutputError, resourceID string) *OutputError { - for i := range errs { - if errs[i].ResourceID == resourceID { - return &errs[i] - } - } - - return nil -} - -func findMatchingValidationFailure(vfs []ResourceValidationFailure, want *ValidationFailureExpectation) *ResourceValidationFailure { - for i := range vfs { - v := &vfs[i] - if v.APIVersion != want.apiVersion || v.Kind != want.kind || v.Name != want.name { - continue - } - - if want.namespace != "" && v.Namespace != want.namespace { - continue - } - - return v - } - - return nil -} - -func findMatchingFieldError(fes []FieldValidationError, want *FieldErrorExpectation) *FieldValidationError { - for i := range fes { - fe := &fes[i] - if fe.Type == want.errType && fe.Field == want.field { - return fe - } - } - - return nil -} - -// findMatchingChange finds a change that matches the resource expectation. -func findMatchingChange(changes []ChangeDetail, expect *ResourceExpectation) *ChangeDetail { - for i := range changes { - change := &changes[i] - if change.Type != expect.changeType { - continue - } - - if change.Kind != expect.kind { - continue - } - - if expect.namespace != "" && change.Namespace != expect.namespace { - continue - } - - // Check name matching - if expect.anyNameAllowed { - return change - } - - if expect.namePattern != nil { - if expect.namePattern.MatchString(change.Name) { - return change - } - - continue - } - - if change.Name == expect.name { - return change - } - } - - return nil -} - -// getFieldFromDiff extracts a field value from the diff structure. -// For added/removed, key is "spec"; for modified, key is "old" or "new". -func getFieldFromDiff(diff map[string]any, key, path string) any { - // Handle special keys for modified resources - var root any - - switch key { - case dt.DiffKeyOld, dt.DiffKeyNew: - root = diff[key] - case dt.DiffTypeWordAdded, dt.DiffTypeWordRemoved: - root = diff[dt.DiffKeySpec] - default: - root = diff[key] - } - - if root == nil { - return nil - } - - return getNestedField(root, path) -} - -// getNewFieldValue returns the "new" field value for assertions. -// For modified resources, it returns the value from the "new" state. -// For added/removed resources, it returns the value from the "spec". -func getNewFieldValue(diff map[string]any, changeType, path string) any { - if changeType == dt.DiffTypeWordModified { - return getFieldFromDiff(diff, dt.DiffKeyNew, path) - } - - return getFieldFromDiff(diff, changeType, path) -} - -// getNestedField extracts a nested field using k8s jsonpath. -// Examples: -// - "spec.forProvider.configData" - simple path -// - "spec.pipeline[0].functionRef.name" - array index -// - "spec.pipeline[1].input.resources[0].patches[0].transforms" - multiple array indices -func getNestedField(obj any, path string) any { - if path == "" { - return obj - } - - // Convert bracket notation to k8s jsonpath escaped format - // "metadata.annotations['key.with.dots']" -> "metadata.annotations.key\.with\.dots" - path = convertBracketNotation(path) - - // Wrap path in k8s jsonpath template syntax: "spec.foo[0]" -> "{.spec.foo[0]}" - jp := jsonpath.New("field") - jp.AllowMissingKeys(true) - - if err := jp.Parse("{." + path + "}"); err != nil { - return nil - } - - results, err := jp.FindResults(obj) - if err != nil || len(results) == 0 || len(results[0]) == 0 { - return nil - } - - return results[0][0].Interface() -} - -// convertBracketNotation converts bracket notation for map keys to k8s jsonpath escaped format. -// Examples: -// - "metadata.annotations['key.with.dots']" -> "metadata.annotations.key\.with\.dots" -// - "spec.data['my-key']" -> "spec.data.my-key" -// - "spec.pipeline[0].name" -> "spec.pipeline[0].name" (array indices unchanged) -func convertBracketNotation(path string) string { - // Pattern matches: ['key'] or ["key"] where key can contain dots - bracketPattern := regexp.MustCompile(`\['([^']+)'\]|\["([^"]+)"\]`) - - return bracketPattern.ReplaceAllStringFunc(path, func(match string) string { - // Extract the key from ['key'] or ["key"] - key := match[2 : len(match)-2] // Remove [' and '] or [" and "] - - // Escape dots in the key for k8s jsonpath - escapedKey := strings.ReplaceAll(key, ".", `\.`) - - // Return as dot notation with escaped dots - return "." + escapedKey - }) -} - -// --- Composition Diff Types and Assertions --- -// -// Note: We use reflect.DeepEqual for value comparisons. This works because all -// field values in our test expectations are strings. If we ever need to compare -// numeric values (where JSON decodes everything as float64), we can switch to -// YAML output format which preserves int vs float64 distinction. - -// StructuredCompDiffOutput mirrors the JSON schema for composition diffs. -type StructuredCompDiffOutput struct { - OutputVersion string `json:"outputVersion"` - Compositions []CompositionDiffWire `json:"compositions"` - Errors []OutputError `json:"errors,omitempty"` - Warnings []OutputWarning `json:"warnings,omitempty"` -} - -// OutputError mirrors dt.OutputError. -type OutputError struct { - ResourceID string `json:"resourceID,omitempty"` - Message string `json:"message"` - ValidationFailures []ResourceValidationFailure `json:"validationFailures,omitempty"` -} - -// ResourceValidationFailure mirrors dt.ResourceValidationFailure. -type ResourceValidationFailure struct { - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Name string `json:"name,omitempty"` - Namespace string `json:"namespace,omitempty"` - Status string `json:"status"` - Errors []FieldValidationError `json:"errors,omitempty"` -} - -// FieldValidationError mirrors dt.FieldValidationError. -type FieldValidationError struct { - Type string `json:"type"` - Field string `json:"field,omitempty"` - Message string `json:"message"` - Value any `json:"value,omitempty"` -} - -// CompositionDiffWire mirrors compositionDiffWire from the renderer. -type CompositionDiffWire struct { - Name string `json:"name"` - Error string `json:"error,omitempty"` // v1 only - Errors []OutputError `json:"errors,omitempty"` // v2 only - CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` - AffectedResources AffectedResourcesSummary `json:"affectedResources"` - ImpactAnalysis []XRImpactWire `json:"impactAnalysis"` - // ImpactAnalysisSkipped distinguishes "not evaluated" from "no affected composites found". - ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` - // MaskedChangesOnly records that an absent compositionChanges does not mean unchanged. - MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` - // RevisionImpact mirrors result.RevisionImpact. - RevisionImpact RevisionImpactWire `json:"revisionImpact"` -} - -// RevisionImpactWire mirrors result.RevisionImpact. -type RevisionImpactWire struct { - ChangeScope string `json:"changeScope"` - CreatesRevision bool `json:"createsRevision"` - RepointedComposites int `json:"repointedComposites"` - PredictedRevisionName string `json:"predictedRevisionName,omitempty"` -} - -// AffectedResourcesSummary mirrors result.AffectedResourcesSummary. -type AffectedResourcesSummary struct { - Total int `json:"total"` - WithChanges int `json:"withChanges"` - Unchanged int `json:"unchanged"` - WithErrors int `json:"withErrors"` - FilteredByPolicy int `json:"filteredByPolicy,omitempty"` - FilteredBySelector int `json:"filteredBySelector,omitempty"` - FilteredByDeletion int `json:"filteredByDeletion,omitempty"` -} - -// XRImpactWire mirrors an impactAnalysis[] entry of either version: v1's xrImpactWire (identity -// inlined, error string, downstreamChanges wrapper) or v2's xrEntryWire (the fields of XREntryWire). -// The two share only status, filterReason and filterDetail, so a v2 entry leaves the v1-only fields -// zero and vice versa; assertXRImpactShape checks that. Read it through ref, downstream and -// errorText, which work for both. -type XRImpactWire struct { - // v1 only. - APIVersion string `json:"apiVersion,omitempty"` - Kind string `json:"kind,omitempty"` - Name string `json:"name,omitempty"` - Namespace string `json:"namespace,omitempty"` - UID string `json:"uid,omitempty"` - Error string `json:"error,omitempty"` - DownstreamChanges *DownstreamChanges `json:"downstreamChanges,omitempty"` - - // Both versions. - Status string `json:"status"` - FilterReason string `json:"filterReason,omitempty"` - FilterDetail string `json:"filterDetail,omitempty"` - - // v2 only. Pointers and nil slices so their absence from a v1 entry is detectable. - XR *corev1.ObjectReference `json:"xr,omitempty"` - Summary *Summary `json:"summary,omitempty"` - Changes []ChangeDetail `json:"changes,omitempty"` - Errors []OutputError `json:"errors,omitempty"` -} - -// ref returns the entry's XR identity. -func (x *XRImpactWire) ref() corev1.ObjectReference { - if x.XR != nil { - return *x.XR - } - - return corev1.ObjectReference{APIVersion: x.APIVersion, Kind: x.Kind, Name: x.Name, Namespace: x.Namespace} -} - -// downstream returns the entry's changes, or nil when it lists none, matching v1's absent -// downstreamChanges. -func (x *XRImpactWire) downstream() *DownstreamChanges { - switch { - case x.DownstreamChanges != nil: - return x.DownstreamChanges - case len(x.Changes) > 0 && x.Summary != nil: - return &DownstreamChanges{Summary: *x.Summary, Changes: x.Changes} - default: - return nil - } -} - -// errorText returns the entry's error message: v1's error string, or v2's errors[] messages joined. -func (x *XRImpactWire) errorText() string { - if x.Error != "" { - return x.Error - } - - msgs := make([]string, 0, len(x.Errors)) - for _, e := range x.Errors { - msgs = append(msgs, e.Message) - } - - return strings.Join(msgs, "; ") -} - -// assertXRImpactShape checks an impactAnalysis[] entry carries exactly its version's fields. -func assertXRImpactShape(t *testing.T, label, version string, x *XRImpactWire) { - t.Helper() - - v1Fields := x.Kind != "" || x.APIVersion != "" || x.Name != "" || x.Error != "" || x.DownstreamChanges != nil - v2Fields := x.XR != nil || x.Summary != nil || x.Changes != nil || x.Errors != nil - - switch version { - case "v2": - if v1Fields { - t.Errorf("%s: v2 entry carries v1 fields: %+v", label, *x) - } - - // summary and changes are always present in v2; changes is [] when there are none. - if x.XR == nil || x.Summary == nil || x.Changes == nil { - t.Errorf("%s: v2 entry lacks xr, summary or changes: %+v", label, *x) - } - default: - if v2Fields { - t.Errorf("%s: v1 entry carries v2 fields: %+v", label, *x) - } - } -} - -// DownstreamChanges mirrors renderer.DownstreamChanges. -type DownstreamChanges struct { - Summary Summary `json:"summary"` - Changes []ChangeDetail `json:"changes"` -} - -// ParseStructuredCompOutput parses JSON output into StructuredCompDiffOutput. -func ParseStructuredCompOutput(jsonOutput string) (StructuredCompDiffOutput, error) { - var output StructuredCompDiffOutput - if err := json.Unmarshal([]byte(jsonOutput), &output); err != nil { - return output, fmt.Errorf("failed to parse comp diff JSON output: %w", err) - } - - return output, nil -} - // CompDiffExpectation is an interface that allows AssertStructuredCompDiff to accept // any level of the builder hierarchy, eliminating the need for And()/AndXR()/AndComp(). type CompDiffExpectation interface { @@ -1468,9 +553,6 @@ type ExpectedCompDiff struct { warnings []*CompWarningExpectation // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". noWarnings bool - // outputVersion, when set, asserts the document's outputVersion and that every composition and - // impactAnalysis[] entry has that version's shape. - outputVersion string // errorIDs are resourceIDs the top-level errors[] must contain. errorIDs []string } @@ -1498,13 +580,6 @@ func (e *ExpectedCompDiff) WithWarning(messageContains string) *CompWarningExpec return w } -// WithOutputVersion asserts the document's outputVersion ("v1" or "v2"), and that each composition and -// impactAnalysis[] entry carries exactly that version's fields. -func (e *ExpectedCompDiff) WithOutputVersion(version string) *ExpectedCompDiff { - e.outputVersion = version - return e -} - // WithError asserts the top-level errors[] contains an error with the given resourceID. func (e *ExpectedCompDiff) WithError(resourceID string) *ExpectedCompDiff { e.errorIDs = append(e.errorIDs, resourceID) @@ -1887,421 +962,3 @@ func (x *XRImpactExpectation) AndComposition(name string) *CompositionExpectatio func (c *CompositionExpectation) AndComposition(name string) *CompositionExpectation { return c.compExpectation().WithComposition(name) } - -// AssertStructuredCompDiff compares actual JSON output against expected. -// Accepts CompDiffExpectation interface so callers don't need to call And()/AndXR()/AndComp(). -// -//nolint:gocognit // Test assertion function with necessary branching for comprehensive validation -func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e CompDiffExpectation) { - t.Helper() - - expected := e.compExpectation() - - output, err := ParseStructuredCompOutput(jsonOutput) - if err != nil { - t.Fatalf("Failed to parse structured comp output: %v\nOutput was:\n%s", err, jsonOutput) - } - - wantWarnings := make([]warningMatch, 0, len(expected.warnings)) - for _, w := range expected.warnings { - wantWarnings = append(wantWarnings, w.warningMatch) - } - - assertWarnings(t, output.Warnings, wantWarnings, expected.noWarnings) - - assertCompOutputVersion(t, output, expected) - - for _, id := range expected.errorIDs { - if findMatchingError(output.Errors, id) == nil { - t.Errorf("Expected top-level error with resourceID %q not found", id) - } - } - - // Check each composition expectation - for _, expectComp := range expected.compositions { - found := findMatchingComposition(output.Compositions, expectComp.name) - if found == nil { - actualComps := make([]string, 0, len(output.Compositions)) - for _, c := range output.Compositions { - actualComps = append(actualComps, c.Name) - } - - t.Errorf("Expected composition %s not found in output. Actual compositions: %v", - expectComp.name, actualComps) - - continue - } - - if expectComp.errorContains != "" { - var text strings.Builder - text.WriteString(found.Error) - for _, e := range found.Errors { - text.WriteString(e.Message) - } - - if !strings.Contains(text.String(), expectComp.errorContains) { - t.Errorf("Composition %s: expected error containing %q, got %q", expectComp.name, expectComp.errorContains, text.String()) - } - } - - // Check affected resources counts - if expectComp.affectedTotal != nil && found.AffectedResources.Total != *expectComp.affectedTotal { - t.Errorf("Composition %s: AffectedResources.Total: expected %d, got %d", - expectComp.name, *expectComp.affectedTotal, found.AffectedResources.Total) - } - - if expectComp.affectedWithChanges != nil && found.AffectedResources.WithChanges != *expectComp.affectedWithChanges { - t.Errorf("Composition %s: AffectedResources.WithChanges: expected %d, got %d", - expectComp.name, *expectComp.affectedWithChanges, found.AffectedResources.WithChanges) - } - - if expectComp.affectedUnchanged != nil && found.AffectedResources.Unchanged != *expectComp.affectedUnchanged { - t.Errorf("Composition %s: AffectedResources.Unchanged: expected %d, got %d", - expectComp.name, *expectComp.affectedUnchanged, found.AffectedResources.Unchanged) - } - - if expectComp.affectedWithErrors != nil && found.AffectedResources.WithErrors != *expectComp.affectedWithErrors { - t.Errorf("Composition %s: AffectedResources.WithErrors: expected %d, got %d", - expectComp.name, *expectComp.affectedWithErrors, found.AffectedResources.WithErrors) - } - - if expectComp.affectedFilteredByDeletion != nil && found.AffectedResources.FilteredByDeletion != *expectComp.affectedFilteredByDeletion { - t.Errorf("Composition %s: AffectedResources.FilteredByDeletion: expected %d, got %d", - expectComp.name, *expectComp.affectedFilteredByDeletion, found.AffectedResources.FilteredByDeletion) - } - - if expectComp.impactAnalysisSkipped != nil && found.ImpactAnalysisSkipped != *expectComp.impactAnalysisSkipped { - t.Errorf("Composition %s: ImpactAnalysisSkipped: expected %t, got %t", - expectComp.name, *expectComp.impactAnalysisSkipped, found.ImpactAnalysisSkipped) - } - - if want := expectComp.revisionImpact; want != nil { - got := found.RevisionImpact - - if got.ChangeScope != want.changeScope { - t.Errorf("Composition %s: RevisionImpact.ChangeScope: expected %q, got %q", - expectComp.name, want.changeScope, got.ChangeScope) - } - - if got.CreatesRevision != want.createsRevision { - t.Errorf("Composition %s: RevisionImpact.CreatesRevision: expected %t, got %t", - expectComp.name, want.createsRevision, got.CreatesRevision) - } - - if got.RepointedComposites != want.repointedComposites { - t.Errorf("Composition %s: RevisionImpact.RepointedComposites: expected %d, got %d", - expectComp.name, want.repointedComposites, got.RepointedComposites) - } - } - - if want := expectComp.predictedRevisionName; want != nil { - got := found.RevisionImpact.PredictedRevisionName - - switch want.pattern { - case "": - if got != "" { - t.Errorf("Composition %s: RevisionImpact.PredictedRevisionName: expected absent, got %q", - expectComp.name, got) - } - default: - matched, err := regexp.MatchString(want.pattern, got) - - switch { - case err != nil: - t.Errorf("Composition %s: RevisionImpact.PredictedRevisionName: invalid pattern %q: %v", - expectComp.name, want.pattern, err) - case !matched: - t.Errorf("Composition %s: RevisionImpact.PredictedRevisionName: %q does not match pattern %q", - expectComp.name, got, want.pattern) - } - } - } - - // Check composition changes - if expectComp.compositionChangeType != "" { - if found.CompositionChanges == nil { - t.Errorf("Composition %s: expected composition changes but none found", expectComp.name) - } else { - if found.CompositionChanges.Type != expectComp.compositionChangeType { - t.Errorf("Composition %s: expected change type %s, got %s", - expectComp.name, expectComp.compositionChangeType, found.CompositionChanges.Type) - } - - // Check composition field changes - for path, expected := range expectComp.compositionFieldChanges { - oldVal := getFieldFromDiff(found.CompositionChanges.Diff, dt.DiffKeyOld, path) - newVal := getFieldFromDiff(found.CompositionChanges.Diff, dt.DiffKeyNew, path) - - if !reflect.DeepEqual(oldVal, expected[0]) { - t.Errorf("Composition %s: field %s old value: expected %v, got %v", - expectComp.name, path, expected[0], oldVal) - } - - if !reflect.DeepEqual(newVal, expected[1]) { - t.Errorf("Composition %s: field %s new value: expected %v, got %v", - expectComp.name, path, expected[1], newVal) - } - } - } - } - - // Check XR impacts - for _, expectXR := range expectComp.xrImpacts { - foundXR := findMatchingXRImpact(found.ImpactAnalysis, expectXR) - if foundXR == nil { - actualXRs := make([]string, 0, len(found.ImpactAnalysis)) - for _, x := range found.ImpactAnalysis { - ref := x.ref() - actualXRs = append(actualXRs, fmt.Sprintf("%s/%s (ns=%s, status=%s)", ref.Kind, ref.Name, ref.Namespace, x.Status)) - } - - t.Errorf("Composition %s: Expected XR impact %s/%s (ns=%s) not found. Actual XRs: %v", - expectComp.name, expectXR.kind, expectXR.name, expectXR.namespace, actualXRs) - - continue - } - - // Check status - if foundXR.Status != expectXR.status { - t.Errorf("Composition %s: XR %s/%s: expected status %s, got %s", - expectComp.name, expectXR.kind, expectXR.name, expectXR.status, foundXR.Status) - } - - // Check filter reason if specified - if expectXR.filterReason != "" && foundXR.FilterReason != expectXR.filterReason { - t.Errorf("Composition %s: XR %s/%s: expected filterReason %s, got %s", - expectComp.name, expectXR.kind, expectXR.name, expectXR.filterReason, foundXR.FilterReason) - } - - // Check error message if specified - if expectXR.errorContains != "" && !strings.Contains(foundXR.errorText(), expectXR.errorContains) { - t.Errorf("Composition %s: XR %s/%s: expected error containing %q, got %q", - expectComp.name, expectXR.kind, expectXR.name, expectXR.errorContains, foundXR.errorText()) - } - - for _, id := range expectXR.errorIDs { - if findMatchingError(foundXR.Errors, id) == nil { - t.Errorf("Composition %s: XR %s/%s: expected entry error with resourceID %q not found", - expectComp.name, expectXR.kind, expectXR.name, id) - } - } - - downstream := foundXR.downstream() - - // Check downstream summary if specified - if expectXR.downstreamSummary != nil && downstream != nil { - if downstream.Summary.Added != expectXR.downstreamSummary.added { - t.Errorf("Composition %s: XR %s/%s: DownstreamChanges.Summary.Added: expected %d, got %d", - expectComp.name, expectXR.kind, expectXR.name, expectXR.downstreamSummary.added, downstream.Summary.Added) - } - - if downstream.Summary.Modified != expectXR.downstreamSummary.modified { - t.Errorf("Composition %s: XR %s/%s: DownstreamChanges.Summary.Modified: expected %d, got %d", - expectComp.name, expectXR.kind, expectXR.name, expectXR.downstreamSummary.modified, downstream.Summary.Modified) - } - - if downstream.Summary.Removed != expectXR.downstreamSummary.removed { - t.Errorf("Composition %s: XR %s/%s: DownstreamChanges.Summary.Removed: expected %d, got %d", - expectComp.name, expectXR.kind, expectXR.name, expectXR.downstreamSummary.removed, downstream.Summary.Removed) - } - } - - // Check downstream resources with field-level assertions - for _, expectRes := range expectXR.downstreamResources { - if downstream == nil { - t.Errorf("Composition %s: XR %s/%s: expected downstream resource %s/%s but no downstream changes present", - expectComp.name, expectXR.kind, expectXR.name, expectRes.kind, expectRes.name) - - continue - } - - foundRes := findMatchingDownstreamChange(downstream.Changes, expectRes) - if foundRes == nil { - actualRes := make([]string, 0, len(downstream.Changes)) - for _, c := range downstream.Changes { - actualRes = append(actualRes, fmt.Sprintf("%s/%s (type=%s)", c.Kind, c.Name, c.Type)) - } - - t.Errorf("Composition %s: XR %s/%s: expected downstream resource %s/%s not found. Actual: %v", - expectComp.name, expectXR.kind, expectXR.name, expectRes.kind, expectRes.name, actualRes) - - continue - } - - // Check change type - if foundRes.Type != expectRes.changeType { - t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: expected type %s, got %s", - expectComp.name, expectXR.kind, expectXR.name, expectRes.kind, expectRes.name, expectRes.changeType, foundRes.Type) - } - - assertDryRun(t, - fmt.Sprintf("Composition %s: XR %s/%s: downstream %s %s/%s", - expectComp.name, expectXR.kind, expectXR.name, expectRes.changeType, expectRes.kind, expectRes.name), - foundRes, expectRes.dryRun) - - // Check field changes (for modified resources) - for path, expected := range expectRes.fieldChanges { - oldVal, newVal := expected[0], expected[1] - assertDownstreamFieldChange(t, expectComp.name, expectXR, expectRes, foundRes, path, oldVal, newVal) - } - - // Check field values (for added/removed resources) - for path, expectedVal := range expectRes.fieldValues { - assertDownstreamFieldValue(t, expectComp.name, expectXR, expectRes, foundRes, path, expectedVal) - } - - // Check field value patterns - for path, pattern := range expectRes.fieldValuePatterns { - assertDownstreamFieldValuePattern(t, expectComp.name, expectXR, expectRes, foundRes, path, pattern) - } - } - } - } -} - -// assertCompOutputVersion checks the comp document's outputVersion, and that each composition and -// impactAnalysis[] entry carries exactly that version's fields, when the test pinned a version. -func assertCompOutputVersion(t *testing.T, output StructuredCompDiffOutput, expected *ExpectedCompDiff) { - t.Helper() - - if expected.outputVersion == "" { - return - } - - if output.OutputVersion != expected.outputVersion { - t.Errorf("outputVersion: expected %q, got %q", expected.outputVersion, output.OutputVersion) - } - - for _, comp := range output.Compositions { - switch { - case expected.outputVersion == "v2" && comp.Error != "": - t.Errorf("Composition %s: v2 output carries the v1 error string", comp.Name) - case expected.outputVersion != "v2" && comp.Errors != nil: - t.Errorf("Composition %s: v1 output carries the v2 errors[]", comp.Name) - } - - for i := range comp.ImpactAnalysis { - assertXRImpactShape(t, fmt.Sprintf("Composition %s: impactAnalysis[%d]", comp.Name, i), expected.outputVersion, &comp.ImpactAnalysis[i]) - } - } -} - -// findMatchingComposition finds a composition by name. -func findMatchingComposition(comps []CompositionDiffWire, name string) *CompositionDiffWire { - for i := range comps { - if comps[i].Name == name { - return &comps[i] - } - } - - return nil -} - -// findMatchingXRImpact finds an XR impact that matches the expectation. -func findMatchingXRImpact(impacts []XRImpactWire, expect *XRImpactExpectation) *XRImpactWire { - for i := range impacts { - impact := &impacts[i] - - ref := impact.ref() - if ref.Kind != expect.kind { - continue - } - - if expect.namespace != "" && ref.Namespace != expect.namespace { - continue - } - - // Check name matching - if expect.anyNameAllowed { - return impact - } - - if ref.Name == expect.name { - return impact - } - } - - return nil -} - -// findMatchingDownstreamChange finds a downstream change that matches the expectation. -func findMatchingDownstreamChange(changes []ChangeDetail, expect *DownstreamResourceExpectation) *ChangeDetail { - for i := range changes { - change := &changes[i] - if change.Type != expect.changeType { - continue - } - - if change.Kind != expect.kind { - continue - } - - if expect.namespace != "" && change.Namespace != expect.namespace { - continue - } - - // Check name matching - if expect.anyNameAllowed { - return change - } - - if expect.namePattern != nil { - if expect.namePattern.MatchString(change.Name) { - return change - } - - continue - } - - if change.Name == expect.name { - return change - } - } - - return nil -} - -// assertDownstreamFieldChange asserts that a field changed from old to new value in a modified downstream resource. -func assertDownstreamFieldChange(t *testing.T, compName string, expectXR *XRImpactExpectation, expectRes *DownstreamResourceExpectation, foundRes *ChangeDetail, path string, expectedOld, expectedNew any) { - t.Helper() - - // For modified resources, the diff structure has "old" and "new" keys - actualOld := getFieldFromDiff(foundRes.Diff, dt.DiffKeyOld, path) - actualNew := getFieldFromDiff(foundRes.Diff, dt.DiffKeyNew, path) - - if !reflect.DeepEqual(actualOld, expectedOld) { - t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s old value: expected %v, got %v", - compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, expectedOld, actualOld) - } - - if !reflect.DeepEqual(actualNew, expectedNew) { - t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s new value: expected %v, got %v", - compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, expectedNew, actualNew) - } -} - -// assertDownstreamFieldValue asserts that a field has the expected value in an added/removed downstream resource. -func assertDownstreamFieldValue(t *testing.T, compName string, expectXR *XRImpactExpectation, expectRes *DownstreamResourceExpectation, foundRes *ChangeDetail, path string, expectedValue any) { - t.Helper() - - // For added/removed resources, the value is in "spec" key - actualValue := getFieldFromDiff(foundRes.Diff, expectRes.changeType, path) - - if !reflect.DeepEqual(actualValue, expectedValue) { - t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s: expected %v, got %v", - compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, expectedValue, actualValue) - } -} - -// assertDownstreamFieldValuePattern asserts that a field value matches a regex pattern. -func assertDownstreamFieldValuePattern(t *testing.T, compName string, expectXR *XRImpactExpectation, expectRes *DownstreamResourceExpectation, foundRes *ChangeDetail, path string, pattern *regexp.Regexp) { - t.Helper() - - actualValue := getNewFieldValue(foundRes.Diff, expectRes.changeType, path) - - actualStr := fmt.Sprintf("%v", actualValue) - if !pattern.MatchString(actualStr) { - t.Errorf("Composition %s: XR %s/%s: downstream %s/%s: field %s value %q does not match pattern %q", - compName, expectXR.kind, expectXR.name, expectRes.kind, foundRes.Name, path, actualStr, pattern.String()) - } -} diff --git a/internal/diff/testutils/structured_assertions_test.go b/internal/diff/testutils/structured_assertions_test.go index e5357f3b..62887a7b 100644 --- a/internal/diff/testutils/structured_assertions_test.go +++ b/internal/diff/testutils/structured_assertions_test.go @@ -1,764 +1,368 @@ package testutils import ( + "encoding/json" "reflect" "testing" -) -func TestParseStructuredOutput(t *testing.T) { - tests := []struct { - name string - input string - wantErr bool - wantAdded int - }{ - { - name: "valid JSON with summary", - input: `{ - "summary": {"added": 1, "modified": 2, "removed": 0}, - "changes": [] - }`, - wantErr: false, - wantAdded: 1, - }, - { - name: "invalid JSON", - input: `{invalid}`, - wantErr: true, - }, - } + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - output, err := ParseStructuredOutput(tt.input) - if (err != nil) != tt.wantErr { - t.Errorf("ParseStructuredOutput() error = %v, wantErr %v", err, tt.wantErr) - return - } +// resultJSON serializes r the way project simulate's -o json does. +func resultJSON(t *testing.T, r plan.Result) string { + t.Helper() - if !tt.wantErr && output.Summary.Added != tt.wantAdded { - t.Errorf("Summary.Added = %d, want %d", output.Summary.Added, tt.wantAdded) - } - }) + out, err := json.Marshal(r) + if err != nil { + t.Fatalf("json.Marshal(): %v", err) } -} - -func TestAssertStructuredDiff_Summary(t *testing.T) { - jsonOutput := `{ - "summary": {"added": 1, "modified": 2, "removed": 3}, - "changes": [] - }` - - // This should pass - matching summary - AssertStructuredDiff(t, jsonOutput, ExpectDiff().WithSummary(1, 2, 3)) -} - -func TestAssertStructuredDiff_AddedResource(t *testing.T) { - jsonOutput := `{ - "summary": {"added": 1, "modified": 0, "removed": 0}, - "changes": [{ - "type": "added", - "apiVersion": "v1alpha1", - "kind": "XNopResource", - "name": "test-resource", - "namespace": "default", - "diff": { - "spec": { - "spec": { - "forProvider": { - "configData": "new-value" - } - } - } - } - }] - }` - - AssertStructuredDiff(t, jsonOutput, - ExpectDiff(). - WithSummary(1, 0, 0). - WithAddedResource("XNopResource", "test-resource", "default"). - WithField("spec.forProvider.configData", "new-value"). - And()) -} - -func TestAssertStructuredDiff_ModifiedResource(t *testing.T) { - jsonOutput := `{ - "summary": {"added": 0, "modified": 1, "removed": 0}, - "changes": [{ - "type": "modified", - "apiVersion": "v1alpha1", - "kind": "XNopResource", - "name": "test-resource", - "namespace": "default", - "diff": { - "old": {"spec": {"forProvider": {"configData": "old-value"}}}, - "new": {"spec": {"forProvider": {"configData": "new-value"}}} - } - }] - }` - AssertStructuredDiff(t, jsonOutput, - ExpectDiff(). - WithSummary(0, 1, 0). - WithModifiedResource("XNopResource", "test-resource", "default"). - WithFieldChange("spec.forProvider.configData", "old-value", "new-value"). - And()) + return string(out) } -func TestAssertStructuredDiff_RemovedResource(t *testing.T) { - jsonOutput := `{ - "summary": {"added": 0, "modified": 0, "removed": 1}, - "changes": [{ - "type": "removed", - "apiVersion": "v1alpha1", - "kind": "XNopResource", - "name": "removed-resource", - "namespace": "default", - "diff": { - "spec": { - "spec": { - "forProvider": { - "configData": "deleted-value" - } - } - } - } - }] - }` - - AssertStructuredDiff(t, jsonOutput, - ExpectDiff(). - WithSummary(0, 0, 1). - WithRemovedResource("XNopResource", "removed-resource", "default"). - WithField("spec.forProvider.configData", "deleted-value"). - And()) -} +func val(v any) *plan.Value { return &plan.Value{Value: v} } -func TestAssertStructuredDiff_NamePattern(t *testing.T) { - jsonOutput := `{ - "summary": {"added": 1, "modified": 0, "removed": 0}, - "changes": [{ - "type": "added", - "apiVersion": "v1alpha1", - "kind": "XNopResource", - "name": "test-resource-abc123", - "namespace": "default", - "diff": {"spec": {}} - }] - }` +// checkAssertion runs assert against a throwaway T and reports whether it failed as wantFail says. +func checkAssertion(t *testing.T, name string, wantFail bool, assert func(*testing.T)) { + t.Helper() - AssertStructuredDiff(t, jsonOutput, - ExpectDiff(). - WithSummary(1, 0, 0). - WithAddedResource("XNopResource", "", "default"). - WithNamePattern(`test-resource-[a-z0-9]+`). - And()) -} + inner := &testing.T{} + assert(inner) -func TestGetNestedField(t *testing.T) { - obj := map[string]any{ - "spec": map[string]any{ - "forProvider": map[string]any{ - "configData": "test-value", - }, - }, - } - - tests := []struct { - path string - want any - }{ - {"spec.forProvider.configData", "test-value"}, - {"spec.forProvider", map[string]any{"configData": "test-value"}}, - {"spec.nonexistent", nil}, - {"", obj}, - } - - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - got := getNestedField(obj, tt.path) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getNestedField(%q) = %v, want %v", tt.path, got, tt.want) - } - }) + if inner.Failed() != wantFail { + t.Errorf("%s: failed = %t, want %t", name, inner.Failed(), wantFail) } } -func TestGetNestedFieldWithArrayIndex(t *testing.T) { - // Simulate a composition structure with pipeline steps - obj := map[string]any{ - "spec": map[string]any{ - "pipeline": []any{ - map[string]any{ - "functionRef": map[string]any{ - "name": "function-patch-and-transform", - }, - "input": map[string]any{ - "resources": []any{ - map[string]any{ - "name": "nop-resource", - "patches": []any{ - map[string]any{ - "fromFieldPath": "spec.coolField", - "transforms": []any{ - map[string]any{ - "string": map[string]any{ - "fmt": "updated-%s", - }, - }, - }, - }, - map[string]any{ - "fromFieldPath": "spec.otherField", - "transforms": []any{ - map[string]any{ - "string": map[string]any{ - "fmt": "basic-%s", - }, - }, - }, - }, - }, - }, - }, - }, - "step": "generate-resources", - }, - map[string]any{ - "functionRef": map[string]any{ - "name": "function-auto-ready", - }, - "step": "detect-ready-resources", - }, - }, - }, - } +var ( + xrA = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "changed-xr", Namespace: "default"} + xrB = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "unchanged-xr", Namespace: "default"} + xrC = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "broken-xr", Namespace: "default"} +) - tests := []struct { - name string - path string - want any - }{ +// xrResult is an xr run: one changed XR (a modified Bucket, a created ConfigMap and a deleted Secret), +// one unchanged XR, and one errored XR. +func xrResult() plan.Result { + plans := []*plan.ResourcePlan{ { - name: "first pipeline step function name", - path: "spec.pipeline[0].functionRef.name", - want: "function-patch-and-transform", - }, - { - name: "second pipeline step function name", - path: "spec.pipeline[1].functionRef.name", - want: "function-auto-ready", - }, - { - name: "first resource name", - path: "spec.pipeline[0].input.resources[0].name", - want: "nop-resource", - }, - { - name: "first patch transform fmt", - path: "spec.pipeline[0].input.resources[0].patches[0].transforms[0].string.fmt", - want: "updated-%s", - }, - { - name: "second patch transform fmt", - path: "spec.pipeline[0].input.resources[0].patches[1].transforms[0].string.fmt", - want: "basic-%s", - }, - { - name: "out of bounds index", - path: "spec.pipeline[5].functionRef.name", - want: nil, - }, - { - name: "negative index (last element)", - path: "spec.pipeline[-1].functionRef.name", - want: "function-auto-ready", // k8s jsonpath supports Python-style negative indices + APIVersion: "example.org/v1", Kind: "Bucket", Name: "bucket-a", Namespace: "default", Action: plan.ActionUpdate, Composite: &xrA, + Changes: []plan.FieldChange{ + {Path: "spec.region", Actual: val("us-east-1"), Planned: val("us-west-2")}, + {Path: `metadata.annotations["example.org/source"]`, Actual: val("old"), Planned: val("new")}, + }, }, { - name: "non-array with index", - path: "spec.pipeline[0].functionRef[0]", - want: nil, + APIVersion: "v1", Kind: "ConfigMap", Name: "cm-x7k2p", Namespace: "default", Action: plan.ActionCreate, Composite: &xrA, + Changes: []plan.FieldChange{ + {Path: "data.key", Planned: val("value")}, + {Path: "spec.items[0].name", Planned: val("first")}, + {Path: "spec.items[1].name", Planned: val("second")}, + }, }, { - name: "step field", - path: "spec.pipeline[0].step", - want: "generate-resources", + APIVersion: "v1", Kind: "Secret", Name: "gone", Namespace: "default", Action: plan.ActionDelete, Composite: &xrA, + Changes: []plan.FieldChange{{Path: "type", Actual: val("Opaque")}}, }, + {APIVersion: xrB.APIVersion, Kind: xrB.Kind, Name: xrB.Name, Namespace: xrB.Namespace, Action: plan.ActionNoOp, Composite: &xrB}, + {APIVersion: xrC.APIVersion, Kind: xrC.Kind, Name: xrC.Name, Namespace: xrC.Namespace, Error: "cannot get composition", Composite: &xrC}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getNestedField(obj, tt.path) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getNestedField(%q) = %v, want %v", tt.path, got, tt.want) - } - }) + return plan.Result{ + Summary: plan.Summarize(plans), + Composites: []plan.CompositeResult{ + {Ref: xrA, Status: plan.CompositeChanged}, + {Ref: xrB, Status: plan.CompositeUnchanged}, + {Ref: xrC, Status: plan.CompositeError}, + }, + Plans: plans, + Errors: []plan.Error{{Resource: &xrC, Message: "cannot get composition"}}, + Warnings: []plan.Warning{{Message: "something advisory", Context: map[string]string{"composition": "c"}}}, } } -func TestGetNestedFieldWithBracketNotation(t *testing.T) { - // Test data with keys containing dots (like Kubernetes annotations) - obj := map[string]any{ - "metadata": map[string]any{ - "annotations": map[string]any{ - "getcomposed.example.org/source-bucket": "bucket-name-abc123", - "crossplane.io/composition-resource-name": "test-resource", - "simple-key": "simple-value", - }, - }, +func TestParseResult(t *testing.T) { + if _, err := ParseResult(`{"plans": [`); err == nil { + t.Error("ParseResult(): want an error for malformed JSON") } - tests := []struct { - name string - path string - want any - }{ - { - name: "bracket notation with single quotes", - path: "metadata.annotations['getcomposed.example.org/source-bucket']", - want: "bucket-name-abc123", - }, - { - name: "bracket notation with double quotes", - path: `metadata.annotations["crossplane.io/composition-resource-name"]`, - want: "test-resource", - }, - { - name: "simple key without dots", - path: "metadata.annotations['simple-key']", - want: "simple-value", - }, - { - name: "simple dot notation still works", - path: "metadata.annotations.simple-key", - want: "simple-value", - }, - { - name: "nonexistent key with brackets", - path: "metadata.annotations['nonexistent.key']", - want: nil, - }, + got, err := ParseResult(resultJSON(t, xrResult())) + if err != nil { + t.Fatalf("ParseResult(): %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getNestedField(obj, tt.path) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("getNestedField(%q) = %v, want %v", tt.path, got, tt.want) - } - }) + if len(got.Plans) != 5 || len(got.Composites) != 3 { + t.Errorf("ParseResult(): got %d plans and %d composites, want 5 and 3", len(got.Plans), len(got.Composites)) } } -func TestAssertStructuredCompDiff_DownstreamFieldChanges(t *testing.T) { - jsonOutput := `{ - "compositions": [{ - "name": "test-composition", - "affectedResources": { - "total": 1, - "withChanges": 1, - "unchanged": 0, - "withErrors": 0 - }, - "impactAnalysis": [{ - "apiVersion": "test.example.org/v1alpha1", - "kind": "XTestResource", - "name": "test-xr", - "status": "changed", - "downstreamChanges": { - "summary": {"added": 0, "modified": 1, "removed": 0}, - "changes": [{ - "type": "modified", - "apiVersion": "nop.crossplane.io/v1alpha1", - "kind": "ClusterNopResource", - "name": "test-xr-abc123", - "diff": { - "old": { - "metadata": { - "annotations": { - "config-data": "old-value", - "resource-tier": "basic" - } - } - }, - "new": { - "metadata": { - "annotations": { - "config-data": "new-value", - "resource-tier": "premium" - } - } - } - } - }] - } - }] - }] - }` - - AssertStructuredCompDiff(t, jsonOutput, - ExpectCompDiff(). - WithComposition("test-composition"). - WithAffectedResources(1, 1, 0, 0). - WithXRImpact("XTestResource", "test-xr", "", "changed"). - WithDownstreamSummary(0, 1, 0). - WithDownstreamResource("modified", "ClusterNopResource", "", ""). - WithAnyName(). - WithFieldChange("metadata.annotations.config-data", "old-value", "new-value"). - WithFieldChange("metadata.annotations.resource-tier", "basic", "premium"). - AndXR(). - AndComp(). - And()) -} +func TestAssertStructuredDiff(t *testing.T) { + out := resultJSON(t, xrResult()) -func TestFindMatchingDownstreamChange(t *testing.T) { - changes := []ChangeDetail{ - { - Type: "modified", - Kind: "ClusterNopResource", - Name: "test-resource-abc123", - Namespace: "", - }, - { - Type: "added", - Kind: "ConfigMap", - Name: "test-config", - Namespace: "default", - }, + // Any resource expectation pins the change count, so a case about one change lists the others. + withoutBucket := func() *ExpectedDiff { + return ExpectDiff().WithAddedResource("ConfigMap", "cm-x7k2p", "default").And().WithRemovedResource("Secret", "gone", "default").And() + } + withoutConfigMap := func() *ExpectedDiff { + return ExpectDiff().WithModifiedResource("Bucket", "bucket-a", "default").And().WithRemovedResource("Secret", "gone", "default").And() } - tests := []struct { - name string - expect *DownstreamResourceExpectation - want bool + cases := map[string]struct { + want DiffExpectation + wantFail bool }{ - { - name: "exact name match", - expect: &DownstreamResourceExpectation{ - changeType: "modified", - kind: "ClusterNopResource", - name: "test-resource-abc123", - }, - want: true, - }, - { - name: "any name match", - expect: &DownstreamResourceExpectation{ - changeType: "modified", - kind: "ClusterNopResource", - anyNameAllowed: true, - }, - want: true, + "Summary": {want: ExpectDiff().WithSummary(1, 1, 1)}, + "WrongSummary": {want: ExpectDiff().WithSummary(0, 1, 1), wantFail: true}, + "AllChanges": {want: ExpectDiff(). + WithModifiedResource("Bucket", "bucket-a", "default"). + WithFieldChange("spec.region", "us-east-1", "us-west-2"). + WithFieldChange("metadata.annotations['example.org/source']", "old", "new"). + And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "default"). + WithField("data.key", "value"). + WithField("spec.items", []any{map[string]any{"name": "first"}, map[string]any{"name": "second"}}). + WithField("spec.items[1].name", "second"). + And(). + WithRemovedResource("Secret", "gone", "default"). + WithField("type", "Opaque")}, + // Any resource expectation pins the change count, so an unlisted change fails. + "UnlistedChange": {want: ExpectDiff(). + WithModifiedResource("Bucket", "bucket-a", "default").And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "default"), wantFail: true}, + "WrongAction": {want: ExpectDiff().WithAddedResource("Bucket", "bucket-a", "default"), wantFail: true}, + "WrongNewValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "us-east-1", "eu-west-1"), + wantFail: true, }, - { - name: "name pattern match", - expect: func() *DownstreamResourceExpectation { - d := &DownstreamResourceExpectation{ - changeType: "modified", - kind: "ClusterNopResource", - } - d.WithNamePattern(`test-resource-[a-z0-9]+`) - - return d - }(), - want: true, + "WrongOldValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "eu-west-1", "us-west-2"), + wantFail: true, }, - { - name: "kind mismatch", - expect: &DownstreamResourceExpectation{ - changeType: "modified", - kind: "WrongKind", - name: "test-resource-abc123", - }, - want: false, - }, - { - name: "change type mismatch", - expect: &DownstreamResourceExpectation{ - changeType: "added", - kind: "ClusterNopResource", - name: "test-resource-abc123", - }, - want: false, + "NamePattern": {want: withoutConfigMap().WithAddedResource("ConfigMap", "", "default").WithNamePattern(`^cm-[a-z0-9]{5}$`)}, + "BadNamePattern": {want: withoutConfigMap().WithAddedResource("ConfigMap", "", "default").WithNamePattern(`^other-`), wantFail: true}, + // A field rendered identically on both sides has no leaf change: asserting it unchanged passes + // when nothing at or under it changed, and fails when something did. + "UnchangedField": {want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.tier", "premium", "premium")}, + "ChangedNotSteady": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "us-east-1", "us-east-1"), + wantFail: true, }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - found := findMatchingDownstreamChange(changes, tt.expect) - if (found != nil) != tt.want { - t.Errorf("findMatchingDownstreamChange() found = %v, want found = %v", found != nil, tt.want) - } - }) - } -} - -func TestAssertStructuredCompDiff_FieldValuePattern(t *testing.T) { - jsonOutput := `{ - "compositions": [{ - "name": "test-composition", - "affectedResources": { - "total": 1, - "withChanges": 1, - "unchanged": 0, - "withErrors": 0 - }, - "impactAnalysis": [{ - "apiVersion": "test.example.org/v1alpha1", - "kind": "XTestResource", - "name": "test-xr", - "status": "changed", - "downstreamChanges": { - "summary": {"added": 0, "modified": 1, "removed": 0}, - "changes": [{ - "type": "modified", - "apiVersion": "nop.crossplane.io/v1alpha1", - "kind": "ClusterNopResource", - "name": "test-xr-abc123", - "diff": { - "old": { - "metadata": { - "annotations": { - "existing-annotation": "value" - } - } - }, - "new": { - "metadata": { - "annotations": { - "existing-annotation": "value", - "generated-ref": "test-xr-xyz789" - } - } - } - } - }] - } - }] - }] - }` - - AssertStructuredCompDiff(t, jsonOutput, - ExpectCompDiff(). - WithComposition("test-composition"). - WithAffectedResources(1, 1, 0, 0). - WithXRImpact("XTestResource", "test-xr", "", "changed"). - WithDownstreamSummary(0, 1, 0). - WithDownstreamResource("modified", "ClusterNopResource", "", ""). - WithAnyName(). - WithFieldValuePattern("metadata.annotations.generated-ref", `test-xr-[a-z0-9]+`). - AndXR(). - AndComp(). - And()) -} - -func TestAssertStructuredDiff_GroupedByXR(t *testing.T) { - // A multi-XR xr structured payload: one changed XR (with a modified - // composed resource), one unchanged XR, and one errored XR. The flat - // changes[]/summary reflect the merge; xrs[] carries the per-XR grouping. - jsonOutput := `{ - "summary": {"added": 0, "modified": 1, "removed": 0}, - "changes": [{ - "type": "modified", "apiVersion": "example.org/v1", "kind": "Bucket", - "name": "bucket-a", "namespace": "default", - "diff": {"old": {"spec": {"region": "us-east-1"}}, "new": {"spec": {"region": "us-west-2"}}} - }], - "errors": [{"resourceID": "XBucket/broken-xr", "message": "cannot get composition"}], - "xrs": [ - { - "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "changed-xr", "namespace": "default"}, - "status": "changed", - "summary": {"added": 0, "modified": 1, "removed": 0}, - "changes": [{ - "type": "modified", "apiVersion": "example.org/v1", "kind": "Bucket", - "name": "bucket-a", "namespace": "default", - "diff": {"old": {"spec": {"region": "us-east-1"}}, "new": {"spec": {"region": "us-west-2"}}} - }] - }, - { - "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "unchanged-xr", "namespace": "default"}, - "status": "unchanged", - "summary": {"added": 0, "modified": 0, "removed": 0}, - "changes": [] - }, - { - "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "broken-xr", "namespace": "default"}, - "status": "error", - "summary": {"added": 0, "modified": 0, "removed": 0}, - "changes": [], - "errors": [{"resourceID": "XBucket/broken-xr", "message": "cannot get composition"}] - } - ] - }` - - matchT := &testing.T{} - AssertStructuredDiff(matchT, jsonOutput, - ExpectDiff(). - WithSummary(0, 1, 0). + "FieldAbsent": {want: withoutConfigMap().WithAddedResource("ConfigMap", "cm-x7k2p", "default").WithFieldAbsent("spec.other")}, + "FieldNotAbsent": {want: withoutConfigMap().WithAddedResource("ConfigMap", "cm-x7k2p", "default").WithFieldAbsent("data.key"), wantFail: true}, + "Error": {want: ExpectDiff().WithError("XBucket/broken-xr").WithMessageContaining("cannot get")}, + "WrongErrorMessage": {want: ExpectDiff().WithError("XBucket/broken-xr").WithMessageContaining("timeout"), wantFail: true}, + "MissingError": {want: ExpectDiff().WithError("XBucket/changed-xr"), wantFail: true}, + "Warning": {want: ExpectDiff().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "c"})}, + "WrongContext": {want: ExpectDiff().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "d"}), wantFail: true}, + "NoWarnings": {want: ExpectDiff().WithNoWarnings(), wantFail: true}, + "GroupedByXR": {want: ExpectDiff(). WithXRs( XR("XBucket", "changed-xr", "default"). Status("changed"). - Summary(0, 1, 0). + Summary(1, 1, 1). Change("modified", "Bucket", "bucket-a", "default"). - WithFieldChange("spec.region", "us-east-1", "us-west-2"), - XR("XBucket", "unchanged-xr", "default"). - Status("unchanged"), - XR("XBucket", "broken-xr", "default"). - Status("error"). - Err("XBucket/broken-xr"), + WithFieldChange("spec.region", "us-east-1", "us-west-2"). + Change("added", "ConfigMap", "cm-x7k2p", "default"). + Change("removed", "Secret", "gone", "default"), + XR("XBucket", "unchanged-xr", "default").Status("unchanged"), + XR("XBucket", "broken-xr", "default").Status("error").Err("XBucket/broken-xr"), ). - Build()) - - if matchT.Failed() { - t.Errorf("grouped assertions unexpectedly failed on a matching payload") - } - - // Negative path: a wrong status must be caught (guards against a no-op - // matcher). All three entries are listed so the only discrepancy is the - // status, not the entry count. - badT := &testing.T{} - AssertStructuredDiff(badT, jsonOutput, - ExpectDiff(). + Build()}, + "GroupedWrongStatus": {want: ExpectDiff(). WithXRs( - XR("XBucket", "changed-xr", "default"). - Status("unchanged"), // actual is "changed" + XR("XBucket", "changed-xr", "default").Status("unchanged"), XR("XBucket", "unchanged-xr", "default"), XR("XBucket", "broken-xr", "default"), ). - Build()) - - if !badT.Failed() { - t.Errorf("grouped assertions should have failed on a mismatched status") + Build(), wantFail: true}, + // Two expectations cannot both consume the same entry. + "GroupedDuplicate": {want: ExpectDiff(). + WithXRs( + XR("XBucket", "changed-xr", "default"), + XR("XBucket", "changed-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + "GroupedErrorOnWrongXR": {want: ExpectDiff(). + WithXRs( + XR("XBucket", "changed-xr", "default").Err("XBucket/broken-xr"), + XR("XBucket", "unchanged-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, } - // Negative path: an xrs[] entry the test did not ask for must be caught. - extraT := &testing.T{} - AssertStructuredDiff(extraT, jsonOutput, - ExpectDiff(). - WithXRs(XR("XBucket", "changed-xr", "default")). - Build()) - - if !extraT.Failed() { - t.Errorf("grouped assertions should have failed on unexpected extra xrs[] entries") + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructuredDiff(t, out, tc.want) }) } +} + +var ( + compA = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "comp-a"} + compB = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "comp-b"} + xr1 = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNop", Name: "xr-1", Namespace: "default"} + xr2 = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNop", Name: "xr-2", Namespace: "default"} + xr3 = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNop", Name: "xr-3", Namespace: "default"} +) - // Negative path (issue #488): two expectations must not both be satisfied by - // the same entry, leaving another entry unchecked. The counts match here - // (2 expectations, 2 entries), so only consuming an entry on match catches - // it. Before that, a duplicated XR identity — the symptom of issue #477 — - // passed unnoticed. - twoEntries := `{ - "summary": {"added": 0, "modified": 0, "removed": 0}, - "changes": [], - "xrs": [ +// compResult is a comp run over two compositions: comp-a reaches a changed, an unchanged and a deleting +// composite; comp-b reaches only a composite whose analysis failed. +func compResult() plan.Result { + return plan.Result{ + Compositions: []plan.CompositionResult{ + {Ref: compA, RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2, PredictedRevision: "comp-a-1a2b3c4"}}, + {Ref: compB, ImpactAnalysisSkipped: true}, + }, + Composites: []plan.CompositeResult{ + {Ref: xr1, Status: plan.CompositeChanged, Composition: &compA}, + {Ref: xr2, Status: plan.CompositeUnchanged, Composition: &compA}, + {Ref: xr3, Status: plan.CompositeSkipped, Reason: string(dt.FilterReasonDeleting), Composition: &compA}, + {Ref: xr1, Status: plan.CompositeError, Composition: &compB}, + }, + Plans: []*plan.ResourcePlan{ { - "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "first-xr", "namespace": "default"}, - "status": "unchanged", - "summary": {"added": 0, "modified": 0, "removed": 0}, - "changes": [] + APIVersion: compA.APIVersion, Kind: compA.Kind, Name: compA.Name, Action: plan.ActionUpdate, + Changes: []plan.FieldChange{{Path: "spec.mode", Actual: val("Resources"), Planned: val("Pipeline")}}, }, + {APIVersion: compB.APIVersion, Kind: compB.Kind, Name: compB.Name, Error: "cannot convert composition"}, { - "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "second-xr", "namespace": "default"}, - "status": "unchanged", - "summary": {"added": 0, "modified": 0, "removed": 0}, - "changes": [] - } - ] - }` - - dupeT := &testing.T{} - AssertStructuredDiff(dupeT, twoEntries, - ExpectDiff(). - WithXRs( - XR("XBucket", "first-xr", "default").Status("unchanged"), - XR("XBucket", "first-xr", "default").Status("unchanged"), - ). - Build()) - - if !dupeT.Failed() { - t.Errorf("grouped assertions should have failed when two expectations matched one xrs[] entry") + APIVersion: "example.org/v1", Kind: "XDownstream", Name: "xr-1-abc12", Namespace: "default", Action: plan.ActionUpdate, Composite: &xr1, + Changes: []plan.FieldChange{{Path: "spec.forProvider.configData", Actual: val("old"), Planned: val("new-value-42")}}, + DryRun: &plan.Verification{SkipReason: "not-found", Detail: "the server could not find it"}, + }, + }, + Errors: []plan.Error{ + {Resource: &compB, Message: "cannot convert composition"}, + {Resource: &xr1, Message: "maximum nesting depth exceeded"}, + }, } } -// TestAssertStructuredCompDiff_V2 asserts a v2 document through the same builders as v1, with the -// real t so a mismatch fails the test. -func TestAssertStructuredCompDiff_V2(t *testing.T) { - jsonOutput := `{ - "outputVersion": "v2", - "compositions": [{ - "name": "test-composition", - "affectedResources": {"total": 2, "withChanges": 1, "unchanged": 0, "withErrors": 1}, - "impactAnalysis": [ - { - "xr": {"apiVersion": "test.example.org/v1alpha1", "kind": "XTestResource", "name": "test-xr"}, - "status": "changed", - "summary": {"added": 0, "modified": 1, "removed": 0}, - "changes": [{ - "type": "modified", - "apiVersion": "nop.crossplane.io/v1alpha1", - "kind": "ClusterNopResource", - "name": "test-xr-abc123", - "diff": {"old": {"spec": {"v": "old"}}, "new": {"spec": {"v": "new"}}} - }] - }, - { - "xr": {"apiVersion": "test.example.org/v1alpha1", "kind": "XTestResource", "name": "broken-xr"}, - "status": "error", - "summary": {"added": 0, "modified": 0, "removed": 0}, - "changes": [], - "errors": [{"resourceID": "XTestResource/broken-xr", "message": "render failed"}] - } - ] - }, { - "name": "broken-composition", - "errors": [{"resourceID": "Composition/broken-composition", "message": "cannot fetch"}], - "affectedResources": {"total": 0, "withChanges": 0, "unchanged": 0, "withErrors": 0}, - "impactAnalysis": [] - }], - "errors": [ - {"resourceID": "XTestResource/broken-xr", "message": "render failed"}, - {"resourceID": "Composition/broken-composition", "message": "cannot fetch"} - ] - }` +func TestAssertStructuredCompDiff(t *testing.T) { + out := resultJSON(t, compResult()) - AssertStructuredCompDiff(t, jsonOutput, - ExpectCompDiff(). - WithOutputVersion("v2"). - WithError("XTestResource/broken-xr"). - WithError("Composition/broken-composition"). - WithComposition("test-composition"). - WithAffectedResources(2, 1, 0, 1). - WithXRImpact("XTestResource", "test-xr", "", "changed"). + downstream := func() *DownstreamResourceExpectation { + return ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "changed"). + WithDownstreamResource("modified", "XDownstream", "xr-1-abc12", "default") + } + + cases := map[string]struct { + want CompDiffExpectation + wantFail bool + }{ + "Composition": {want: ExpectCompDiff(). + WithComposition("comp-a"). + WithAffectedResources(3, 1, 1, 0). + WithFilteredByDeletion(1). + WithRevisionImpact("spec", true, 2). + WithPredictedRevisionNamePattern(`^comp-a-[0-9a-f]{7}$`). + WithCompositionModified(). + WithCompositionFieldChange("spec.mode", "Resources", "Pipeline"). + WithXRImpact("XNop", "xr-1", "default", "changed"). WithDownstreamSummary(0, 1, 0). - WithDownstreamResource("modified", "ClusterNopResource", "test-xr-abc123", ""). - WithFieldChange("spec.v", "old", "new"). + WithDownstreamResource("modified", "XDownstream", "", "default"). + WithNamePattern(`^xr-1-`). + WithFieldChange("spec.forProvider.configData", "old", "new-value-42"). + WithFieldValuePattern("spec.forProvider.configData", `^new-value-\d+$`). + WithDryRunSkipped("not-found", "could not find"). AndXR(). AndComp(). - WithXRImpact("XTestResource", "broken-xr", "", "error"). - WithErrorContaining("render failed"). - WithErrorResourceID("XTestResource/broken-xr"). - AndComposition("broken-composition"). - WithCompositionErrorContaining("cannot fetch")) + WithXRImpact("XNop", "xr-3", "default", "filtered"). + WithFilterReason(string(dt.FilterReasonDeleting))}, + // Counts are per composition: comp-b's errored composite is not comp-a's. + "CountsArePerComposition": {want: ExpectCompDiff().WithComposition("comp-a").WithAffectedResources(4, 1, 1, 1), wantFail: true}, + "OtherComposition": {want: ExpectCompDiff(). + WithError("Composition/comp-b"). + WithComposition("comp-b"). + WithCompositionErrorContaining("cannot convert"). + WithImpactAnalysisSkipped(). + WithAffectedResources(1, 0, 0, 1). + WithXRImpact("XNop", "xr-1", "default", "error"). + WithErrorContaining("maximum nesting depth"). + WithErrorResourceID("XNop/xr-1")}, + "MissingComposition": {want: ExpectCompDiff().WithComposition("comp-c"), wantFail: true}, + "WrongRevisionScope": {want: ExpectCompDiff().WithComposition("comp-a").WithRevisionImpact("metadata", true, 2), wantFail: true}, + "PredictedNameAbsent": {want: ExpectCompDiff().WithComposition("comp-a").WithoutPredictedRevisionName(), wantFail: true}, + "NoRevisionImpact": {want: ExpectCompDiff().WithComposition("comp-b").WithRevisionImpact("none", false, 0), wantFail: true}, + "NotSkipped": {want: ExpectCompDiff().WithComposition("comp-a").WithImpactAnalysisSkipped(), wantFail: true}, + "WrongXRStatus": {want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "changed"), wantFail: true}, + "WrongFilterReason": { + want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-3", "default", "filtered"). + WithFilterReason(string(dt.FilterReasonManualPolicy)), + wantFail: true, + }, + "WrongDownstreamValue": {want: downstream().WithFieldChange("spec.forProvider.configData", "old", "other"), wantFail: true}, + "BadValuePattern": {want: downstream().WithFieldValuePattern("spec.forProvider.configData", `^old$`), wantFail: true}, + "DryRunPerformed": {want: downstream().WithDryRunPerformed(), wantFail: true}, + "ErrorOnOtherXR": { + want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "unchanged").WithErrorResourceID("XNop/xr-1"), + wantFail: true, + }, + "MissingTopLevelError": {want: ExpectCompDiff().WithError("Composition/comp-a"), wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructuredCompDiff(t, out, tc.want) }) + } +} + +func TestParsePath(t *testing.T) { + cases := map[string]struct { + path string + want []pathToken + wantErr bool + }{ + "Dotted": {path: "spec.forProvider.region", want: []pathToken{{key: "spec"}, {key: "forProvider"}, {key: "region"}}}, + "Index": {path: "spec.items[2].name", want: []pathToken{{key: "spec"}, {key: "items"}, {index: 2, isIndex: true}, {key: "name"}}}, + "DoubleQuoted": {path: `metadata.annotations["a.b/c"]`, want: []pathToken{{key: "metadata"}, {key: "annotations"}, {key: "a.b/c"}}}, + "SingleQuoted": {path: "metadata.annotations['a.b/c']", want: []pathToken{{key: "metadata"}, {key: "annotations"}, {key: "a.b/c"}}}, + "EscapedQuote": {path: `data["say \"hi\""]`, want: []pathToken{{key: "data"}, {key: `say "hi"`}}}, + "BadIndex": {path: "spec.items[x]", wantErr: true}, + "Unterminated": {path: "spec.items[1", wantErr: true}, + "UnterminatedQ": {path: "metadata['a.b", wantErr: true}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := parsePath(tc.path) + if (err != nil) != tc.wantErr { + t.Fatalf("parsePath(%q): err = %v, wantErr %t", tc.path, err, tc.wantErr) + } + + if !tc.wantErr && !reflect.DeepEqual(got, tc.want) { + t.Errorf("parsePath(%q) = %+v, want %+v", tc.path, got, tc.want) + } + }) + } } -// TestAssertStructuredDiff_V2 asserts a v2 xr document, which has no flat changes[], with the real t. -func TestAssertStructuredDiff_V2(t *testing.T) { - jsonOutput := `{ - "outputVersion": "v2", - "summary": {"added": 1, "modified": 0, "removed": 0}, - "xrs": [{ - "xr": {"apiVersion": "example.org/v1", "kind": "XBucket", "name": "b", "namespace": "default"}, - "status": "changed", - "summary": {"added": 1, "modified": 0, "removed": 0}, - "changes": [{"type": "added", "apiVersion": "v1", "kind": "ConfigMap", "name": "cm", "namespace": "default", "diff": {"spec": {"data": {"k": "v"}}}}] - }] - }` +func TestValueAt(t *testing.T) { + changes := []plan.FieldChange{ + {Path: "spec.a", Actual: val("old-a"), Planned: val("new-a")}, + {Path: "spec.list[0].x", Planned: val("x0")}, + {Path: "spec.list[1].x", Actual: val("gone")}, + {Path: `metadata.labels["app.io/name"]`, Planned: val("app")}, + } + + cases := map[string]struct { + path string + planned bool + want any + }{ + "Leaf": {path: "spec.a", planned: true, want: "new-a"}, + "LeafActual": {path: "spec.a", want: "old-a"}, + "Subtree": {path: "spec", planned: true, want: map[string]any{"a": "new-a", "list": []any{map[string]any{"x": "x0"}}}}, + "SubtreeActual": {path: "spec.list", want: []any{nil, map[string]any{"x": "gone"}}}, + "QuotedKey": {path: "metadata.labels['app.io/name']", planned: true, want: "app"}, + "Absent": {path: "spec.b", planned: true, want: nil}, + "PrefixIsNotPath": {path: "spec.list[0].xy", planned: true, want: nil}, + } - AssertStructuredDiff(t, jsonOutput, - ExpectDiff(). - WithOutputVersion("v2"). - WithSummary(1, 0, 0). - WithXRs(XR("XBucket", "b", "default").Status("changed").Summary(1, 0, 0). - Change("added", "ConfigMap", "cm", "default").WithField("data.k", "v"))) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := valueAt(t, changes, tc.planned, tc.path); !reflect.DeepEqual(got, tc.want) { + t.Errorf("valueAt(%q, planned=%t) = %#v, want %#v", tc.path, tc.planned, got, tc.want) + } + }) + } } From 6cd6e8d0286ff8e79d03508e70630b64010998f9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 03:11:32 -0400 Subject: [PATCH 055/126] refactor(simulate)!: drop crossplane-diff's v1/v2 structured output Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 24 +- cmd/crossplane/project/simulate_test.go | 60 +- .../diff/diffprocessor/comp_processor_test.go | 11 +- internal/diff/integration/integration_test.go | 4 +- internal/diff/renderer/comp_diff_renderer.go | 189 ---- .../diff/renderer/comp_diff_renderer_test.go | 691 -------------- internal/diff/renderer/diff_formatter.go | 17 +- .../diff/renderer/dry_run_warnings_test.go | 37 +- internal/diff/renderer/structured_renderer.go | 514 ----------- .../diff/renderer/structured_renderer_test.go | 847 ------------------ .../diff/testutils/structured_assertions.go | 9 +- 11 files changed, 29 insertions(+), 2374 deletions(-) delete mode 100644 internal/diff/renderer/structured_renderer.go delete mode 100644 internal/diff/renderer/structured_renderer_test.go diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 81d41885..0d4edc10 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -80,10 +80,6 @@ type simulateCmd struct { MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` - // ponytail: OutputVersion keeps crossplane-diff's v1/v2 structured schema - // reachable while the integration tests still assert on it. It goes once - // they assert on plan.Result. - OutputVersion string `default:"" enum:",v1,v2" help:"Emit crossplane-diff's structured schema (v1 or v2) for -o json|yaml instead of the plan result." hidden:""` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` @@ -193,9 +189,6 @@ func (c *simulateCmd) Validate() error { if c.MaxIterations < 1 { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } - if c.OutputVersion != "" && c.Output != plan.FormatJSON && c.Output != plan.FormatYAML { - return errors.Errorf("--output-version selects a structured schema, so it applies only with -o json or -o yaml, not -o %s", c.Output) - } // The two flags are mutually exclusive (kong xor), so at most one of // these has anything to check. @@ -650,23 +643,15 @@ func (c *simulateCmd) diffOptions(k *kong.Context) renderer.DiffOptions { opts.Format = renderer.OutputFormatDiff } - if c.OutputVersion != "" { - opts.OutputVersion = renderer.OutputVersion(c.OutputVersion) - } - return opts } // renderXRReport writes an xr or project run's report in the format -o -// selects: the engine's human diff for diff, its v1/v2 structured schema for -// json or yaml when --output-version is set, and plan.Result otherwise. +// selects: the engine's human diff for diff, and plan.Result otherwise. func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport) error { opts := c.diffOptions(k) - switch { - case c.Output == string(renderer.OutputFormatDiff): + if c.Output == string(renderer.OutputFormatDiff) { return renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) - case c.OutputVersion != "": - return renderer.NewStructuredDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) } return c.renderPlan(k, simulate.FromXRReport(r), dt.XRDryRunWarnings(r.Groups)) } @@ -676,11 +661,8 @@ func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r * func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput) error { opts := c.diffOptions(k) opts.MinimizeComposition = c.MinimizeComposition - switch { - case c.Output == string(renderer.OutputFormatDiff): + if c.Output == string(renderer.OutputFormatDiff) { return renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts).RenderCompDiff(o) - case c.OutputVersion != "": - return renderer.NewStructuredCompDiffRenderer(logger, opts).RenderCompDiff(o) } return c.renderPlan(k, simulate.FromCompReport(o), dt.CompDryRunWarnings(o)) } diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 8568af50..b201db2f 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -104,7 +104,7 @@ func TestSimulateFlags(t *testing.T) { args: []string{ "-f", "p.yaml", "-c", "staging", "-n", "team-a", "--name", "db", "--include-pinned", "--max-iterations", "3", "--timeout", "2m", - "--max-concurrency", "2", "-o", "json", "--output-version", "v2", "--crossplane-version", "v2.3.4", + "--max-concurrency", "2", "-o", "json", "--crossplane-version", "v2.3.4", "--as", "jane", "--as-group", "admins", }, want: want{cmd: &simulateCmd{ @@ -117,7 +117,6 @@ func TestSimulateFlags(t *testing.T) { Timeout: 2 * time.Minute, MaxConcurrency: 2, Output: "json", - OutputVersion: "v2", CrossplaneVersion: "v2.3.4", MaxNestedDepth: 10, DryRunOn: "all", @@ -198,45 +197,11 @@ func TestSimulateFlags(t *testing.T) { DryRunOn: "all", }}, }, - "UnknownOutputVersion": { - reason: "Structured output schema versions other than v1 and v2 should be rejected.", - args: []string{"--output-version", "v3"}, - want: want{err: true}, - }, "UnknownOutput": { reason: "Output formats other than plan, diff, json and yaml should be rejected.", args: []string{"-o", "xml"}, want: want{err: true}, }, - "OutputVersionWithStructuredOutput": { - reason: "--output-version should be accepted with -o yaml.", - args: []string{"-o", "yaml", "--output-version", "v1"}, - want: want{cmd: &simulateCmd{ - Input: []string{"crossplane-project.yaml"}, - MaxConcurrency: 8, - MaxIterations: dp.DefaultMaxRenderIterations, - Timeout: time.Minute, - Output: "yaml", - OutputVersion: "v1", - MaxNestedDepth: 10, - DryRunOn: "all", - }}, - }, - "OutputVersionWithDefaultOutput": { - reason: "--output-version should be rejected with the default -o plan, which has no structured schema.", - args: []string{"--output-version", "v1"}, - want: want{err: true}, - }, - "OutputVersionWithPlan": { - reason: "--output-version should be rejected with -o plan.", - args: []string{"-o", "plan", "--output-version", "v2"}, - want: want{err: true}, - }, - "OutputVersionWithDiff": { - reason: "--output-version should be rejected with -o diff.", - args: []string{"-o", "diff", "--output-version", "v1"}, - want: want{err: true}, - }, "ZeroIterations": { reason: "A render needs at least one iteration.", args: []string{"--max-iterations", "0"}, @@ -439,19 +404,18 @@ func TestSimulateDiffOptions(t *testing.T) { want func(o *renderer.DiffOptions) }{ "UnsetFlagsSelectHumanDiff": { - reason: "Unset flags should select the human-readable diff and the renderer's default output version, uncolorized off a terminal.", + reason: "Unset flags should select the human-readable diff, uncolorized off a terminal.", want: func(o *renderer.DiffOptions) { o.UseColors = false }, }, "StructuredFlagsCarryThrough": { - reason: "The format, version and compact flags should reach the renderer.", - cmd: simulateCmd{Output: "json", OutputVersion: "v2", NoColor: true, Compact: true}, + reason: "The format and compact flags should reach the renderer.", + cmd: simulateCmd{Output: "json", NoColor: true, Compact: true}, want: func(o *renderer.DiffOptions) { o.UseColors = false o.Compact = true o.Format = renderer.OutputFormatJSON - o.OutputVersion = renderer.OutputVersionV2 }, }, "UnknownFormatFallsBackToHumanDiff": { @@ -495,8 +459,8 @@ func TestSimulateDiffOptions(t *testing.T) { } // TestSimulateRenderReports pins which renderer each -o value selects, for -// both report types: plan.Result for plan, json and yaml, the engine's human -// diff for diff, and its v1/v2 schema only under --output-version. +// both report types: plan.Result for plan, json and yaml, and the engine's +// human diff for diff. func TestSimulateRenderReports(t *testing.T) { xr := corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod"} cm := &un.Unstructured{Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "cm"}, "data": map[string]any{"k": "v"}}} @@ -566,18 +530,6 @@ func TestSimulateRenderReports(t *testing.T) { wantXR: contains("+++ ConfigMap/cm", "Summary: 1 added"), wantComp: contains("=== Composition Changes ===", "=== Affected Composite Resources ==="), }, - "JSONWithOutputVersion": { - reason: "-o json with --output-version should print the engine's structured schema at that version.", - cmd: simulateCmd{Output: "json", OutputVersion: "v2"}, - wantXR: contains(`"outputVersion": "v2"`), - wantComp: contains(`"outputVersion": "v2"`), - }, - "YAMLWithOutputVersion": { - reason: "-o yaml with --output-version should print the engine's structured schema at that version.", - cmd: simulateCmd{Output: "yaml", OutputVersion: "v1"}, - wantXR: contains("outputVersion: v1"), - wantComp: contains("outputVersion: v1"), - }, } for name, tc := range cases { diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index c722a334..e23a10db 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -2142,16 +2142,7 @@ func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.O logger := tu.TestLogger(t, false) - var compRenderer renderer.CompDiffRenderer - - switch opts.Format { - case renderer.OutputFormatJSON, renderer.OutputFormatYAML: - compRenderer = renderer.NewStructuredCompDiffRenderer(logger, opts) - case renderer.OutputFormatDiff: - fallthrough - default: - compRenderer = renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts) - } + compRenderer := renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts) if err := compRenderer.RenderCompDiff(output); err != nil { t.Fatalf("RenderCompDiff(): %v", err) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 83e43fba..73872e64 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -833,8 +833,8 @@ func TestDiffIntegration(t *testing.T) { "cleanupForDiff (compare/compare.go) deletes metadata.status unconditionally from " + "both sides of every diff, in the same block that deletes ownerReferences — the strip whose " + "identical consequence for ownerReferences this feature's own spec documented (§1a) and then " + - "missed for status. ResourceViews.Clean is the only view any renderer reads " + - "(structured_renderer.go:462-472); ResourceViews.Desired.Raw, which does carry the " + + "missed for status. ResourceViews.Clean is the only view any renderer reads" + + "; ResourceViews.Desired.Raw, which does carry the " + "re-attached status, has no reader at all. Verified empirically: the apiserver's dry-run " + "create response for XStatusResource carries no status, the object handed to cleanupForDiff " + "carries status.phase=authored-by-composition, and the object cleanupForDiff returns does " + diff --git a/internal/diff/renderer/comp_diff_renderer.go b/internal/diff/renderer/comp_diff_renderer.go index 3ab7784d..43b5d994 100644 --- a/internal/diff/renderer/comp_diff_renderer.go +++ b/internal/diff/renderer/comp_diff_renderer.go @@ -17,14 +17,10 @@ limitations under the License. package renderer import ( - "encoding/json" "fmt" "io" - "slices" "strings" - sigsyaml "sigs.k8s.io/yaml" - "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -588,191 +584,6 @@ func (r *DefaultCompDiffRenderer) buildXRStatusList(impacts []dt.XRImpact) strin return sb.String() } -// StructuredCompDiffRenderer renders composition diffs in JSON/YAML format. -type StructuredCompDiffRenderer struct { - logger logging.Logger - opts DiffOptions -} - -// NewStructuredCompDiffRenderer creates a new structured composition diff renderer. -func NewStructuredCompDiffRenderer(logger logging.Logger, opts DiffOptions) CompDiffRenderer { - return &StructuredCompDiffRenderer{ - logger: logger, - opts: opts, - } -} - -// RenderCompDiff renders the composition diff in structured format (JSON/YAML) -// to r.opts.Stdout. Top-level errors go into the payload only; see -// CompDiffRenderer. -func (r *StructuredCompDiffRenderer) RenderCompDiff(output *dt.CompDiffOutput) error { - // One run-wide summary of unverified additions, derived here from the diffs and appended after the - // warnings raised during the run. See DiffRenderer.RenderDiffs. - dryRun := dt.CompDryRunWarnings(output) - - // Convert internal representation to JSON output structure - warnings := append(slices.Clone(output.Warnings), dryRun...) - - v2 := buildStructuredCompOutput(output) - v2.Warnings = warnings - - // Anything but v2, including the empty value, is v1. - var jsonOutput any = v2 - if r.opts.OutputVersion != OutputVersionV2 { - jsonOutput = v2.v1() - } - - var ( - data []byte - err error - ) - - switch r.opts.Format { - case OutputFormatJSON: - data, err = json.MarshalIndent(jsonOutput, "", " ") - case OutputFormatYAML: - data, err = sigsyaml.Marshal(jsonOutput) - case OutputFormatDiff: - fallthrough - default: - return errors.Errorf("unsupported format for structured comp diff renderer: %s", r.opts.Format) - } - - if err != nil { - return errors.Wrap(err, "failed to marshal comp diff output") - } - - _, err = r.opts.Stdout.Write(append(data, '\n')) - if err != nil { - return errors.Wrap(err, "failed to write output") - } - - // Of the warnings, only the ones derived here go to stderr: the rest went there when raised. - return writeWarnings(r.opts.Stderr, dryRun) -} - -// buildStructuredCompOutput converts internal CompDiffOutput to the v2 document, from which v1 is -// derived (see compDiffWireV2.v1). Each failed composition's error is appended to the top-level -// errors, after the processor's, so that list is the union of every error in the document. -func buildStructuredCompOutput(output *dt.CompDiffOutput) *compDiffWireV2 { - result := &compDiffWireV2{ - OutputVersion: OutputVersionV2, - Compositions: make([]compositionDiffWireV2, 0, len(output.Compositions)), - Errors: slices.Clone(output.Errors), - } - - for _, comp := range output.Compositions { - jsonComp := compositionDiffWireV2{ - Name: comp.Name, - AffectedResources: comp.AffectedResources, - ImpactAnalysis: make([]xrEntryWire, 0, len(comp.ImpactAnalysis)), - ImpactAnalysisSkipped: comp.ImpactAnalysisSkipped, - MaskedChangesOnly: comp.MaskedChangesOnly, - } - - // Omit revisionImpact entirely when the comparison never completed, rather than serializing a - // zero value that would claim changeScope "" and createsRevision false. See - // RevisionImpact.Determined and issue #479. - if comp.RevisionImpact.Determined() { - revisionImpact := comp.RevisionImpact - jsonComp.RevisionImpact = &revisionImpact - } - - if comp.Error != nil { - jsonComp.Errors = []dt.OutputError{*comp.Error} - result.Errors = append(result.Errors, *comp.Error) - } - - if comp.CompositionDiff != nil && comp.CompositionDiff.DiffType != dt.DiffTypeEqual { - jsonComp.CompositionChanges = resourceDiffToChangeDetail(comp.CompositionDiff) - } - - for _, impact := range listedImpacts(&comp) { - entry := toXREntry(impact.ObjectReference, impact.Status, impact.Diffs, impact.Error) - entry.FilterReason = impact.FilterReason - entry.FilterDetail = impact.FilterDetail - jsonComp.ImpactAnalysis = append(jsonComp.ImpactAnalysis, entry) - } - - result.Compositions = append(result.Compositions, jsonComp) - } - - return result -} - -// v1 projects the document to v1. Its compositions and their impactAnalysis[] entries take the v1 -// shape, and its top-level errors drop the failed compositions' errors, which v1 reports only on the -// composition. buildStructuredCompOutput appends those last, one per failed composition, so they are -// the list's tail. -func (d *compDiffWireV2) v1() *compDiffWire { - failed := 0 - result := &compDiffWire{ - OutputVersion: OutputVersionV1, - Compositions: make([]compositionDiffWire, 0, len(d.Compositions)), - Warnings: d.Warnings, - } - - for _, comp := range d.Compositions { - if len(comp.Errors) > 0 { - failed++ - } - - result.Compositions = append(result.Compositions, comp.v1()) - } - - result.Errors = d.Errors[:len(d.Errors)-failed] - - return result -} - -// v1 projects a composition to v1, where its error is a string rather than errors[]. -func (c compositionDiffWireV2) v1() compositionDiffWire { - result := compositionDiffWire{ - Name: c.Name, - Error: firstErrorMessage(c.Errors), - CompositionChanges: c.CompositionChanges, - AffectedResources: c.AffectedResources, - ImpactAnalysis: make([]xrImpactWire, 0, len(c.ImpactAnalysis)), - ImpactAnalysisSkipped: c.ImpactAnalysisSkipped, - MaskedChangesOnly: c.MaskedChangesOnly, - RevisionImpact: c.RevisionImpact, - } - - for _, entry := range c.ImpactAnalysis { - result.ImpactAnalysis = append(result.ImpactAnalysis, entry.v1()) - } - - return result -} - -// v1 projects the entry to comp's v1 impactAnalysis[] entry: identity inlined, the error as a string, -// and summary and changes wrapped in downstreamChanges, present only when there are changes. -func (e xrEntryWire) v1() xrImpactWire { - result := xrImpactWire{ - ObjectReference: e.XR, - Status: e.Status, - FilterReason: e.FilterReason, - FilterDetail: e.FilterDetail, - Error: firstErrorMessage(e.Errors), - } - - if e.Status == dt.XRStatusChanged && len(e.Changes) > 0 { - result.DownstreamChanges = &DownstreamChanges{Summary: e.Summary, Changes: e.Changes} - } - - return result -} - -// firstErrorMessage returns the message of the one error v2 carries where v1 had an error string, or -// "" when there is none. -func firstErrorMessage(errs []dt.OutputError) string { - if len(errs) == 0 { - return "" - } - - return errs[0].Message -} - // formatXRStatusSummary generates the summary line with correct pluralization. func formatXRStatusSummary(changedCount, unchangedCount, errorCount int) string { parts := []string{} diff --git a/internal/diff/renderer/comp_diff_renderer_test.go b/internal/diff/renderer/comp_diff_renderer_test.go index d7a56946..92751e0b 100644 --- a/internal/diff/renderer/comp_diff_renderer_test.go +++ b/internal/diff/renderer/comp_diff_renderer_test.go @@ -2,286 +2,17 @@ package renderer import ( "bytes" - "encoding/json" - "reflect" "strings" "testing" gcmp "github.com/google/go-cmp/cmp" corev1 "k8s.io/api/core/v1" - un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) -// testCompDiffFixture defines a reusable test case with input and expected output. -type testCompDiffFixture struct { - name string - output *dt.CompDiffOutput - minimize bool - validate func(t *testing.T, format OutputFormat, result string) -} - -// bothViews returns a ResourceViews whose Raw and Clean both point at obj. Test -// fixtures use it when the object has no ignorable / server-side fields, so the -// cleaned view equals the raw one. -func bothViews(obj *un.Unstructured) dt.ResourceViews { - return dt.ResourceViews{Raw: obj, Clean: obj} -} - -// sharedCompDiffFixtures returns test fixtures that should be run through both JSON and YAML renderers. -func sharedCompDiffFixtures() []testCompDiffFixture { - return []testCompDiffFixture{ - { - name: "EmptyCompositions", - output: &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{}}, - validate: func(t *testing.T, format OutputFormat, result string) { - t.Helper() - - if format == OutputFormatJSON { - var parsed compDiffWire - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if len(parsed.Compositions) != 0 { - t.Errorf("Expected 0 compositions, got %d", len(parsed.Compositions)) - } - } else if !strings.Contains(result, "compositions:") { - t.Error("Expected YAML to contain 'compositions:'") - } - }, - }, - { - name: "WithChanges", - output: &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{{ - Name: "test-comp", - CompositionDiff: &dt.ResourceDiff{ - DiffType: dt.DiffTypeAdded, - ResourceName: "test-comp", - Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, - Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), - }, - AffectedResources: dt.AffectedResourcesSummary{Total: 2, WithChanges: 1, Unchanged: 1}, - ImpactAnalysis: []dt.XRImpact{ - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: dt.XRStatusChanged}, - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-2"}, Status: dt.XRStatusUnchanged}, - }, - }}, - }, - validate: func(t *testing.T, format OutputFormat, result string) { - t.Helper() - - if format == OutputFormatJSON { - var parsed compDiffWire - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if len(parsed.Compositions) != 1 { - t.Fatalf("Expected 1 composition, got %d", len(parsed.Compositions)) - } - - if parsed.Compositions[0].Name != "test-comp" { - t.Errorf("Expected name 'test-comp', got '%s'", parsed.Compositions[0].Name) - } - - if parsed.Compositions[0].AffectedResources.Total != 2 { - t.Errorf("Expected total 2, got %d", parsed.Compositions[0].AffectedResources.Total) - } - - // Verify compositionChanges is present in JSON - if parsed.Compositions[0].CompositionChanges == nil { - t.Error("Expected compositionChanges to be present") - } - - // Verify embedded ObjectReference fields are at top level (not nested) - // This tests that json:",inline" works correctly - var rawParsed map[string]any - if err := json.Unmarshal([]byte(result), &rawParsed); err != nil { - t.Fatalf("Failed to parse raw JSON: %v", err) - } - - comps, ok := rawParsed["compositions"].([]any) - if !ok { - t.Fatalf("Expected 'compositions' to be array, got %T", rawParsed["compositions"]) - } - - comp, ok := comps[0].(map[string]any) - if !ok { - t.Fatalf("Expected compositions[0] to be object, got %T", comps[0]) - } - - impacts, ok := comp["impactAnalysis"].([]any) - if !ok { - t.Fatalf("Expected 'impactAnalysis' to be array, got %T", comp["impactAnalysis"]) - } - - impact, ok := impacts[0].(map[string]any) - if !ok { - t.Fatalf("Expected impacts[0] to be object, got %T", impacts[0]) - } - - // Verify apiVersion, kind, name are top-level keys (not nested under "objectReference") - if _, ok := impact["apiVersion"]; !ok { - t.Error("Expected 'apiVersion' to be a top-level field in xrImpactWire (embedded from ObjectReference)") - } - - if _, ok := impact["kind"]; !ok { - t.Error("Expected 'kind' to be a top-level field in xrImpactWire (embedded from ObjectReference)") - } - - if _, ok := impact["name"]; !ok { - t.Error("Expected 'name' to be a top-level field in xrImpactWire (embedded from ObjectReference)") - } - - // ObjectReference should NOT be nested - if _, ok := impact["objectReference"]; ok { - t.Error("ObjectReference should be inlined, not a nested field") - } - } else { - if !strings.Contains(result, "compositions:") { - t.Error("Expected YAML to contain 'compositions:'") - } - - if !strings.Contains(result, "name: test-comp") { - t.Error("Expected YAML to contain 'name: test-comp'") - } - } - }, - }, - { - // MinimizeComposition is a human-output affordance only: structured - // (JSON/YAML) output must always retain full compositionChanges. - name: "MinimizeKeepsStructuredFidelity", - minimize: true, - output: &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{{ - Name: "test-comp", - CompositionDiff: &dt.ResourceDiff{ - DiffType: dt.DiffTypeModified, - ResourceName: "test-comp", - Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, - Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), - Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), - }, - AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, - ImpactAnalysis: []dt.XRImpact{{ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XResource", Name: "xr-1"}, Status: dt.XRStatusUnchanged}}, - }}, - }, - validate: func(t *testing.T, format OutputFormat, result string) { - t.Helper() - - if format == OutputFormatJSON { - var parsed compDiffWire - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if parsed.Compositions[0].CompositionChanges == nil { - t.Error("expected compositionChanges preserved in structured output when minimized") - } - - if len(parsed.Compositions[0].ImpactAnalysis) != 1 { - t.Errorf("expected impact analysis preserved, got %d", len(parsed.Compositions[0].ImpactAnalysis)) - } - } else if !strings.Contains(result, "compositionChanges:") { - t.Error("expected YAML to retain 'compositionChanges:' when minimized") - } - }, - }, - { - // Issue #479: a composition that failed to process never completed the comparison, so nothing - // about its revision impact was determined. Serializing the zero value would put changeScope - // outside its "none"/"metadata"/"spec" enum and assert createsRevision: false about an apply - // the tool never evaluated. The object is omitted instead; a composition that *was* compared - // keeps it, including when the composites went unevaluated. - name: "FailedCompositionOmitsRevisionImpact", - output: &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{ - { - Name: "compared.example.org", - ImpactAnalysisSkipped: true, - RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1}, - ImpactAnalysis: []dt.XRImpact{}, - }, - { - Name: "broken.example.org", - Error: &dt.OutputError{Message: "cannot calculate composition diff"}, - ImpactAnalysis: []dt.XRImpact{}, - }, - }, - }, - validate: func(t *testing.T, format OutputFormat, result string) { - t.Helper() - - if format != OutputFormatJSON { - // YAML shares the JSON tags, so absence of the key is the whole assertion here. - if strings.Count(result, "revisionImpact:") != 1 { - t.Errorf("expected exactly one revisionImpact block in YAML, got:\n%s", result) - } - - return - } - - var parsed compDiffWire - if err := json.Unmarshal([]byte(result), &parsed); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if len(parsed.Compositions) != 2 { - t.Fatalf("expected 2 compositions, got %d", len(parsed.Compositions)) - } - - compared, broken := parsed.Compositions[0], parsed.Compositions[1] - - want := &dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 1} - if diff := gcmp.Diff(want, compared.RevisionImpact); diff != "" { - t.Errorf("compared composition revisionImpact mismatch (-want +got):\n%s", diff) - } - - if broken.RevisionImpact != nil { - t.Errorf("failed composition should omit revisionImpact, got %+v", *broken.RevisionImpact) - } - }, - }, - } -} - -func TestStructuredCompDiffRenderer_RenderCompDiff(t *testing.T) { - formats := []OutputFormat{OutputFormatJSON, OutputFormatYAML} - fixtures := sharedCompDiffFixtures() - - for _, format := range formats { - for _, fixture := range fixtures { - testName := string(format) + "/" + fixture.name - t.Run(testName, func(t *testing.T) { - logger := tu.TestLogger(t, false) - - var buf bytes.Buffer - - opts := DefaultDiffOptions() - opts.Format = format - opts.MinimizeComposition = fixture.minimize - opts.Stdout = &buf - opts.Stderr = &bytes.Buffer{} // discard stderr - - renderer := NewStructuredCompDiffRenderer(logger, opts) - - err := renderer.RenderCompDiff(fixture.output) - if err != nil { - t.Fatalf("RenderCompDiff() failed: %v", err) - } - - fixture.validate(t, format, buf.String()) - }) - } - } -} - func TestDefaultCompDiffRenderer_RenderCompDiff(t *testing.T) { tests := map[string]struct { output *dt.CompDiffOutput @@ -690,56 +421,6 @@ func TestDefaultCompDiffRenderer_RenderCompDiff_TopLevelErrorsNotPrinted(t *test } } -// TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsInOutputOnly verifies -// that top-level errors in CompDiffOutput.Errors are included in the structured -// output but not written to stderr. -func TestStructuredCompDiffRenderer_RenderCompDiff_TopLevelErrorsInOutputOnly(t *testing.T) { - output := &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{}, - Errors: []dt.OutputError{ - {ResourceID: "xbuckets.example.org", Message: "failed to list XRs for composition"}, - {Message: "cluster connection lost"}, - }, - } - - for _, format := range []OutputFormat{OutputFormatJSON, OutputFormatYAML} { - t.Run(string(format), func(t *testing.T) { - logger := tu.TestLogger(t, false) - - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) - - opts := DefaultDiffOptions() - opts.Format = format - opts.Stdout = &stdout - opts.Stderr = &stderr - - renderer := NewStructuredCompDiffRenderer(logger, opts) - - if err := renderer.RenderCompDiff(output); err != nil { - t.Fatalf("RenderCompDiff() failed: %v", err) - } - - stderrOut := stderr.String() - for _, e := range output.Errors { - if strings.Contains(stderrOut, e.Message) { - t.Errorf("Expected stderr not to contain error %q, got: %q", e.Message, stderrOut) - } - } - - // Verify errors are in structured output (stdout) - stdoutStr := stdout.String() - for _, e := range output.Errors { - if !strings.Contains(stdoutStr, e.Message) { - t.Errorf("Expected stdout to contain error message %q, got: %q", e.Message, stdoutStr) - } - } - }) - } -} - func Test_formatXRStatusSummary(t *testing.T) { tests := map[string]struct { changed, unchanged, errors int @@ -780,234 +461,6 @@ func Test_pluralize(t *testing.T) { } } -// TestCompDiffOutput_JSONSchema validates that the internal CompDiffOutput type -// serializes to JSON with the correct schema structure. This includes verifying: -// - compositionChanges field appears with correct type symbol -// - downstreamChanges is present for changed XRs with proper summary counts -// - error field serializes correctly on error impacts -// - nested structures (ImpactAnalysis, AffectedResources) serialize properly -// -// YAML serialization uses the same JSON tags (via sigs.k8s.io/yaml), so if JSON -// serializes correctly, YAML will too. YAML format coverage is provided by -// TestStructuredCompDiffRenderer_RenderCompDiff. -func TestCompDiffOutput_JSONSchema(t *testing.T) { - output := &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{{ - Name: "xbuckets.example.org", - CompositionDiff: &dt.ResourceDiff{ - DiffType: dt.DiffTypeModified, - ResourceName: "xbuckets.example.org", - Gvk: schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1", Kind: "Composition"}, - Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), - Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "apiextensions.crossplane.io/v1", "kind": "Composition"}}), - }, - AffectedResources: dt.AffectedResourcesSummary{Total: 5, WithChanges: 2, Unchanged: 2, WithErrors: 1}, - ImpactAnalysis: []dt.XRImpact{ - { - ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-1"}, - Status: dt.XRStatusChanged, - Diffs: map[string]*dt.ResourceDiff{ - "s3.aws.upbound.io/v1beta1/Bucket//new-bucket": { - DiffType: dt.DiffTypeAdded, - ResourceName: "new-bucket", - Gvk: schema.GroupVersionKind{Group: "s3.aws.upbound.io", Version: "v1beta1", Kind: "Bucket"}, - Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), - }, - "s3.aws.upbound.io/v1beta1/Bucket//existing-bucket": { - DiffType: dt.DiffTypeModified, - ResourceName: "existing-bucket", - Gvk: schema.GroupVersionKind{Group: "s3.aws.upbound.io", Version: "v1beta1", Kind: "Bucket"}, - Current: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), - Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), - }, - }, - }, - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-2"}, Status: dt.XRStatusUnchanged}, - {ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3"}, Status: dt.XRStatusError, Error: &dt.OutputError{ResourceID: "XBucket/bucket-3", Message: "render failed"}}, - }, - }}, - } - - // Test via the structured renderer (JSON) - logger := tu.TestLogger(t, false) - - var jsonBuf bytes.Buffer - - opts := DefaultDiffOptions() - opts.Format = OutputFormatJSON - opts.Stdout = &jsonBuf - opts.Stderr = &bytes.Buffer{} // discard stderr - - jsonRenderer := NewStructuredCompDiffRenderer(logger, opts) - - if err := jsonRenderer.RenderCompDiff(output); err != nil { - t.Fatalf("Failed to render JSON: %v", err) - } - - var parsed compDiffWire - if err := json.Unmarshal(jsonBuf.Bytes(), &parsed); err != nil { - t.Fatalf("Failed to unmarshal: %v", err) - } - - if len(parsed.Compositions) != 1 { - t.Fatalf("Expected 1 composition, got %d", len(parsed.Compositions)) - } - - comp := parsed.Compositions[0] - if comp.AffectedResources.Total != 5 { - t.Errorf("Expected total 5, got %d", comp.AffectedResources.Total) - } - - if len(comp.ImpactAnalysis) != 3 { - t.Errorf("Expected 3 impacts, got %d", len(comp.ImpactAnalysis)) - } - - // Verify compositionChanges in JSON - if comp.CompositionChanges == nil { - t.Error("Expected compositionChanges to be present") - } - - if comp.CompositionChanges.Type != "modified" { - t.Errorf("Expected compositionChanges.type 'modified', got '%s'", comp.CompositionChanges.Type) - } - - // Verify error impact in JSON - if comp.ImpactAnalysis[2].Error != "render failed" { - t.Errorf("Expected error 'render failed', got '%s'", comp.ImpactAnalysis[2].Error) - } - - // Verify changed impact has downstreamChanges in JSON - if comp.ImpactAnalysis[0].DownstreamChanges == nil { - t.Error("Expected downstreamChanges for changed XR") - } - - if comp.ImpactAnalysis[0].DownstreamChanges.Summary.Added != 1 { - t.Errorf("Expected 1 added, got %d", comp.ImpactAnalysis[0].DownstreamChanges.Summary.Added) - } - - if comp.ImpactAnalysis[0].DownstreamChanges.Summary.Modified != 1 { - t.Errorf("Expected 1 modified, got %d", comp.ImpactAnalysis[0].DownstreamChanges.Summary.Modified) - } -} - -func TestXRStatusFiltered_JSON(t *testing.T) { - output := &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{{ - Name: "test-comp", - Scoped: true, - AffectedResources: dt.AffectedResourcesSummary{Total: 3, FilteredByPolicy: 1, FilteredBySelector: 1, FilteredByDeletion: 1}, - ImpactAnalysis: []dt.XRImpact{ - { - ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "manual-xr", Namespace: "ns"}, - Status: dt.XRStatusFiltered, - FilterReason: dt.FilterReasonManualPolicy, - }, - { - ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "selector-xr", Namespace: "ns"}, - Status: dt.XRStatusFiltered, - FilterReason: dt.FilterReasonRevisionSelectorMismatch, - FilterDetail: "compositionRevisionSelector {version: 0.0.1} does not match composition labels {version: 0.0.2}", - }, - { - ObjectReference: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XR", Name: "deleting-xr", Namespace: "ns"}, - Status: dt.XRStatusFiltered, - FilterReason: dt.FilterReasonDeleting, - FilterDetail: "deletionTimestamp: 2026-09-07T11:25:03Z", - }, - }, - }}, - } - - logger := tu.TestLogger(t, false) - - var jsonBuf bytes.Buffer - - opts := DefaultDiffOptions() - opts.Format = OutputFormatJSON - opts.Stdout = &jsonBuf - opts.Stderr = &bytes.Buffer{} - - r := NewStructuredCompDiffRenderer(logger, opts) - if err := r.RenderCompDiff(output); err != nil { - t.Fatalf("RenderCompDiff: %v", err) - } - - var parsed compDiffWire - if err := json.Unmarshal(jsonBuf.Bytes(), &parsed); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if len(parsed.Compositions) != 1 || len(parsed.Compositions[0].ImpactAnalysis) != 3 { - t.Fatalf("expected 1 composition with 3 impacts, got %+v", parsed) - } - - // No impact should carry the removed "filtered_by_policy" status value. - for _, imp := range parsed.Compositions[0].ImpactAnalysis { - if string(imp.Status) != "filtered" { - t.Errorf("status: got %q, want %q", imp.Status, "filtered") - } - - if imp.DownstreamChanges != nil { - t.Errorf("downstreamChanges should be omitted for filtered impacts, got %+v", imp.DownstreamChanges) - } - } - - byName := map[string]xrImpactWire{} - for _, imp := range parsed.Compositions[0].ImpactAnalysis { - byName[imp.Name] = imp - } - - if got, want := string(byName["manual-xr"].FilterReason), "manual_policy"; got != want { - t.Errorf("manual-xr filterReason: got %q, want %q", got, want) - } - - if got, want := string(byName["selector-xr"].FilterReason), "revision_selector_mismatch"; got != want { - t.Errorf("selector-xr filterReason: got %q, want %q", got, want) - } - - if byName["selector-xr"].FilterDetail == "" { - t.Errorf("selector-xr expected a filterDetail hint, got empty") - } - - if got, want := string(byName["deleting-xr"].FilterReason), "deleting"; got != want { - t.Errorf("deleting-xr filterReason: got %q, want %q", got, want) - } - - if got, want := byName["deleting-xr"].FilterDetail, "deletionTimestamp: 2026-09-07T11:25:03Z"; got != want { - t.Errorf("deleting-xr filterDetail: got %q, want %q", got, want) - } - - if got := parsed.Compositions[0].AffectedResources.FilteredBySelector; got != 1 { - t.Errorf("filteredBySelector: got %d, want 1", got) - } - - if got := parsed.Compositions[0].AffectedResources.FilteredByDeletion; got != 1 { - t.Errorf("filteredByDeletion: got %d, want 1", got) - } - - // Default discovery records the same filtered composites but, as in the human output, reports - // them only through the counters. - output.Compositions[0].Scoped = false - jsonBuf.Reset() - - if err := r.RenderCompDiff(output); err != nil { - t.Fatalf("RenderCompDiff (discovery): %v", err) - } - - var discovery compDiffWire - if err := json.Unmarshal(jsonBuf.Bytes(), &discovery); err != nil { - t.Fatalf("unmarshal (discovery): %v", err) - } - - if got := discovery.Compositions[0].ImpactAnalysis; len(got) != 0 { - t.Errorf("discovery impactAnalysis: got %+v, want none", got) - } - - if got := discovery.Compositions[0].AffectedResources; got != output.Compositions[0].AffectedResources { - t.Errorf("discovery affectedResources: got %+v, want %+v", got, output.Compositions[0].AffectedResources) - } -} - func TestXRStatusFiltered_TextRenderer(t *testing.T) { comp := dt.CompositionDiff{ Name: "test-comp", @@ -1360,147 +813,3 @@ func TestCompositionDiff_HasChanges_FilteredOnly(t *testing.T) { t.Errorf("dt.CompositionDiff with only filtered impacts should not be HasChanges()") } } - -// TestStructuredCompDiffRenderer_OutputVersions renders each per-XR status, and a failed composition, -// under each output version, and asserts the whole document plus what reaches stderr. -func TestStructuredCompDiffRenderer_OutputVersions(t *testing.T) { - ref := func(name string) corev1.ObjectReference { - return corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: name} - } - addedBucket := &dt.ResourceDiff{ - DiffType: dt.DiffTypeAdded, - ResourceName: "new-bucket", - Gvk: schema.GroupVersionKind{Group: "s3.aws.upbound.io", Version: "v1beta1", Kind: "Bucket"}, - Desired: bothViews(&un.Unstructured{Object: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}), - } - addedChange := ChangeDetail{ - Type: dt.DiffTypeWordAdded, - APIVersion: "s3.aws.upbound.io/v1beta1", - Kind: "Bucket", - Name: "new-bucket", - Diff: map[string]any{dt.DiffKeySpec: map[string]any{"apiVersion": "s3.aws.upbound.io/v1beta1", "kind": "Bucket"}}, - } - xrErr := &dt.OutputError{ - ResourceID: "XBucket/bucket-3", - Message: "render failed", - ValidationFailures: []dt.ResourceValidationFailure{{APIVersion: "example.org/v1", Kind: "XBucket", Name: "bucket-3", Status: "invalid", Errors: []dt.FieldValidationError{{Type: "schema", Field: "spec.size", Message: "must be positive"}}}}, - } - compErr := &dt.OutputError{ResourceID: "Composition/xbroken.example.org", Message: "cannot fetch composition"} - revisionImpact := dt.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 3} - affected := dt.AffectedResourcesSummary{Total: 4, WithChanges: 1, Unchanged: 1, WithErrors: 1, FilteredBySelector: 1} - - output := &dt.CompDiffOutput{ - Compositions: []dt.CompositionDiff{ - { - Name: "xbuckets.example.org", - Scoped: true, - AffectedResources: affected, - RevisionImpact: revisionImpact, - ImpactAnalysis: []dt.XRImpact{ - {ObjectReference: ref("bucket-1"), Status: dt.XRStatusChanged, Diffs: map[string]*dt.ResourceDiff{"s3.aws.upbound.io/v1beta1/Bucket//new-bucket": addedBucket}}, - {ObjectReference: ref("bucket-2"), Status: dt.XRStatusUnchanged}, - {ObjectReference: ref("bucket-3"), Status: dt.XRStatusError, Error: xrErr}, - {ObjectReference: ref("bucket-4"), Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, - }, - }, - { - Name: "xbroken.example.org", - Error: compErr, - ImpactAnalysis: []dt.XRImpact{}, - }, - }, - // The processor lists each errored XR's OutputError at top level. - Errors: []dt.OutputError{*xrErr}, - } - - cases := map[string]struct { - version OutputVersion - // want is the whole document, as the type of the selected version. - want any - }{ - "DefaultIsV1": { - want: compDiffWire{ - OutputVersion: OutputVersionV1, - Compositions: []compositionDiffWire{ - { - Name: "xbuckets.example.org", - AffectedResources: affected, - RevisionImpact: &revisionImpact, - ImpactAnalysis: []xrImpactWire{ - {ObjectReference: ref("bucket-1"), Status: dt.XRStatusChanged, DownstreamChanges: &DownstreamChanges{Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}}, - {ObjectReference: ref("bucket-2"), Status: dt.XRStatusUnchanged}, - {ObjectReference: ref("bucket-3"), Status: dt.XRStatusError, Error: "render failed"}, - {ObjectReference: ref("bucket-4"), Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch"}, - }, - }, - { - Name: "xbroken.example.org", - Error: "cannot fetch composition", - ImpactAnalysis: []xrImpactWire{}, - }, - }, - // v1 does not list the failed composition here. - Errors: []dt.OutputError{*xrErr}, - }, - }, - "V2": { - version: OutputVersionV2, - want: compDiffWireV2{ - OutputVersion: OutputVersionV2, - Compositions: []compositionDiffWireV2{ - { - Name: "xbuckets.example.org", - AffectedResources: affected, - RevisionImpact: &revisionImpact, - ImpactAnalysis: []xrEntryWire{ - {XR: ref("bucket-1"), Status: dt.XRStatusChanged, Summary: Summary{Added: 1}, Changes: []ChangeDetail{addedChange}}, - {XR: ref("bucket-2"), Status: dt.XRStatusUnchanged, Changes: []ChangeDetail{}}, - {XR: ref("bucket-3"), Status: dt.XRStatusError, Changes: []ChangeDetail{}, Errors: []dt.OutputError{*xrErr}}, - {XR: ref("bucket-4"), Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonRevisionSelectorMismatch, FilterDetail: "selector mismatch", Changes: []ChangeDetail{}}, - }, - }, - { - Name: "xbroken.example.org", - Errors: []dt.OutputError{*compErr}, - ImpactAnalysis: []xrEntryWire{}, - }, - }, - // v2 appends the failed composition, so the list is the union of the document's errors. - Errors: []dt.OutputError{*xrErr, *compErr}, - }, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - var stdout, stderr bytes.Buffer - - opts := DefaultDiffOptions() - opts.Format = OutputFormatJSON - opts.Stdout = &stdout - opts.Stderr = &stderr - - if tc.version != "" { - opts.OutputVersion = tc.version - } - - if err := NewStructuredCompDiffRenderer(tu.TestLogger(t, false), opts).RenderCompDiff(output); err != nil { - t.Fatalf("RenderCompDiff() failed: %v", err) - } - - got := reflect.New(reflect.TypeOf(tc.want)) - if err := json.Unmarshal(stdout.Bytes(), got.Interface()); err != nil { - t.Fatalf("Failed to unmarshal: %v\nOutput: %s", err, stdout.String()) - } - - if diff := gcmp.Diff(tc.want, got.Elem().Interface()); diff != "" { - t.Errorf("structured output mismatch (-want +got):\n%s", diff) - } - - // Errors are in the document only; the command prints the returned error. - if stderr.Len() != 0 { - t.Errorf("want nothing on stderr, got: %q", stderr.String()) - } - }) - } -} diff --git a/internal/diff/renderer/diff_formatter.go b/internal/diff/renderer/diff_formatter.go index 2e46c8d0..db57b411 100644 --- a/internal/diff/renderer/diff_formatter.go +++ b/internal/diff/renderer/diff_formatter.go @@ -49,19 +49,26 @@ type DiffOptions struct { // per composition, omitting the full YAML diff body. Only consumed by the // human-readable composition diff renderer; structured output is unaffected. MinimizeComposition bool - - // OutputVersion selects the schema of structured (JSON/YAML) output. Empty means - // OutputVersionV1. The human-readable diff format ignores it. - OutputVersion OutputVersion } +// OutputFormat represents the desired output format for diffs. +type OutputFormat string + +const ( + // OutputFormatDiff is the default human-readable diff format. + OutputFormatDiff OutputFormat = "diff" + // OutputFormatJSON outputs structured JSON. + OutputFormatJSON OutputFormat = "json" + // OutputFormatYAML outputs structured YAML. + OutputFormatYAML OutputFormat = "yaml" +) + // DefaultDiffOptions returns the default options with colors enabled. func DefaultDiffOptions() DiffOptions { return DiffOptions{ Stdout: os.Stdout, Stderr: os.Stderr, Format: OutputFormatDiff, - OutputVersion: OutputVersionV1, UseColors: true, AddPrefix: "+ ", DeletePrefix: "- ", diff --git a/internal/diff/renderer/dry_run_warnings_test.go b/internal/diff/renderer/dry_run_warnings_test.go index c982d02c..6a637a3b 100644 --- a/internal/diff/renderer/dry_run_warnings_test.go +++ b/internal/diff/renderer/dry_run_warnings_test.go @@ -2,11 +2,9 @@ package renderer import ( "bytes" - "encoding/json" "strings" "testing" - "github.com/google/go-cmp/cmp" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -42,8 +40,7 @@ func diffMap(diffs ...*dt.ResourceDiff) map[string]*dt.ResourceDiff { } // TestRenderers_EmitDryRunWarnings covers every entry point a command renders through. Each derives the -// summary from what it is handed, writes it to stderr in every output mode, and adds it to warnings[] in -// structured modes after the warnings raised during the run. The summary spans inputs, so two XRs' +// summary from what it is handed and writes it to stderr. The summary spans inputs, so two XRs' // additions are counted together; a failed XR shows no diffs and contributes nothing. func TestRenderers_EmitDryRunWarnings(t *testing.T) { raised := dt.OutputWarning{Message: "raised during the run"} @@ -80,8 +77,6 @@ func TestRenderers_EmitDryRunWarnings(t *testing.T) { tests := map[string]struct { format OutputFormat render renderFn - // structured is false for the human renderers, which have no warnings[] to check. - structured bool }{ "XRText": { format: OutputFormatDiff, @@ -89,13 +84,6 @@ func TestRenderers_EmitDryRunWarnings(t *testing.T) { return NewDiffRenderer(tu.TestLogger(t, false), opts).RenderDiffs(xrGroups, nil, []dt.OutputWarning{raised}) }, }, - "XRJSON": { - format: OutputFormatJSON, - structured: true, - render: func(opts DiffOptions) error { - return NewStructuredDiffRenderer(tu.TestLogger(t, false), opts).RenderDiffs(xrGroups, nil, []dt.OutputWarning{raised}) - }, - }, "CompText": { format: OutputFormatDiff, render: func(opts DiffOptions) error { @@ -103,13 +91,6 @@ func TestRenderers_EmitDryRunWarnings(t *testing.T) { return NewDefaultCompDiffRenderer(logger, NewDiffRenderer(logger, opts), opts).RenderCompDiff(compOutput()) }, }, - "CompJSON": { - format: OutputFormatJSON, - structured: true, - render: func(opts DiffOptions) error { - return NewStructuredCompDiffRenderer(tu.TestLogger(t, false), opts).RenderCompDiff(compOutput()) - }, - }, } for name, tc := range tests { @@ -137,22 +118,6 @@ func TestRenderers_EmitDryRunWarnings(t *testing.T) { if strings.Contains(stderr.String(), raised.FormatWarning()) { t.Errorf("stderr repeats a warning raised during the run:\n%s", stderr.String()) } - - if !tc.structured { - return - } - - var out struct { - Warnings []dt.OutputWarning `json:"warnings"` - } - - if err := json.Unmarshal(stdout.Bytes(), &out); err != nil { - t.Fatalf("cannot parse structured output: %v\n%s", err, stdout.String()) - } - - if d := cmp.Diff([]dt.OutputWarning{raised, summary}, out.Warnings); d != "" { - t.Errorf("warnings[] mismatch (-want +got):\n%s", d) - } }) } } diff --git a/internal/diff/renderer/structured_renderer.go b/internal/diff/renderer/structured_renderer.go deleted file mode 100644 index c8fe4de4..00000000 --- a/internal/diff/renderer/structured_renderer.go +++ /dev/null @@ -1,514 +0,0 @@ -package renderer - -import ( - "cmp" - "encoding/json" - "maps" - "reflect" - "slices" - - corev1 "k8s.io/api/core/v1" - sigsyaml "sigs.k8s.io/yaml" - - "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - - dt "github.com/crossplane/cli/v2/internal/diff/result" -) - -// OutputFormat represents the desired output format for diffs. -type OutputFormat string - -const ( - // OutputFormatDiff is the default human-readable diff format. - OutputFormatDiff OutputFormat = "diff" - // OutputFormatJSON outputs structured JSON. - OutputFormatJSON OutputFormat = "json" - // OutputFormatYAML outputs structured YAML. - OutputFormatYAML OutputFormat = "yaml" -) - -// OutputVersion selects the schema of structured (JSON/YAML) output. Every structured document -// names its version in its top-level outputVersion field, so a consumer can detect the shape. -type OutputVersion string - -const ( - // OutputVersionV1 is the original schema: xr emits the deprecated flat changes[] beside xrs[], and - // comp's impactAnalysis[] entries inline the XR's identity, wrap changes in downstreamChanges and - // carry a per-XR (and per-composition) error string. - OutputVersionV1 OutputVersion = "v1" - // OutputVersionV2 is the converged schema: xrs[] and impactAnalysis[] hold the same entry type - // (xrEntryWire), xr drops the flat changes[], and comp carries structured errors[] per XR and per - // composition, with failed compositions also reported in the top-level errors[]. - OutputVersionV2 OutputVersion = "v2" -) - -// OutputError is an alias for dt.OutputError for convenience. -// Use this type for error handling in structured output. -type OutputError = dt.OutputError - -// StructuredDiffOutput represents the structured output format for diffs. -// Note: Only JSON tags are used because sigs.k8s.io/yaml uses JSON tags for YAML serialization. -// -// This is the v1 document (OutputVersionV1): the v2 document (structuredDiffOutputV2) plus the -// deprecated flat Changes. It is only ever derived from the v2 one, by structuredDiffOutputV2.v1. -type StructuredDiffOutput struct { - // OutputVersion names the schema of this document. - OutputVersion OutputVersion `json:"outputVersion"` - - // Summary counts exactly what Changes lists: each distinct change once, however many input XRs - // reach it. Per-input counts are on each Xrs entry; where inputs overlap those can sum to more. - Summary Summary `json:"summary"` - - // Changes is the flat, ungrouped list of all resource changes across every - // input XR. - // - // Deprecated: use Xrs for per-input-XR grouping. This field is retained for - // backward compatibility and will be removed in a future major release. - Changes []ChangeDetail `json:"changes"` - - // Errors is the union of resource-processing errors across all input XRs - // (the top-level/global error list). Each errored XR also surfaces its - // error inside its own Xrs entry; this list stays complete for consumers - // that read errors here. - Errors []dt.OutputError `json:"errors,omitempty"` - - // Xrs groups changes by the input XR/claim that produced them, one entry - // per input in input order. This is the recommended view; the flat Changes - // field above is deprecated. - Xrs []xrEntryWire `json:"xrs"` - - // Warnings is the list of non-fatal advisories raised during the run — things worth knowing that - // did not invalidate the diff. Unlike Errors they do NOT affect the exit code, so a consumer - // gating on failure should read Errors. Warnings are not grouped per input XR because they - // originate deep in the call stack, below the point where the owning input is known. They are - // sorted by message, then context, followed by the renderer's summary of unverified additions (see - // dt.DryRunWarnings). - Warnings []dt.OutputWarning `json:"warnings,omitempty"` -} - -// structuredDiffOutputV2 is the v2 (OutputVersionV2) xr document, and the one the renderer always -// builds: v1 is derived from it (see v1). Its xrs[] entries are the xrEntryWire that comp's -// impactAnalysis[] also holds. -type structuredDiffOutputV2 struct { - OutputVersion OutputVersion `json:"outputVersion"` - // Summary counts each distinct change once, however many input XRs reach it, as in v1. - Summary Summary `json:"summary"` - Errors []dt.OutputError `json:"errors,omitempty"` - Xrs []xrEntryWire `json:"xrs"` - Warnings []dt.OutputWarning `json:"warnings,omitempty"` -} - -// v1 projects the document to v1, which differs only by adding the deprecated flat changes[] (see -// flatChangeSet). That list is passed in because it is not recoverable from the document: it holds -// each distinct change once, however many input XRs reach it. -func (d structuredDiffOutputV2) v1(changes []ChangeDetail) StructuredDiffOutput { - return StructuredDiffOutput{ - OutputVersion: OutputVersionV1, - Summary: d.Summary, - Changes: changes, - Errors: d.Errors, - Xrs: d.Xrs, - Warnings: d.Warnings, - } -} - -// xrEntryWire is the per-XR entry, shared by xr's xrs[] (in both versions) and comp's v2 -// impactAnalysis[], so one parser handles both. comp's v1 entry is projected from it (see -// xrEntryWire.v1). Build it only with toXREntry. -// -// XR is projected to apiVersion/kind/name/namespace (see toXREntry). Summary and Changes are always -// present, zero and empty unless Status is changed. FilterReason and FilterDetail are set only by comp, -// for a filtered XR. Errors is set only when Status is error, and holds the same OutputError the -// command lists in its top-level errors[]. -type xrEntryWire struct { - XR corev1.ObjectReference `json:"xr"` - Status dt.XRStatus `json:"status"` - FilterReason dt.FilterReason `json:"filterReason,omitempty"` - FilterDetail string `json:"filterDetail,omitempty"` - Summary Summary `json:"summary"` - Changes []ChangeDetail `json:"changes"` - Errors []dt.OutputError `json:"errors,omitempty"` -} - -// toXREntry builds the per-XR entry both commands emit. An errored XR carries err; a changed XR its -// non-equal diffs. Other statuses get the zero summary and an empty change list. -func toXREntry(ref corev1.ObjectReference, status dt.XRStatus, diffs map[string]*dt.ResourceDiff, err *dt.OutputError) xrEntryWire { - // Project to exactly apiVersion/kind/name/namespace. We use ObjectReference as the type (so - // consumers can unmarshal "xr" into the platform-standard struct), but deliberately omit its other - // fields: fieldPath is meaningless for an XR; uid is unstable across delete/recreate and would - // mislead consumers tracking an XR across runs (stable identity is kind/namespace/name); and - // resourceVersion, if ever wanted, is a property of the diff run, not per-XR identity. - entry := xrEntryWire{ - XR: corev1.ObjectReference{ - APIVersion: ref.APIVersion, - Kind: ref.Kind, - Name: ref.Name, - Namespace: ref.Namespace, - }, - Status: status, - Changes: []ChangeDetail{}, - } - - switch status { - case dt.XRStatusChanged: - entry.Summary, entry.Changes = buildChangeSet(diffs) - case dt.XRStatusError: - if err != nil { - entry.Errors = []dt.OutputError{*err} - } - case dt.XRStatusUnchanged, dt.XRStatusFiltered: - // No changes or errors to report. - } - - return entry -} - -// xrGroupStatus derives an xr input's status: error if it failed, changed if it has any non-equal -// diff, unchanged otherwise. -func xrGroupStatus(g dt.XRDiffGroup) dt.XRStatus { - switch { - case g.Err != nil: - return dt.XRStatusError - case slices.ContainsFunc(slices.Collect(maps.Values(g.Diffs)), func(d *dt.ResourceDiff) bool { return d.DiffType != dt.DiffTypeEqual }): - return dt.XRStatusChanged - default: - return dt.XRStatusUnchanged - } -} - -// buildXREntries converts the processor's per-input-XR groups into v2 xrs[] entries, in input order. -func buildXREntries(groups []dt.XRDiffGroup) []xrEntryWire { - out := make([]xrEntryWire, 0, len(groups)) - - for _, g := range groups { - ref := corev1.ObjectReference{APIVersion: g.XR.APIVersion, Kind: g.XR.Kind, Name: g.XR.Name, Namespace: g.XR.Namespace} - out = append(out, toXREntry(ref, xrGroupStatus(g), g.Diffs, g.Err)) - } - - return out -} - -// Summary contains aggregated counts of changes. -type Summary struct { - Added int `json:"added"` - Modified int `json:"modified"` - Removed int `json:"removed"` -} - -// increment bumps the counter for the given diff type. Equal (and any unknown) -// types are no-ops, since equal diffs are excluded from structured output; the -// explicit case keeps the exhaustive-switch linter satisfied. -func (s *Summary) increment(t dt.DiffType) { - switch t { - case dt.DiffTypeAdded: - s.Added++ - case dt.DiffTypeModified: - s.Modified++ - case dt.DiffTypeRemoved: - s.Removed++ - case dt.DiffTypeEqual: - // Equal diffs are not counted. - } -} - -// ChangeDetail represents a single resource change. -// -// This is the shared per-resource wire shape for both commands and both versions: it is reached -// via xrEntryWire.Changes, and in comp's v1 via DownstreamChanges.Changes. A field added here -// therefore surfaces in every output without per-command plumbing. -type ChangeDetail struct { - Type string `json:"type"` - APIVersion string `json:"apiVersion"` - Kind string `json:"kind"` - Name string `json:"name"` - Namespace string `json:"namespace,omitempty"` - Diff map[string]any `json:"diff"` - - // DryRun is present only when this resource's desired state was not - // verified against the apiserver. See dt.DryRunInfo for why absence is the - // success case and what it does not promise for removals. - DryRun *dt.DryRunInfo `json:"dryRun,omitempty"` -} - -// --- JSON Output Types (used by StructuredCompDiffRenderer) --- -// Note: Only JSON tags are used because sigs.k8s.io/yaml uses JSON tags for YAML serialization. - -// compDiffWire is the serialized (JSON/YAML) v1 (OutputVersionV1) shape for composition diffs; -// compDiffWireV2 is the v2 one. -type compDiffWire struct { - OutputVersion OutputVersion `json:"outputVersion"` - Compositions []compositionDiffWire `json:"compositions"` - Errors []dt.OutputError `json:"errors,omitempty"` - Warnings []dt.OutputWarning `json:"warnings,omitempty"` -} - -type compositionDiffWire struct { - Name string `json:"name"` - Error string `json:"error,omitempty"` - CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` - AffectedResources dt.AffectedResourcesSummary `json:"affectedResources"` - ImpactAnalysis []xrImpactWire `json:"impactAnalysis"` - // ImpactAnalysisSkipped tells consumers that no changed/unchanged/errored verdict was reached for - // the composites that would adopt the resulting revision, rather than that none were found. It - // does not imply impactAnalysis is empty: composites excluded by update policy, revision selector - // or deletion are identified without rendering, so they are still reported here and in - // affectedResources (issue #478). Read revisionImpact.changeScope alongside it to tell "nothing - // could have changed" from "a revision is created but was not evaluated". - ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` - // MaskedChangesOnly tells consumers that an absent compositionChanges does not mean the - // composition is unchanged: it differs only in fields excluded from the diff. - MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` - // RevisionImpact is what applying this composition does to CompositionRevisions, independent of - // whether anything renders differently. Present whenever the comparison completed — including when - // impactAnalysis was skipped, which is the point of it — and absent for a composition that failed - // to process, where the `error` field says why nothing about the revision was determined. - RevisionImpact *dt.RevisionImpact `json:"revisionImpact,omitempty"` -} - -// compDiffWireV2 is the v2 (OutputVersionV2) composition diff document. Its Errors also lists each -// failed composition's error, which v1 reports only on the composition. -type compDiffWireV2 struct { - OutputVersion OutputVersion `json:"outputVersion"` - Compositions []compositionDiffWireV2 `json:"compositions"` - Errors []dt.OutputError `json:"errors,omitempty"` - Warnings []dt.OutputWarning `json:"warnings,omitempty"` -} - -// compositionDiffWireV2 is compositionDiffWire with the per-composition error as structured errors[] -// and impactAnalysis[] entries of the shared xrEntryWire type. See compositionDiffWire for the -// meaning of the other fields. -type compositionDiffWireV2 struct { - Name string `json:"name"` - Errors []dt.OutputError `json:"errors,omitempty"` - CompositionChanges *ChangeDetail `json:"compositionChanges,omitempty"` - AffectedResources dt.AffectedResourcesSummary `json:"affectedResources"` - ImpactAnalysis []xrEntryWire `json:"impactAnalysis"` - ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` - MaskedChangesOnly bool `json:"maskedChangesOnly,omitempty"` - RevisionImpact *dt.RevisionImpact `json:"revisionImpact,omitempty"` -} - -// xrImpactWire is the v1 (OutputVersionV1) impactAnalysis[] entry; v2 uses xrEntryWire instead. -type xrImpactWire struct { - corev1.ObjectReference `json:",inline"` - - Status dt.XRStatus `json:"status"` - FilterReason dt.FilterReason `json:"filterReason,omitempty"` - FilterDetail string `json:"filterDetail,omitempty"` - Error string `json:"error,omitempty"` - DownstreamChanges *DownstreamChanges `json:"downstreamChanges,omitempty"` -} - -// DownstreamChanges contains the downstream resource changes for an XR. It is part of the v1 -// (OutputVersionV1) impactAnalysis[] entry only; v2's xrEntryWire carries summary and changes directly. -type DownstreamChanges struct { - Summary Summary `json:"summary"` - Changes []ChangeDetail `json:"changes"` -} - -// StructuredDiffRenderer renders diffs in structured formats (JSON/YAML). -type StructuredDiffRenderer struct { - logger logging.Logger - opts DiffOptions -} - -// NewStructuredDiffRenderer creates a new structured renderer with the specified format. -func NewStructuredDiffRenderer(logger logging.Logger, opts DiffOptions) DiffRenderer { - return &StructuredDiffRenderer{ - logger: logger, - opts: opts, - } -} - -// RenderDiffs renders the diffs in the configured structured format. -// -// It emits two views built from the same groups: the deprecated flat -// changes[]/summary (merged across all groups) for backward compatibility, and -// the per-input-XR xrs[] grouping. The top-level errors[] is the union passed -// by the caller. -func (r *StructuredDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.OutputError, warnings []dt.OutputWarning) error { - r.logger.Debug("Rendering diffs in structured format", - "format", r.opts.Format, - "groupCount", len(groups), - "errorCount", len(errs), - "warningCount", len(warnings)) - - // Flat, deprecated view: every input's changes, one copy of each distinct change (see - // flatChangeSet). The summary counts exactly what changes[] lists. - summary, changes := flatChangeSet(groups) - - // The summary of unverified additions is derived here, from the diffs, and follows the warnings - // raised during the run rather than being interleaved with them. See the DiffRenderer interface. - dryRun := dt.XRDryRunWarnings(groups) - allWarnings := append(slices.Clone(warnings), dryRun...) - - // Grouped view: one entry per input XR, in input order, each complete for its input. Where inputs - // overlap, a shared resource appears under each, so the xrs[] summaries can sum to more than the - // top-level summary, which counts resources rather than inputs' views of them. - v2 := structuredDiffOutputV2{ - OutputVersion: OutputVersionV2, - Summary: summary, - Errors: errs, - Xrs: buildXREntries(groups), - Warnings: allWarnings, - } - - // Anything but v2, including the empty value, is v1. - var output any = v2 - if r.opts.OutputVersion != OutputVersionV2 { - output = v2.v1(changes) - } - - var ( - data []byte - err error - ) - - switch r.opts.Format { - case OutputFormatJSON: - data, err = json.MarshalIndent(output, "", " ") - case OutputFormatYAML: - data, err = sigsyaml.Marshal(output) - case OutputFormatDiff: - return errors.Errorf("unsupported output format for structured renderer: %s", r.opts.Format) - } - - if err != nil { - return errors.Wrap(err, "failed to marshal diff output") - } - - _, err = r.opts.Stdout.Write(data) - if err != nil { - return errors.Wrap(err, "failed to write structured output") - } - - // Add newline for cleaner terminal output - _, err = r.opts.Stdout.Write([]byte("\n")) - if err != nil { - return errors.Wrap(err, "failed to write newline") - } - - // Errors are in the structured output, and the command prints the error the processor returns, - // so they are not written to stderr here. Of the warnings, only the ones derived here are - // written: the rest already went to stderr when they were raised. - return writeWarnings(r.opts.Stderr, dryRun) -} - -// buildChangeSet walks a diff map into the (summary, changes) pair shared by -// every structured view: the flat top-level changes[], each xrs[] entry, and -// each comp downstreamChanges block. Equal diffs are skipped (they contribute -// to neither the summary nor the change list). Changes are sorted by the -// canonical order (see diffSortFunc) for deterministic output. -func buildChangeSet(diffs map[string]*dt.ResourceDiff) (Summary, []ChangeDetail) { - sorted := slices.AppendSeq(make([]*dt.ResourceDiff, 0, len(diffs)), maps.Values(diffs)) - slices.SortFunc(sorted, diffSortFunc) - - summary := Summary{} - changes := make([]ChangeDetail, 0, len(sorted)) - - for _, diff := range sorted { - if diff.DiffType == dt.DiffTypeEqual { - continue - } - - summary.increment(diff.DiffType) - changes = append(changes, *resourceDiffToChangeDetail(diff)) - } - - return summary, changes -} - -// flatChangeSet builds the deprecated flat view: every group's non-equal changes, in canonical order -// (see diffSortFunc), keeping one copy of a change identical to one already listed for the same -// resource. It is lossless on its own terms: two inputs that render one resource differently are both -// listed, rather than one silently replacing the other in a map keyed by resource (issue #476). That -// keeps the flat view correct whatever the input validator allows, including a validator that treats -// inputs individually, where they may legitimately disagree about a resource. Identical renderings, the -// same change reached twice, still count once. -func flatChangeSet(groups []dt.XRDiffGroup) (Summary, []ChangeDetail) { - var all []*dt.ResourceDiff - - for _, g := range groups { - for _, d := range g.Diffs { - if d.DiffType != dt.DiffTypeEqual { - all = append(all, d) - } - } - } - - // Stable, so that differing changes to one resource stay in input order. - slices.SortStableFunc(all, diffSortFunc) - - summary := Summary{} - changes := make([]ChangeDetail, 0, len(all)) - listed := make(map[string][]ChangeDetail, len(all)) - - for _, d := range all { - change := *resourceDiffToChangeDetail(d) - key := d.GetDiffKey() - - if slices.ContainsFunc(listed[key], func(c ChangeDetail) bool { return reflect.DeepEqual(c, change) }) { - continue - } - - listed[key] = append(listed[key], change) - summary.increment(d.DiffType) - changes = append(changes, change) - } - - return summary, changes -} - -// diffSortFunc is the canonical ordering for structured change lists: primarily -// by the human-meaningful Kind/Name (matching the human renderer's getKindName -// sort), with the full apiVersion/kind/namespace/name key as a stable -// tiebreaker so same-kind/same-name resources in different namespaces still -// sort deterministically. -func diffSortFunc(a, b *dt.ResourceDiff) int { - return cmp.Or( - cmp.Compare(a.Gvk.Kind, b.Gvk.Kind), - cmp.Compare(a.ResourceName, b.ResourceName), - cmp.Compare(a.GetDiffKey(), b.GetDiffKey()), - ) -} - -// resourceDiffToChangeDetail converts a ResourceDiff to a ChangeDetail for -// structured (JSON/YAML) output. -// -// It reads the pre-cleaned views populated during diff generation, so -// --ignore-paths and the unconditional-cleanup fields are already stripped; -// the renderer performs no cleanup of its own. -func resourceDiffToChangeDetail(diff *dt.ResourceDiff) *ChangeDetail { - change := &ChangeDetail{ - Type: diff.DiffType.ToWord(), - APIVersion: diff.Gvk.GroupVersion().String(), - Kind: diff.Gvk.Kind, - Name: diff.ResourceName, - Namespace: diff.Namespace, - Diff: make(map[string]any), - // Nil for every resource whose desired state reached the apiserver, which - // omitempty turns into an absent key — see dt.DryRunInfo. - DryRun: diff.DryRun, - } - - switch diff.DiffType { - case dt.DiffTypeAdded: - if diff.Desired.Clean != nil { - change.Diff[dt.DiffKeySpec] = diff.Desired.Clean.Object - } - case dt.DiffTypeRemoved: - if diff.Current.Clean != nil { - change.Diff[dt.DiffKeySpec] = diff.Current.Clean.Object - } - case dt.DiffTypeModified: - if diff.Current.Clean != nil && diff.Desired.Clean != nil { - change.Diff[dt.DiffKeyOld] = diff.Current.Clean.Object - change.Diff[dt.DiffKeyNew] = diff.Desired.Clean.Object - } - case dt.DiffTypeEqual: - // Equal diffs have no detail to show - } - - return change -} diff --git a/internal/diff/renderer/structured_renderer_test.go b/internal/diff/renderer/structured_renderer_test.go deleted file mode 100644 index e7a5c6ba..00000000 --- a/internal/diff/renderer/structured_renderer_test.go +++ /dev/null @@ -1,847 +0,0 @@ -package renderer - -import ( - "bytes" - "encoding/json" - "maps" - "reflect" - "slices" - "strings" - "testing" - - "github.com/google/go-cmp/cmp" - corev1 "k8s.io/api/core/v1" - un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - sigsyaml "sigs.k8s.io/yaml" - - "github.com/crossplane/cli/v2/internal/diff/compare" - dt "github.com/crossplane/cli/v2/internal/diff/result" - tu "github.com/crossplane/cli/v2/internal/diff/testutils" -) - -// testDiffFixture defines a reusable test case with input diffs and expected output. -type testDiffFixture struct { - name string - diffs map[string]*dt.ResourceDiff - errs []dt.OutputError - expectedSummary Summary - expectedChanges int - // Optional: additional validation beyond summary/changes count - validate func(t *testing.T, output *StructuredDiffOutput) -} - -// sharedDiffFixtures returns test fixtures that should be run through both JSON and YAML renderers. -func sharedDiffFixtures() []testDiffFixture { - // Create reusable test resources - addedResource := tu.NewResource("nop.crossplane.io/v1alpha1", "NopResource", "new-resource"). - InNamespace("default"). - WithSpec(map[string]any{ - "forProvider": map[string]any{ - "field": "value", - }, - }). - Build() - - modifiedCurrentResource := tu.NewResource("example.org/v1alpha1", "XExample", "modified-resource"). - InNamespace("production"). - WithSpec(map[string]any{ - "oldValue": "something", - }). - Build() - - modifiedDesiredResource := tu.NewResource("example.org/v1alpha1", "XExample", "modified-resource"). - InNamespace("production"). - WithSpec(map[string]any{ - "newValue": "something-else", - }). - Build() - - removedResource := tu.NewResource("example.org/v1alpha1", "XNopResource", "removed-resource"). - WithSpec(map[string]any{ - "coolField": "goodbye", - }). - Build() - - return []testDiffFixture{ - { - name: "AllDiffTypes", - diffs: map[string]*dt.ResourceDiff{ - "added": { - DiffType: dt.DiffTypeAdded, - ResourceName: "new-resource", - Namespace: "default", - Gvk: schema.GroupVersionKind{ - Group: "nop.crossplane.io", - Version: "v1alpha1", - Kind: "NopResource", - }, - Desired: dt.ResourceViews{Raw: addedResource, Clean: addedResource}, - }, - "modified": { - DiffType: dt.DiffTypeModified, - ResourceName: "modified-resource", - Namespace: "production", - Gvk: schema.GroupVersionKind{ - Group: "example.org", - Version: "v1alpha1", - Kind: "XExample", - }, - Current: dt.ResourceViews{Raw: modifiedCurrentResource, Clean: modifiedCurrentResource}, - Desired: dt.ResourceViews{Raw: modifiedDesiredResource, Clean: modifiedDesiredResource}, - }, - "removed": { - DiffType: dt.DiffTypeRemoved, - ResourceName: "removed-resource", - Gvk: schema.GroupVersionKind{ - Group: "example.org", - Version: "v1alpha1", - Kind: "XNopResource", - }, - Current: dt.ResourceViews{Raw: removedResource, Clean: removedResource}, - }, - "equal": { - DiffType: dt.DiffTypeEqual, - ResourceName: "unchanged-resource", - Gvk: schema.GroupVersionKind{ - Kind: "NopResource", - }, - }, - }, - expectedSummary: Summary{Added: 1, Modified: 1, Removed: 1}, - expectedChanges: 3, // equal should be excluded - validate: func(t *testing.T, output *StructuredDiffOutput) { - t.Helper() - - // Find and verify added resource - var addedChange *ChangeDetail - - for i := range output.Changes { - if output.Changes[i].Type == dt.DiffTypeWordAdded { - addedChange = &output.Changes[i] - break - } - } - - if addedChange == nil { - t.Fatal("Added resource not found in changes") - } - - if addedChange.Kind != "NopResource" { - t.Errorf("Expected Kind 'NopResource', got '%s'", addedChange.Kind) - } - - if addedChange.Name != "new-resource" { - t.Errorf("Expected Name 'new-resource', got '%s'", addedChange.Name) - } - - if addedChange.Namespace != "default" { - t.Errorf("Expected Namespace 'default', got '%s'", addedChange.Namespace) - } - }, - }, - { - name: "EmptyDiffs", - diffs: map[string]*dt.ResourceDiff{}, - expectedSummary: Summary{Added: 0, Modified: 0, Removed: 0}, - expectedChanges: 0, - }, - { - name: "OnlyEqualDiffs", - diffs: map[string]*dt.ResourceDiff{ - "equal1": { - DiffType: dt.DiffTypeEqual, - ResourceName: "resource-1", - Gvk: schema.GroupVersionKind{Kind: "NopResource"}, - }, - "equal2": { - DiffType: dt.DiffTypeEqual, - ResourceName: "resource-2", - Gvk: schema.GroupVersionKind{Kind: "NopResource"}, - }, - }, - expectedSummary: Summary{Added: 0, Modified: 0, Removed: 0}, - expectedChanges: 0, // equal diffs should be excluded - }, - { - name: "WithErrors", - diffs: map[string]*dt.ResourceDiff{}, - errs: []dt.OutputError{ - {ResourceID: "example.org/v1/XResource/my-xr", Message: "failed to render XR: missing composition"}, - {Message: "cluster connection timeout"}, - }, - expectedSummary: Summary{Added: 0, Modified: 0, Removed: 0}, - expectedChanges: 0, - validate: func(t *testing.T, output *StructuredDiffOutput) { - t.Helper() - - if len(output.Errors) != 2 { - t.Fatalf("Expected 2 errors, got %d", len(output.Errors)) - } - - if output.Errors[0].ResourceID != "example.org/v1/XResource/my-xr" { - t.Errorf("Expected first error ResourceID 'example.org/v1/XResource/my-xr', got '%s'", output.Errors[0].ResourceID) - } - - if output.Errors[1].Message != "cluster connection timeout" { - t.Errorf("Expected second error message 'cluster connection timeout', got '%s'", output.Errors[1].Message) - } - }, - }, - } -} - -func TestStructuredDiffRenderer_RenderDiffs(t *testing.T) { - formats := []OutputFormat{OutputFormatJSON, OutputFormatYAML} - fixtures := sharedDiffFixtures() - - for _, format := range formats { - for _, fixture := range fixtures { - testName := string(format) + "/" + fixture.name - t.Run(testName, func(t *testing.T) { - logger := tu.TestLogger(t, false) - - var buf bytes.Buffer - - opts := DefaultDiffOptions() - opts.Format = format - opts.Stdout = &buf - opts.Stderr = &bytes.Buffer{} // discard stderr for these tests - - renderer := NewStructuredDiffRenderer(logger, opts) - - err := renderer.RenderDiffs(identitylessGroups(fixture.diffs), fixture.errs, nil) - if err != nil { - t.Fatalf("RenderDiffs() failed: %v", err) - } - - // Parse the output based on format - var output StructuredDiffOutput - - switch format { - case OutputFormatJSON: - if err := json.Unmarshal(buf.Bytes(), &output); err != nil { - t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, buf.String()) - } - case OutputFormatYAML: - if err := sigsyaml.Unmarshal(buf.Bytes(), &output); err != nil { - t.Fatalf("Failed to parse YAML output: %v\nOutput: %s", err, buf.String()) - } - case OutputFormatDiff: - t.Fatal("OutputFormatDiff should not be used with StructuredDiffRenderer") - } - - // Verify summary - if output.Summary.Added != fixture.expectedSummary.Added { - t.Errorf("Summary.Added = %d, want %d", output.Summary.Added, fixture.expectedSummary.Added) - } - - if output.Summary.Modified != fixture.expectedSummary.Modified { - t.Errorf("Summary.Modified = %d, want %d", output.Summary.Modified, fixture.expectedSummary.Modified) - } - - if output.Summary.Removed != fixture.expectedSummary.Removed { - t.Errorf("Summary.Removed = %d, want %d", output.Summary.Removed, fixture.expectedSummary.Removed) - } - - // Verify change count - if len(output.Changes) != fixture.expectedChanges { - t.Errorf("len(Changes) = %d, want %d", len(output.Changes), fixture.expectedChanges) - } - - // Verify errors round-trip if present - if len(fixture.errs) > 0 { - if diff := cmp.Diff(fixture.errs, output.Errors); diff != "" { - t.Errorf("Errors mismatch (-want +got):\n%s", diff) - } - } - - // Run additional validation if provided - if fixture.validate != nil { - fixture.validate(t, &output) - } - }) - } - } -} - -// TestStructuredDiffRenderer_GroupsByXR verifies the per-input-XR xrs[] view is -// built alongside the (deprecated) flat changes[]/summary view. -func TestStructuredDiffRenderer_GroupsByXR(t *testing.T) { - changed := &dt.ResourceDiff{ - DiffType: dt.DiffTypeModified, - ResourceName: "bucket-a", - Namespace: "default", - Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, - Current: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "bucket-a"). - WithSpec(map[string]any{"region": "us-east-1"}).Build()}, - Desired: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "bucket-a"). - WithSpec(map[string]any{"region": "us-west-2"}).Build()}, - } - - // Group whose only diff is equal -> unchanged, empty changes. - equalOnly := &dt.ResourceDiff{ - DiffType: dt.DiffTypeEqual, - ResourceName: "bucket-b", - Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, - } - - // Two XRs that both render Bucket/shared: same diff key, different desired - // state. See TestStructuredDiffRenderer_GroupsByXR/SameKeyFromTwoXRs. - sharedDiff := func(newRegion string) *dt.ResourceDiff { - return &dt.ResourceDiff{ - DiffType: dt.DiffTypeModified, - ResourceName: "shared", - Namespace: "default", - Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, - Current: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "shared"). - WithSpec(map[string]any{"region": "us-east-1"}).Build()}, - Desired: dt.ResourceViews{Clean: tu.NewResource("example.org/v1", "Bucket", "shared"). - WithSpec(map[string]any{"region": newRegion}).Build()}, - } - } - - xrRef := func(name string) corev1.ObjectReference { - return corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XBucket", Name: name, Namespace: "default"} - } - bucketChange := ChangeDetail{ - Type: dt.DiffTypeWordModified, - APIVersion: "example.org/v1", - Kind: "Bucket", - Name: "bucket-a", - Namespace: "default", - Diff: map[string]any{ - dt.DiffKeyOld: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "bucket-a"}, "spec": map[string]any{"region": "us-east-1"}}, - dt.DiffKeyNew: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "bucket-a"}, "spec": map[string]any{"region": "us-west-2"}}, - }, - } - sharedChange := func(newRegion string) ChangeDetail { - return ChangeDetail{ - Type: dt.DiffTypeWordModified, - APIVersion: "example.org/v1", - Kind: "Bucket", - Name: "shared", - Namespace: "default", - Diff: map[string]any{ - dt.DiffKeyOld: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "shared"}, "spec": map[string]any{"region": "us-east-1"}}, - dt.DiffKeyNew: map[string]any{"apiVersion": "example.org/v1", "kind": "Bucket", "metadata": map[string]any{"name": "shared"}, "spec": map[string]any{"region": newRegion}}, - }, - } - } - - unionErrs := []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}} - - cases := map[string]struct { - groups []dt.XRDiffGroup - errs []dt.OutputError - version OutputVersion - // The whole output asserted as one value, as the document type of the selected version - // (StructuredDiffOutput for v1, structuredDiffOutputV2 for v2). For v1: the flat - // back-compat view (aggregate summary + merged changes + union errors) plus the - // per-input-XR xrs[] view, in input order. - want any - // wantKeys is the document's top-level JSON keys, which pins that v1 differs from before - // only by outputVersion and that v2 drops the flat changes[]. - wantKeys []string - }{ - "ChangedUnchangedAndErrored": { - groups: []dt.XRDiffGroup{ - { - XR: xrRef("changed-xr"), - Diffs: map[string]*dt.ResourceDiff{"changed": changed}, - }, - { - XR: xrRef("unchanged-xr"), - Diffs: map[string]*dt.ResourceDiff{"equal": equalOnly}, - }, - { - XR: xrRef("broken-xr"), - Err: &dt.OutputError{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}, - }, - }, - errs: unionErrs, - want: StructuredDiffOutput{ - OutputVersion: OutputVersionV1, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{bucketChange}, - Errors: unionErrs, - Xrs: []xrEntryWire{ - { - XR: xrRef("changed-xr"), - Status: dt.XRStatusChanged, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{bucketChange}, - }, - { - XR: xrRef("unchanged-xr"), - Status: dt.XRStatusUnchanged, - Changes: []ChangeDetail{}, - }, - { - XR: xrRef("broken-xr"), - Status: dt.XRStatusError, - Changes: []ChangeDetail{}, - Errors: []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}}, - }, - }, - }, - wantKeys: []string{"changes", "errors", "outputVersion", "summary", "xrs"}, - }, - // An explicit v1 is the same document as the default. - "ExplicitV1": { - groups: []dt.XRDiffGroup{ - {XR: xrRef("unchanged-xr"), Diffs: map[string]*dt.ResourceDiff{"equal": equalOnly}}, - }, - version: OutputVersionV1, - want: StructuredDiffOutput{ - OutputVersion: OutputVersionV1, - Changes: []ChangeDetail{}, - Xrs: []xrEntryWire{ - {XR: xrRef("unchanged-xr"), Status: dt.XRStatusUnchanged, Changes: []ChangeDetail{}}, - }, - }, - wantKeys: []string{"changes", "outputVersion", "summary", "xrs"}, - }, - // v2 drops the flat changes[] and emits the shared per-XR entry for each status xr produces. - "V2ChangedUnchangedAndErrored": { - groups: []dt.XRDiffGroup{ - { - XR: xrRef("changed-xr"), - Diffs: map[string]*dt.ResourceDiff{"changed": changed}, - }, - { - XR: xrRef("unchanged-xr"), - Diffs: map[string]*dt.ResourceDiff{"equal": equalOnly}, - }, - { - XR: xrRef("broken-xr"), - Err: &dt.OutputError{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}, - }, - }, - errs: unionErrs, - version: OutputVersionV2, - want: structuredDiffOutputV2{ - OutputVersion: OutputVersionV2, - Summary: Summary{Modified: 1}, - Errors: unionErrs, - Xrs: []xrEntryWire{ - { - XR: xrRef("changed-xr"), - Status: dt.XRStatusChanged, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{bucketChange}, - }, - { - XR: xrRef("unchanged-xr"), - Status: dt.XRStatusUnchanged, - Changes: []ChangeDetail{}, - }, - { - XR: xrRef("broken-xr"), - Status: dt.XRStatusError, - Changes: []ChangeDetail{}, - Errors: []dt.OutputError{{ResourceID: "XBucket/broken-xr", Message: "cannot get composition"}}, - }, - }, - }, - wantKeys: []string{"errors", "outputVersion", "summary", "xrs"}, - }, - // One resource reached through two inputs' renders with the same rendering, through one - // controller. It is one change, so the flat view (summary and changes[]) counts and lists it once, - // while each xrs[] entry still reports everything its input produces. So the xrs[] summaries can - // sum to more than the top-level summary when inputs overlap; that is the point of the split — - // the flat view counts resources, the grouped view is complete per input. - "SameResourceReachedThroughTwoInputs": { - groups: []dt.XRDiffGroup{ - { - XR: xrRef("app-claim"), - Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("us-west-2")}, - }, - { - XR: xrRef("app-xr"), - Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("us-west-2")}, - }, - }, - want: StructuredDiffOutput{ - OutputVersion: OutputVersionV1, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{sharedChange("us-west-2")}, - Xrs: []xrEntryWire{ - { - XR: xrRef("app-claim"), - Status: dt.XRStatusChanged, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{sharedChange("us-west-2")}, - }, - { - XR: xrRef("app-xr"), - Status: dt.XRStatusChanged, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{sharedChange("us-west-2")}, - }, - }, - }, - }, - // Issue #476: two input XRs rendering one resource differently. The flat view lists both, - // in input order: it is lossless on its own terms, not only because today's validator rejects - // such input. That keeps the renderer correct under any InputValidator, including one that - // treats inputs individually, where two inputs may legitimately disagree about a resource. - "SameKeyFromTwoXRs": { - groups: []dt.XRDiffGroup{ - { - XR: xrRef("app-a"), - Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("us-west-2")}, - }, - { - XR: xrRef("app-b"), - Diffs: map[string]*dt.ResourceDiff{"example.org/v1/Bucket/default/shared": sharedDiff("eu-west-1")}, - }, - }, - want: StructuredDiffOutput{ - OutputVersion: OutputVersionV1, - Summary: Summary{Modified: 2}, - Changes: []ChangeDetail{sharedChange("us-west-2"), sharedChange("eu-west-1")}, - Xrs: []xrEntryWire{ - { - XR: xrRef("app-a"), - Status: dt.XRStatusChanged, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{sharedChange("us-west-2")}, - }, - { - XR: xrRef("app-b"), - Status: dt.XRStatusChanged, - Summary: Summary{Modified: 1}, - Changes: []ChangeDetail{sharedChange("eu-west-1")}, - }, - }, - }, - }, - } - - for name, tc := range cases { - t.Run(name, func(t *testing.T) { - var buf bytes.Buffer - - opts := DefaultDiffOptions() - opts.Format = OutputFormatJSON - opts.Stdout = &buf - opts.Stderr = &bytes.Buffer{} - opts.OutputVersion = tc.version - - r := NewStructuredDiffRenderer(tu.TestLogger(t, false), opts) - if err := r.RenderDiffs(tc.groups, tc.errs, nil); err != nil { - t.Fatalf("RenderDiffs() failed: %v", err) - } - - output := reflect.New(reflect.TypeOf(tc.want)) - if err := json.Unmarshal(buf.Bytes(), output.Interface()); err != nil { - t.Fatalf("Failed to parse JSON: %v\nOutput: %s", err, buf.String()) - } - - if diff := cmp.Diff(tc.want, output.Elem().Interface()); diff != "" { - t.Errorf("structured output mismatch (-want +got):\n%s", diff) - } - - if tc.wantKeys != nil { - var doc map[string]json.RawMessage - if err := json.Unmarshal(buf.Bytes(), &doc); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) - } - - if diff := cmp.Diff(tc.wantKeys, slices.Sorted(maps.Keys(doc))); diff != "" { - t.Errorf("top-level keys mismatch (-want +got):\n%s", diff) - } - } - }) - } -} - -// TestStructuredDiffRenderer_RenderDiffs_ErrorsInOutputOnly verifies that errors -// are included in the structured output for machine parsing but not written to -// stderr: the processor returns them to the command, which prints them once. -func TestStructuredDiffRenderer_RenderDiffs_ErrorsInOutputOnly(t *testing.T) { - errs := []dt.OutputError{ - {ResourceID: "example.org/v1/XResource/my-xr", Message: "failed to render XR: missing composition"}, - {Message: "cluster connection timeout"}, - } - - for _, format := range []OutputFormat{OutputFormatJSON, OutputFormatYAML} { - t.Run(string(format), func(t *testing.T) { - logger := tu.TestLogger(t, false) - - var ( - stdout bytes.Buffer - stderr bytes.Buffer - ) - - opts := DefaultDiffOptions() - opts.Format = format - opts.Stdout = &stdout - opts.Stderr = &stderr - - renderer := NewStructuredDiffRenderer(logger, opts) - - err := renderer.RenderDiffs(identitylessGroups(map[string]*dt.ResourceDiff{}), errs, nil) - if err != nil { - t.Fatalf("RenderDiffs() failed: %v", err) - } - - stderrOut := stderr.String() - for _, e := range errs { - if strings.Contains(stderrOut, e.Message) { - t.Errorf("Expected stderr not to contain error %q, got: %q", e.Message, stderrOut) - } - } - - // Verify errors are in structured output (stdout) - var output StructuredDiffOutput - - switch format { - case OutputFormatJSON: - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("Failed to parse JSON output: %v\nOutput: %s", err, stdout.String()) - } - case OutputFormatYAML: - if err := sigsyaml.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("Failed to parse YAML output: %v\nOutput: %s", err, stdout.String()) - } - case OutputFormatDiff: - t.Fatal("OutputFormatDiff should not be used with StructuredDiffRenderer") - } - - if diff := cmp.Diff(errs, output.Errors); diff != "" { - t.Errorf("Structured output errors mismatch (-want +got):\n%s", diff) - } - }) - } -} - -// TestStructuredDiffRenderer_RespectsIgnorePaths verifies that --ignore-paths -// and the unconditional cleanup performed for the human diff are also honored -// when rendering structured (JSON/YAML) output. -// -// Each case runs compare.GenerateDiff to classify the diff (matching real -// CLI flow) and then renders through the structured renderer, then asserts on -// the parsed structured output. -func TestStructuredDiffRenderer_RespectsIgnorePaths(t *testing.T) { - const ( - ignoredAnnotation = "argocd.argoproj.io/tracking-id" - ignoredLabel = "argocd.argoproj.io/instance" - ) - - ignorePaths := []string{ - "metadata.annotations[" + ignoredAnnotation + "]", - "metadata.labels[" + ignoredLabel + "]", - } - - // xExample returns a builder for a namespaced XExample resource named "r1". - // Callers chain expressive builder methods (WithSpecField, WithAnnotations, - // WithServerMetadata, WithStatus, WithOwnerReference, …) to add exactly the - // fields the case needs, then call Build(). - xExample := func() *tu.ResourceBuilder { - return tu.NewResource("example.org/v1alpha1", "XExample", "r1"). - InNamespace("default") - } - - // cleanSpec is the object body we expect the renderer to emit for a resource - // whose only surviving content is its identity plus spec.configData — i.e. - // after cleanup has stripped ignored annotations/labels and all server-side - // fields. Built with the same builder used for inputs, minus the noise, so - // the expectation reads as "this is the shape that should come out". - cleanSpec := func(configData string) map[string]any { - return xExample().WithSpecField("configData", configData).Build().Object - } - - cases := []struct { - name string - current *un.Unstructured // pass nil for Added - desired *un.Unstructured // pass nil for Removed - ignorePaths []string - wantSummary Summary - wantChanges int - // wantDiffDetail is the expected Changes[0].Diff payload, as decoded from - // the rendered JSON/YAML (so it holds nested map[string]any structures). - // Every leaf value in these fixtures is a string, so the comparison isn't - // affected by JSON's number decoding (all numbers would decode to - // float64). Left nil when wantChanges is 0. - wantDiffDetail map[string]any - }{ - { - // Only user-supplied ignored path differs -> classified Equal. - name: "OnlyIgnoredAnnotation_UnchangedInSummary", - current: xExample().WithSpecField("configData", "same"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}).Build(), - desired: xExample().WithSpecField("configData", "same"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}).Build(), - ignorePaths: ignorePaths, - wantSummary: Summary{}, - wantChanges: 0, - }, - { - // Only ownerReferences differ -> classified Equal - // via unconditional cleanup, without any user --ignore-paths. - name: "OnlyOwnerReferences_UnchangedInSummary", - current: xExample().WithSpecField("configData", "same"). - WithOwnerReference("Owner", "old", "v1", "u1").Build(), - desired: xExample().WithSpecField("configData", "same"). - WithOwnerReference("Owner", "new", "v1", "u2").Build(), - wantSummary: Summary{}, - wantChanges: 0, - }, - { - // Mixed ignored + non-ignored change. Modified count - // increments once; diff.old/new carry only the cleaned bodies — the - // ignored annotation and label are absent, the spec change survives. - name: "IgnoredPlusNonIgnored_CountOneAndIgnoredStripped", - current: xExample().WithSpecField("configData", "old"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). - WithLabels(map[string]string{ignoredLabel: "app-old"}).Build(), - desired: xExample().WithSpecField("configData", "new"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). - WithLabels(map[string]string{ignoredLabel: "app-new"}).Build(), - ignorePaths: ignorePaths, - wantSummary: Summary{Modified: 1}, - wantChanges: 1, - wantDiffDetail: map[string]any{ - dt.DiffKeyOld: cleanSpec("old"), - dt.DiffKeyNew: cleanSpec("new"), - }, - }, - { - // Unconditional-cleanup fields (server metadata, managedFields, - // ownerReferences, status) must not leak into the JSON diff even - // without user-supplied --ignore-paths. - name: "ServerSideFieldsStripped", - current: xExample().WithSpecField("configData", "old"). - WithServerMetadata(). - WithFieldManagers("kubectl"). - WithOwnerReference("Owner", "o", "v1", "u"). - WithStatus(map[string]any{"phase": "Ready"}).Build(), - desired: xExample().WithSpecField("configData", "new"). - WithServerMetadata(). - WithFieldManagers("kubectl"). - WithOwnerReference("Owner", "o", "v1", "u"). - WithStatus(map[string]any{"phase": "Ready"}).Build(), - wantSummary: Summary{Modified: 1}, - wantChanges: 1, - wantDiffDetail: map[string]any{ - dt.DiffKeyOld: cleanSpec("old"), - dt.DiffKeyNew: cleanSpec("new"), - }, - }, - { - // Added resource — diff.spec carries only the cleaned body, - // no ignored annotation and no server-side fields. - name: "AddedResource_IgnoresPathsInSpec", - current: nil, - desired: xExample().WithSpecField("configData", "new"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-new"}). - WithServerMetadata(). - WithFieldManagers("kubectl").Build(), - ignorePaths: ignorePaths, - wantSummary: Summary{Added: 1}, - wantChanges: 1, - wantDiffDetail: map[string]any{ - dt.DiffKeySpec: cleanSpec("new"), - }, - }, - { - // Removed resource — diff.spec carries only the cleaned body, - // no ignored annotation and no ownerReferences. - name: "RemovedResource_IgnoresPathsInSpec", - current: xExample().WithSpecField("configData", "old"). - WithAnnotations(map[string]string{ignoredAnnotation: "id-old"}). - WithServerMetadata(). - WithOwnerReference("Owner", "o", "v1", "u").Build(), - desired: nil, - ignorePaths: ignorePaths, - wantSummary: Summary{Removed: 1}, - wantChanges: 1, - wantDiffDetail: map[string]any{ - dt.DiffKeySpec: cleanSpec("old"), - }, - }, - } - - // Run every case through both structured formats, JSON and YAML: each must honour the same - // cleanup, so a case that passed for one alone would prove nothing about the other. - formats := []OutputFormat{OutputFormatJSON, OutputFormatYAML} - - for _, format := range formats { - for _, tc := range cases { - t.Run(string(format)+"/"+tc.name, func(t *testing.T) { - logger := tu.TestLogger(t, false) - - // Run the same classify-then-render pipeline the CLI uses, so - // we exercise classification + rendering together rather than - // only the renderer. Cleanup (ignore-paths + server-side fields) - // happens here, during generation, and is stored on the diff's - // clean views; the renderer just emits them. - // - // Note: this uses tc.ignorePaths verbatim and does NOT prepend - // the CLI's built-in default ignore path - // (metadata.annotations[kubectl.kubernetes.io/last-applied-configuration], - // added in defaultProcessorOptions, cmd_utils.go). That default - // wiring is covered end-to-end by the IgnorePathsArgoCD - // integration test in diff_integration_test.go. - diffOpts := compare.Options{IgnorePaths: tc.ignorePaths} - - rd, err := compare.GenerateDiff(t.Context(), tc.current, tc.desired, logger, diffOpts) - if err != nil { - t.Fatalf("compare.GenerateDiff: %v", err) - } - - var buf bytes.Buffer - - // The renderer no longer performs cleanup, so it needs no - // IgnorePaths — the diff already carries cleaned views. - renderOpts := DefaultDiffOptions() - renderOpts.Format = format - renderOpts.Stdout = &buf - renderOpts.Stderr = &bytes.Buffer{} - - r := NewStructuredDiffRenderer(logger, renderOpts) - if err := r.RenderDiffs(identitylessGroups(map[string]*dt.ResourceDiff{"r1": rd}), nil, nil); err != nil { - t.Fatalf("RenderDiffs() failed: %v", err) - } - - var output StructuredDiffOutput - - switch format { - case OutputFormatJSON: - if err := json.Unmarshal(buf.Bytes(), &output); err != nil { - t.Fatalf("json.Unmarshal: %v\noutput: %s", err, buf.String()) - } - case OutputFormatYAML: - if err := sigsyaml.Unmarshal(buf.Bytes(), &output); err != nil { - t.Fatalf("yaml.Unmarshal: %v\noutput: %s", err, buf.String()) - } - case OutputFormatDiff: - t.Fatal("diff format not supported by structured renderer") - } - - if diff := cmp.Diff(tc.wantSummary, output.Summary); diff != "" { - t.Errorf("summary mismatch (-want +got):\n%s", diff) - } - - if len(output.Changes) != tc.wantChanges { - t.Errorf("len(Changes) = %d, want %d\noutput: %s", len(output.Changes), tc.wantChanges, buf.String()) - } - - // When a change is expected, the emitted diff detail must match - // the cleaned bodies exactly — this catches both leaked ignored/ - // server-side fields and any missing non-ignored content. - if tc.wantDiffDetail != nil { - if len(output.Changes) == 0 { - t.Fatalf("expected one change with diff detail, got none\noutput: %s", buf.String()) - } - - if diff := cmp.Diff(tc.wantDiffDetail, output.Changes[0].Diff); diff != "" { - t.Errorf("diff detail mismatch (-want +got):\n%s", diff) - } - } - }) - } - } -} diff --git a/internal/diff/testutils/structured_assertions.go b/internal/diff/testutils/structured_assertions.go index 76cf4d28..4dfaa183 100644 --- a/internal/diff/testutils/structured_assertions.go +++ b/internal/diff/testutils/structured_assertions.go @@ -624,8 +624,7 @@ type CompositionExpectation struct { // of revisionImpact so a case can assert the name alone, and because the name is usually matched by // pattern (its suffix is a content hash) while the rest are exact. predictedRevisionName *expectedPredictedRevisionName - // errorContains, when set, is a substring the composition's error must contain: v1's error string, - // or a message in v2's errors[]. + // errorContains, when set, is a substring the composition's error must contain: its plan's error. errorContains string } @@ -659,7 +658,7 @@ type XRImpactExpectation struct { status string // "changed", "unchanged", "error", "filtered" filterReason string // "manual_policy", "revision_selector_mismatch", "deleting"; only checked when set errorContains string // substring of an "error" impact's Error; only checked when set - errorIDs []string // resourceIDs the entry's v2 errors[] must contain + errorIDs []string // resourceIDs the plan.Result errors[] attributed to the XR must contain downstreamSummary *expectedSummary downstreamResources []*DownstreamResourceExpectation } @@ -764,8 +763,8 @@ func (c *CompositionExpectation) WithCompositionFieldChange(path string, oldValu return c } -// WithCompositionErrorContaining asserts the composition failed with an error containing s: v1's -// error string, or a message in v2's errors[]. +// WithCompositionErrorContaining asserts the composition failed with an error containing s, the +// error on its plan in plan.Result. func (c *CompositionExpectation) WithCompositionErrorContaining(s string) *CompositionExpectation { c.errorContains = s return c From d2b10be8e948a730c0ef632b0e10e6e2dd6382f9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 03:27:15 -0400 Subject: [PATCH 056/126] feat(diff): opt-in WithKeepEqualDiffs predicate 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/compare/compare.go | 5 ++ internal/diff/diffprocessor/comp_processor.go | 5 ++ .../diff/diffprocessor/comp_processor_test.go | 20 ++++- .../diff/diffprocessor/diff_calculator.go | 2 +- .../diffprocessor/diff_calculator_test.go | 83 +++++++++++++++++++ .../diffprocessor/input_validator_test.go | 8 ++ .../diff/diffprocessor/processor_config.go | 16 +++- 7 files changed, 136 insertions(+), 3 deletions(-) diff --git a/internal/diff/compare/compare.go b/internal/diff/compare/compare.go index 8fcf626b..a2ddce51 100644 --- a/internal/diff/compare/compare.go +++ b/internal/diff/compare/compare.go @@ -60,6 +60,11 @@ type Options struct { // Callers should also clear IgnorePaths for such a comparison, for the same reason — the user's // masks are a display preference too. ForVerdict bool + + // KeepEqualDiffs, when set, names the composed resources whose diff is kept even when equal, so a + // caller can refine them further (for example, plan them against the provider). Nil keeps none, + // which is the default. It is read by the diff calculator, not by GenerateDiff. + KeepEqualDiffs func(*un.Unstructured) bool } // GetLineDiff performs a proper line-by-line diff and returns the raw diffs. diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 2cb15ad2..1658ab8c 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -1237,6 +1237,11 @@ func (p *DefaultCompDiffProcessor) buildImpactAnalysis(xrs []*un.Unstructured, r summary.WithChanges++ default: impact.Status = dt.XRStatusUnchanged + // Renderers ignore an unchanged impact's diffs; they are carried only for a caller that kept + // equal diffs to refine them. + if result != nil && p.config.KeepEqualDiffs != nil { + impact.Diffs = result.Diffs + } summary.Unchanged++ } diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index e23a10db..daf109fd 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -1078,8 +1078,10 @@ func TestDefaultCompDiffProcessor_buildImpactAnalysis(t *testing.T) { validationErr := &SchemaValidationError{Message: "validation failed", Failures: failures} modified := &dt.ResourceDiff{DiffType: dt.DiffTypeModified} + equal := &dt.ResourceDiff{DiffType: dt.DiffTypeEqual} tests := map[string]struct { + keep func(*un.Unstructured) bool result *XRDiffResult wantImpact dt.XRImpact wantSummary dt.AffectedResourcesSummary @@ -1121,6 +1123,21 @@ func TestDefaultCompDiffProcessor_buildImpactAnalysis(t *testing.T) { wantImpact: dt.XRImpact{ObjectReference: ref, Status: dt.XRStatusUnchanged}, wantSummary: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, }, + "UnchangedCarriesNoDiffsByDefault": { + result: &XRDiffResult{Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": equal}}, + wantImpact: dt.XRImpact{ObjectReference: ref, Status: dt.XRStatusUnchanged}, + wantSummary: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + }, + "UnchangedCarriesKeptEqualDiffs": { + keep: func(*un.Unstructured) bool { return true }, + result: &XRDiffResult{Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": equal}}, + wantImpact: dt.XRImpact{ + ObjectReference: ref, + Status: dt.XRStatusUnchanged, + Diffs: map[string]*dt.ResourceDiff{"Bucket/my-bucket": equal}, + }, + wantSummary: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + }, } for name, tt := range tests { @@ -1130,7 +1147,8 @@ func TestDefaultCompDiffProcessor_buildImpactAnalysis(t *testing.T) { results[dt.MakeDiffKeyFromResource(xr)] = tt.result } - impacts, summary := (&DefaultCompDiffProcessor{}).buildImpactAnalysis([]*un.Unstructured{xr}, results) + p := &DefaultCompDiffProcessor{config: ProcessorConfig{KeepEqualDiffs: tt.keep}} + impacts, summary := p.buildImpactAnalysis([]*un.Unstructured{xr}, results) if diff := gcmp.Diff([]dt.XRImpact{tt.wantImpact}, impacts); diff != "" { t.Errorf("buildImpactAnalysis() impacts mismatch (-want +got):\n%s", diff) diff --git a/internal/diff/diffprocessor/diff_calculator.go b/internal/diff/diffprocessor/diff_calculator.go index 5ed81b6b..2b63611c 100644 --- a/internal/diff/diffprocessor/diff_calculator.go +++ b/internal/diff/diffprocessor/diff_calculator.go @@ -338,7 +338,7 @@ func (c *DefaultDiffCalculator) CalculateNonRemovalDiffs(ctx context.Context, xr } diffKey := diff.GetDiffKey() - if diff.DiffType != dt.DiffTypeEqual { + if diff.DiffType != dt.DiffTypeEqual || (c.diffOptions.KeepEqualDiffs != nil && c.diffOptions.KeepEqualDiffs(un)) { diffs[diffKey] = diff } diff --git a/internal/diff/diffprocessor/diff_calculator_test.go b/internal/diff/diffprocessor/diff_calculator_test.go index 2cbaccee..a640d969 100644 --- a/internal/diff/diffprocessor/diff_calculator_test.go +++ b/internal/diff/diffprocessor/diff_calculator_test.go @@ -2468,3 +2468,86 @@ func TestDefaultDiffCalculator_CalculateNonRemovalDiffsRendered(t *testing.T) { }) } } + +// TestDefaultDiffCalculator_KeepEqualDiffs checks that an equal composed diff is dropped by default and +// kept only for the resources the KeepEqualDiffs predicate names. +func TestDefaultDiffCalculator_KeepEqualDiffs(t *testing.T) { + existingXR := tu.NewResource("example.org/v1", "XR", "test-xr").WithUID("xr-uid").Build() + existingComposed := tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + ControlledBy(existingXR). + WithSpecField("field", "value"). + Build() + + xrKey := "example.org/v1/XR//test-xr" + composedKey := "example.org/v1/Composed//cpd-1" + + tests := map[string]struct { + reason string + keep func(*un.Unstructured) bool + want map[string]dt.DiffType + }{ + "NilKeepsNone": { + reason: "Unset, an equal composed diff is dropped; only the XR's own diff is stored.", + want: map[string]dt.DiffType{xrKey: dt.DiffTypeEqual}, + }, + "PredicateFalseKeepsNone": { + reason: "A predicate that declines the resource drops its equal diff.", + keep: func(*un.Unstructured) bool { return false }, + want: map[string]dt.DiffType{xrKey: dt.DiffTypeEqual}, + }, + "PredicateTrueKeepsEqual": { + reason: "A predicate that names the resource keeps its equal diff.", + keep: func(u *un.Unstructured) bool { return u.GetKind() == "Composed" }, + want: map[string]dt.DiffType{xrKey: dt.DiffTypeEqual, composedKey: dt.DiffTypeEqual}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + logger := tu.TestLogger(t, false) + resourceClient := tu.NewMockResourceClient(). + WithResourcesExist(existingXR, existingComposed). + WithResourcesFoundByLabel([]*un.Unstructured{existingComposed}, "crossplane.io/composite", "test-xr"). + Build() + + calculator := NewDiffCalculator( + tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + tu.NewMockAccessChecker().Build(), + tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + NewResourceManager(resourceClient, tu.NewMockDefinitionClient().Build(), tu.NewMockResourceTreeClient().Build(), logger, NewAdvisoryCollector(nil)), + logger, + compare.Options{KeepEqualDiffs: tt.keep}, + DryRunOnAll, + &tu.MockDefaulter{}, + ) + + xr := &cmp.Unstructured{} + xr.SetUnstructuredContent(existingXR.DeepCopy().UnstructuredContent()) + + rendered := tu.NewResource("example.org/v1", "Composed", "cpd-1"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("resource-1"). + WithSpecField("field", "value"). + BuildUComposed() + + diffs, _, err := calculator.CalculateNonRemovalDiffs(t.Context(), xr, render.CompositionOutputs{ + CompositeResource: xr, + ComposedResources: []cpd.Unstructured{*rendered}, + }) + if err != nil { + t.Fatalf("%s\nCalculateNonRemovalDiffs(): unexpected error: %v", tt.reason, err) + } + + got := make(map[string]dt.DiffType, len(diffs)) + for k, d := range diffs { + got[k] = d.DiffType + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\nCalculateNonRemovalDiffs(): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/input_validator_test.go b/internal/diff/diffprocessor/input_validator_test.go index 36db75e2..86a475e7 100644 --- a/internal/diff/diffprocessor/input_validator_test.go +++ b/internal/diff/diffprocessor/input_validator_test.go @@ -484,6 +484,14 @@ func TestRenderOverlapErrors(t *testing.T) { reason: "Equal diffs appear in no rendered view, so merging them loses nothing.", groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeEqual, []string{"a", "b"}[i], "u", "v") }), }, + "KeptEqualBesideModifiedDisagrees": { + reason: "A kept-equal rendering of one object beside a modified one is a disagreement: applying both " + + "inputs either changes the object or does not, and no single diff says which.", + groups: two(func(i int) *dt.ResourceDiff { + return bucket([]dt.DiffType{dt.DiffTypeEqual, dt.DiffTypeModified}[i], "parent", "u", []string{"1", "2"}[i]) + }), + want: []string{disagreement}, + }, "SameControllerIdenticalIsMerged": { reason: "One change reached twice through one controller; the controller is compared by name, not UID.", groups: two(func(i int) *dt.ResourceDiff { return bucket(dt.DiffTypeModified, "parent", []string{"1", "2"}[i], "v") }), diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 0b82c9f5..295a605e 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -2,6 +2,7 @@ package diffprocessor import ( corev1 "k8s.io/api/core/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -142,6 +143,11 @@ type ProcessorConfig struct { // IgnorePaths is a list of paths to ignore when calculating diffs IgnorePaths []string + // KeepEqualDiffs, when set, names the composed resources whose diff is kept even when equal, and + // makes an unchanged composite's impact carry its diffs. Nil, the default, keeps none. Every + // HasChanges and renderer skips equal diffs, so this only adds diffs a caller can refine further. + KeepEqualDiffs func(*un.Unstructured) bool + // FunctionCredentials holds Secret credentials to pass to Functions during rendering FunctionCredentials []corev1.Secret @@ -296,6 +302,14 @@ func WithIgnorePaths(ignorePaths []string) ProcessorOption { } } +// WithKeepEqualDiffs keeps the equal diffs of the composed resources keep reports true for (see +// ProcessorConfig.KeepEqualDiffs). +func WithKeepEqualDiffs(keep func(*un.Unstructured) bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.KeepEqualDiffs = keep + } +} + // WithFunctionCredentials sets the credentials to pass to Functions during rendering. func WithFunctionCredentials(creds []corev1.Secret) ProcessorOption { return func(config *ProcessorConfig) { @@ -413,7 +427,7 @@ func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) // GetCompareOptions returns the compare.Options based on the ProcessorConfig. func (c *ProcessorConfig) GetCompareOptions() compare.Options { - return compare.Options{IgnorePaths: c.IgnorePaths} + return compare.Options{IgnorePaths: c.IgnorePaths, KeepEqualDiffs: c.KeepEqualDiffs} } // SetDefaultFactories sets default component factory functions if not already set. From 1d3665cae89741adba12d53abd621542e8eed599 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 03:43:10 -0400 Subject: [PATCH 057/126] feat(simulate): plan engine diffs with provider diff servers 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) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 4 +- .../diff/diffprocessor/comp_processor_test.go | 2 +- internal/simulate/providers.go | 91 +++++++ internal/simulate/providers_test.go | 226 ++++++++++++++++++ internal/simulate/report.go | 107 +++++++-- internal/simulate/report_test.go | 133 ++++++++++- internal/simulate/simulate.go | 85 +++++-- internal/simulate/simulate_test.go | 10 + 8 files changed, 608 insertions(+), 50 deletions(-) create mode 100644 internal/simulate/providers.go create mode 100644 internal/simulate/providers_test.go diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 0d4edc10..6bc4d411 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -653,7 +653,7 @@ func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r * if c.Output == string(renderer.OutputFormatDiff) { return renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) } - return c.renderPlan(k, simulate.FromXRReport(r), dt.XRDryRunWarnings(r.Groups)) + return c.renderPlan(k, simulate.FromXRReport(r, nil), dt.XRDryRunWarnings(r.Groups)) } // renderCompReport writes a composition run's report in the format -o @@ -664,7 +664,7 @@ func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o if c.Output == string(renderer.OutputFormatDiff) { return renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts).RenderCompDiff(o) } - return c.renderPlan(k, simulate.FromCompReport(o), dt.CompDryRunWarnings(o)) + return c.renderPlan(k, simulate.FromCompReport(o, nil), dt.CompDryRunWarnings(o)) } // renderPlan writes res to stdout in the plan, json or yaml format, then diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index daf109fd..84212e98 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -2139,7 +2139,7 @@ func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.O } if format == renderer.OutputFormatJSON { - out, err := json.Marshal(simulate.FromCompReport(output)) + out, err := json.Marshal(simulate.FromCompReport(output, nil)) if err != nil { t.Fatalf("json.Marshal(): %v", err) } diff --git a/internal/simulate/providers.go b/internal/simulate/providers.go new file mode 100644 index 00000000..8cf18a71 --- /dev/null +++ b/internal/simulate/providers.go @@ -0,0 +1,91 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "context" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// PlanDiffs plans the engine's resource diffs against the provider diff +// servers p routes to, keyed by diff key, for FromXRReport and FromCompReport +// to merge. Each key across sets is planned once. +// +// Removals are not planned: a provider plans no deletes, so the engine's +// stands. An equal diff is planned only when keep reports true for it (nil +// keeps none), which leaves out the XR's own diff the engine always stores. A +// kind no diff server serves is not planned either, so its engine plan stands +// and is never approximate; a kept equal diff of such a kind gets a no-op +// entry, which the merge drops. +func PlanDiffs(ctx context.Context, p *Planner, keep func(*unstructured.Unstructured) bool, sets ...map[string]*dt.ResourceDiff) map[string]*plan.ResourcePlan { + plans := map[string]*plan.ResourcePlan{} + + // ponytail: sequential, as resource simulate's loop is; parallelize with + // errgroup if large projects are slow. + for _, diffs := range sets { + for key, d := range diffs { + if _, done := plans[key]; done || d.DiffType == dt.DiffTypeRemoved || d.Desired.Raw == nil { + continue + } + if d.DiffType == dt.DiffTypeEqual && (keep == nil || !keep(d.Desired.Raw)) { + continue + } + if !p.CanPlan(d.Gvk.GroupKind()) { + if d.DiffType == dt.DiffTypeEqual { + noop := plan.NewResourcePlan(d.Desired.Raw) + noop.Action = plan.ActionNoOp + plans[key] = noop + } + continue + } + + // Copies, because PlanWithLive merges external tags into desired. + // Desired.Raw is the server-side dry run's result, and Current.Raw + // the live resource the engine matched, nil for a create. + plans[key] = p.PlanWithLive(ctx, d.Desired.Raw.DeepCopy(), d.Current.Raw.DeepCopy()) + } + } + + return plans +} + +// Changed reports whether res plans any change: a plan that is not an error +// and creates, updates, replaces or deletes. With provider plans merged in it +// is the run's verdict, since a provider can find drift the cluster cannot. +func Changed(res plan.Result) bool { + for _, p := range res.Plans { + if p.Error == "" && isChange(p.Action) { + return true + } + } + + return false +} + +func isChange(a plan.Action) bool { + switch a { + case plan.ActionCreate, plan.ActionUpdate, plan.ActionReplace, plan.ActionDelete: + return true + case plan.ActionNoOp: + } + + return false +} diff --git a/internal/simulate/providers_test.go b/internal/simulate/providers_test.go new file mode 100644 index 00000000..c63b763c --- /dev/null +++ b/internal/simulate/providers_test.go @@ -0,0 +1,226 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "context" + "testing" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// vpcRouter routes only VPCs, to client. +type vpcRouter struct { + client diffv1alpha1.PlanServiceClient +} + +func (r *vpcRouter) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + return r.client, gk == vpcGVK.GroupKind() +} + +// countingPlanClient answers Plan with rsp or err, counting calls. +type countingPlanClient struct { + rsp *diffv1alpha1.PlanResponse + err error + calls int +} + +func (c *countingPlanClient) Plan(context.Context, *diffv1alpha1.PlanRequest, ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + c.calls++ + return c.rsp, c.err +} + +// vpcDiff is a VPC diff of type t whose dry-run result is desired. +func vpcDiff(t dt.DiffType, name string) *dt.ResourceDiff { + d := &dt.ResourceDiff{Gvk: vpcGVK, ResourceName: name, DiffType: t} + d.Desired.Raw = vpc(name, "10.1.0.0/16") + if t != dt.DiffTypeAdded { + d.Current.Raw = vpc(name, "10.0.0.0/16") + } + return d +} + +func TestPlanDiffs(t *testing.T) { + ctx := context.Background() + + s := runtime.NewScheme() + s.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) + rm.Add(vpcGVK, meta.RESTScopeRoot) + + keepAll := func(*unstructured.Unstructured) bool { return true } + // An update with no visible change is a no-op, so this one has a change. + update := &diffv1alpha1.PlanResponse{ + Action: diffv1alpha1.Action_ACTION_UPDATE, + Changes: []*diffv1alpha1.FieldChange{{Field: "spec.forProvider.cidrBlock", Actual: fieldValue("10.0.0.0/16"), Planned: fieldValue("10.1.0.0/16")}}, + } + + cm := &dt.ResourceDiff{Gvk: cmGVK, ResourceName: "cm", DiffType: dt.DiffTypeModified} + cm.Desired.Raw = obj(cmGVK, "cm", nil) + cmEqual := &dt.ResourceDiff{Gvk: cmGVK, ResourceName: "cm-eq", DiffType: dt.DiffTypeEqual} + cmEqual.Desired.Raw = obj(cmGVK, "cm-eq", nil) + + cases := map[string]struct { + reason string + rsp *diffv1alpha1.PlanResponse + err error + keep func(*unstructured.Unstructured) bool + sets []map[string]*dt.ResourceDiff + want map[string]plan.Action + wantCalls int + check func(t *testing.T, got map[string]*plan.ResourcePlan) + }{ + "Routed": { + reason: "A routed modified diff is planned by its diff server.", + rsp: update, + sets: []map[string]*dt.ResourceDiff{diffs(vpcDiff(dt.DiffTypeModified, "a"))}, + want: map[string]plan.Action{vpcDiff(dt.DiffTypeModified, "a").GetDiffKey(): plan.ActionUpdate}, + wantCalls: 1, + }, + "Unrouted": { + reason: "A kind no diff server serves gets no entry, so its engine plan stands; a kept equal one gets a no-op entry.", + keep: keepAll, + sets: []map[string]*dt.ResourceDiff{diffs(cm, cmEqual)}, + want: map[string]plan.Action{cmEqual.GetDiffKey(): plan.ActionNoOp}, + }, + "Removed": { + reason: "A removal is not planned: providers plan no deletes.", + rsp: update, + sets: []map[string]*dt.ResourceDiff{diffs(vpcDiff(dt.DiffTypeRemoved, "a"))}, + want: map[string]plan.Action{}, + }, + "EqualNotKept": { + reason: "An equal diff keep declines, such as the XR's own, is not planned.", + rsp: update, + sets: []map[string]*dt.ResourceDiff{diffs(vpcDiff(dt.DiffTypeEqual, "a"))}, + want: map[string]plan.Action{}, + }, + "KeptEqualNoOp": { + reason: "A kept equal diff is planned; the provider's no-op is returned for the merge to drop.", + rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_NO_OP}, + keep: keepAll, + sets: []map[string]*dt.ResourceDiff{diffs(vpcDiff(dt.DiffTypeEqual, "a"))}, + want: map[string]plan.Action{vpcDiff(dt.DiffTypeEqual, "a").GetDiffKey(): plan.ActionNoOp}, + wantCalls: 1, + }, + "NotSupported": { + reason: "DIFF_COMPUTATION_NOT_SUPPORTED is an approximate plan, not an error.", + err: notSupportedErr(t), + sets: []map[string]*dt.ResourceDiff{diffs(vpcDiff(dt.DiffTypeModified, "a"))}, + want: map[string]plan.Action{vpcDiff(dt.DiffTypeModified, "a").GetDiffKey(): plan.ActionUpdate}, + wantCalls: 1, + check: func(t *testing.T, got map[string]*plan.ResourcePlan) { + t.Helper() + if p := got[vpcDiff(dt.DiffTypeModified, "a").GetDiffKey()]; !p.Approximate || p.Error != "" { + t.Errorf("PlanDiffs(...): want an approximate plan, got %+v", p) + } + }, + }, + "Error": { + reason: "A diff server error is a per-resource error plan.", + err: status.Error(codes.Internal, "boom"), + sets: []map[string]*dt.ResourceDiff{diffs(vpcDiff(dt.DiffTypeAdded, "a"))}, + want: map[string]plan.Action{vpcDiff(dt.DiffTypeAdded, "a").GetDiffKey(): ""}, + wantCalls: 1, + check: func(t *testing.T, got map[string]*plan.ResourcePlan) { + t.Helper() + if p := got[vpcDiff(dt.DiffTypeAdded, "a").GetDiffKey()]; p.Error == "" { + t.Errorf("PlanDiffs(...): want an error plan, got %+v", p) + } + }, + }, + "DedupeAcrossGroups": { + reason: "One resource reached by two groups is planned once.", + rsp: update, + sets: []map[string]*dt.ResourceDiff{ + diffs(vpcDiff(dt.DiffTypeModified, "a")), + diffs(vpcDiff(dt.DiffTypeModified, "a"), vpcDiff(dt.DiffTypeModified, "b")), + }, + want: map[string]plan.Action{ + vpcDiff(dt.DiffTypeModified, "a").GetDiffKey(): plan.ActionUpdate, + vpcDiff(dt.DiffTypeModified, "b").GetDiffKey(): plan.ActionUpdate, + }, + wantCalls: 2, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + pc := &countingPlanClient{rsp: tc.rsp, err: tc.err} + kube := fake.NewClientBuilder().WithScheme(s).Build() + p := NewPlanner(kube, rm, "default", &vpcRouter{client: pc}, time.Second) + + got := PlanDiffs(ctx, p, tc.keep, tc.sets...) + + actions := map[string]plan.Action{} + for k, rp := range got { + actions[k] = rp.Action + } + if len(actions) != len(tc.want) { + t.Errorf("%s\nPlanDiffs(...): want %v, got %v", tc.reason, tc.want, actions) + } + for k, a := range tc.want { + if g, ok := actions[k]; !ok || g != a { + t.Errorf("%s\nPlanDiffs(...)[%q]: want %q, got %q (present %t)", tc.reason, k, a, g, ok) + } + } + if pc.calls != tc.wantCalls { + t.Errorf("%s\nPlanDiffs(...): want %d Plan calls, got %d", tc.reason, tc.wantCalls, pc.calls) + } + if tc.check != nil { + tc.check(t, got) + } + }) + } +} + +func TestChanged(t *testing.T) { + cases := map[string]struct { + plans []*plan.ResourcePlan + want bool + }{ + "Empty": {want: false}, + "NoOpOnly": {plans: []*plan.ResourcePlan{{Action: plan.ActionNoOp}}, want: false}, + "ErrorOnly": {plans: []*plan.ResourcePlan{{Action: plan.ActionUpdate, Error: "boom"}}, want: false}, + "Create": {plans: []*plan.ResourcePlan{{Action: plan.ActionNoOp}, {Action: plan.ActionCreate}}, want: true}, + "Update": {plans: []*plan.ResourcePlan{{Action: plan.ActionUpdate}}, want: true}, + "Replace": {plans: []*plan.ResourcePlan{{Action: plan.ActionReplace}}, want: true}, + "Delete": {plans: []*plan.ResourcePlan{{Action: plan.ActionDelete}}, want: true}, + "ApproximateUp": {plans: []*plan.ResourcePlan{{Action: plan.ActionUpdate, Approximate: true}}, want: true}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := Changed(plan.Result{Plans: tc.plans}); got != tc.want { + t.Errorf("Changed(...): want %t, got %t", tc.want, got) + } + }) + } +} diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 306811c8..6c663933 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -37,15 +37,25 @@ import ( // provider diff servers, so its plans say nothing about the cloud resources. const noteProviderPlanNotRun = "Cloud-layer plan not run (provider diff servers not yet wired)." +// Notes a caller sets on Result.Notes when no provider plans refined the +// cluster-layer result, saying why. A result built from provider plans +// carries no note: each approximate plan already says why it is approximate. +const ( + NoteProviderPlanSkipped = "Cloud-layer plan skipped (--skip-plan)." + NoteNoDiffServers = "Cloud-layer plan not run: no provider diff servers serve these resources." +) + // FromXRReport maps an xr or project diff run onto plan.Result: one plan per // resource diff (the XR's own included), each grouped under its input XR or -// claim, and one Composites entry per input. -func FromXRReport(r *dt.XRReport) plan.Result { +// claim, and one Composites entry per input. plans are the provider plans +// PlanDiffs returned, merged into the engine's by diff key; nil means none +// ran. +func FromXRReport(r *dt.XRReport, plans map[string]*plan.ResourcePlan) plan.Result { if r == nil { r = &dt.XRReport{} } - a := newAssembler() + a := newAssembler(plans) owned := make([]dt.OutputError, 0, len(r.Groups)) for _, g := range r.Groups { @@ -63,8 +73,10 @@ func FromXRReport(r *dt.XRReport) plan.Result { c.Status = plan.CompositeChanged } + if a.addDiffs(g.Diffs, &xr) && c.Status == plan.CompositeUnchanged { + c.Status = plan.CompositeChanged + } a.composites = append(a.composites, c) - a.addDiffs(g.Diffs, &xr) } a.addRunErrors(r.Errors, owned) @@ -74,13 +86,14 @@ func FromXRReport(r *dt.XRReport) plan.Result { // FromCompReport maps a composition diff run onto plan.Result: a plan and a // Compositions entry per Composition, and a Composites entry per affected -// composite, with that composite's downstream plans grouped under it. -func FromCompReport(o *dt.CompDiffOutput) plan.Result { +// composite, with that composite's downstream plans grouped under it. plans +// are merged as FromXRReport merges them. +func FromCompReport(o *dt.CompDiffOutput, plans map[string]*plan.ResourcePlan) plan.Result { if o == nil { o = &dt.CompDiffOutput{} } - a := newAssembler() + a := newAssembler(plans) owned := make([]dt.OutputError, 0, len(o.Errors)) compositions := make([]plan.CompositionResult, 0, len(o.Compositions)) @@ -130,8 +143,10 @@ func FromCompReport(o *dt.CompDiffOutput) plan.Result { a.deferred++ } + if a.addDiffs(impact.Diffs, &xr) && c.Status == plan.CompositeUnchanged { + c.Status = plan.CompositeChanged + } a.composites = append(a.composites, c) - a.addDiffs(impact.Diffs, &xr) } } @@ -172,6 +187,9 @@ func compositeResult(impact dt.XRImpact) plan.CompositeResult { // assembler accumulates one run's plans, composites and errors. type assembler struct { + // provider holds the provider plans to merge, by diff key; nil when none + // ran. + provider map[string]*plan.ResourcePlan plans []*plan.ResourcePlan composites []plan.CompositeResult errors []plan.Error @@ -187,29 +205,39 @@ type listedChange struct { dryRun *plan.Verification } -func newAssembler() *assembler { - return &assembler{plans: []*plan.ResourcePlan{}, listed: map[string][]listedChange{}} +func newAssembler(provider map[string]*plan.ResourcePlan) *assembler { + return &assembler{provider: provider, plans: []*plan.ResourcePlan{}, listed: map[string][]listedChange{}} } // addDiffs adds a plan per diff, in the structured renderer's canonical order. -func (a *assembler) addDiffs(diffs map[string]*dt.ResourceDiff, composite *plan.ResourceRef) { +// It reports whether a provider plan changed any of them, which the engine's +// verdict alone does not show. +func (a *assembler) addDiffs(diffs map[string]*dt.ResourceDiff, composite *plan.ResourceRef) bool { + changed := false for _, d := range slices.SortedFunc(maps.Values(diffs), diffOrder) { - a.addDiff(d, composite) + changed = a.addDiff(d, composite) || changed } + + return changed } -// addDiff adds the plan for d under composite, unless an identical change to -// the same resource is already listed (under the first composite reaching -// it). Differing renders of one resource are all listed, as the structured -// renderer's flat view lists them. -func (a *assembler) addDiff(d *dt.ResourceDiff, composite *plan.ResourceRef) { - p := resourcePlan(d) +// addDiff adds the plan for d under composite, merged with its provider plan, +// unless an identical change to the same resource is already listed (under +// the first composite reaching it). Differing renders of one resource are all +// listed, as the structured renderer's flat view lists them. It reports +// whether the merged plan is a change, listed or not. +func (a *assembler) addDiff(d *dt.ResourceDiff, composite *plan.ResourceRef) bool { + key := d.GetDiffKey() + p := merge(d, resourcePlan(d), a.provider[key]) + if p == nil { + return false + } p.Composite = composite + changed := p.Error == "" && isChange(p.Action) - key := d.GetDiffKey() lc := listedChange{action: p.Action, changes: p.Changes, dryRun: p.DryRun} if slices.ContainsFunc(a.listed[key], func(l listedChange) bool { return reflect.DeepEqual(l, lc) }) { - return + return changed } a.listed[key] = append(a.listed[key], lc) @@ -217,6 +245,41 @@ func (a *assembler) addDiff(d *dt.ResourceDiff, composite *plan.ResourceRef) { // their redacted values are not mistaken for one change. p.Changes = redact(d.Gvk, p.Changes) a.plans = append(a.plans, p) + + return changed +} + +// merge returns the plan for d given its engine plan p and its provider plan +// pp, or nil to drop it. The cluster layer decides what changes in the +// cluster; the provider refines what that does to the external resource. +func merge(d *dt.ResourceDiff, p, pp *plan.ResourcePlan) *plan.ResourcePlan { + switch { + case pp == nil: + return p + case pp.Error != "": + // An error plan, as the engine's own are, keeping the engine's + // identity (its name carries the "(generated)" display name) and + // DryRun. + p.Action, p.Changes, p.Error = "", nil, pp.Error + return p + case pp.Approximate: + // The engine's diff of the dry-run result beats Fallback's + // spec-only one. + p.Approximate = true + p.FallbackReason = pp.FallbackReason + case pp.Action == plan.ActionCreate, pp.Action == plan.ActionUpdate, pp.Action == plan.ActionReplace: + p.Action = pp.Action + p.Changes = pp.Changes + p.ReplacePaths = pp.ReplacePaths + } + + // An equal diff is kept only for its provider plan; when that finds no + // change either, the resource is no news. + if d.DiffType == dt.DiffTypeEqual && p.Action == plan.ActionNoOp { + return nil + } + + return p } // addError records e, failing the plan for ref. composite is the composite @@ -251,7 +314,9 @@ func (a *assembler) result(warnings []dt.OutputWarning) plan.Result { res.Composites = a.composites res.Errors = a.errors res.Errored = res.Errored || len(a.errors) > 0 - res.Notes = []string{noteProviderPlanNotRun} + if a.provider == nil { + res.Notes = []string{noteProviderPlanNotRun} + } for _, w := range warnings { res.Warnings = append(res.Warnings, plan.Warning{Message: w.Message, Context: w.Context}) diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index 13579f6e..9141a820 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -18,6 +18,7 @@ package simulate import ( "maps" + "slices" "testing" "github.com/google/go-cmp/cmp" @@ -290,7 +291,7 @@ func TestFromXRReport(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - got := FromXRReport(tc.in) + got := FromXRReport(tc.in, nil) if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { t.Errorf("%s\nFromXRReport(...): -want, +got:\n%s", tc.reason, diff) } @@ -474,10 +475,138 @@ func TestFromCompReport(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - got := FromCompReport(tc.in) + got := FromCompReport(tc.in, nil) if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { t.Errorf("%s\nFromCompReport(...): -want, +got:\n%s", tc.reason, diff) } }) } } + +// TestProviderPlanMerge covers each row of how a provider plan merges into the +// engine's plan for the same diff key, and the composite status upgrade. +func TestProviderPlanMerge(t *testing.T) { + added := rdiff(cmGVK, "cm", dt.DiffTypeAdded, nil, map[string]any{"data": map[string]any{"k": "v"}}) + modified := rdiff(cmGVK, "cm", dt.DiffTypeModified, + map[string]any{"metadata": map[string]any{"labels": map[string]any{"a": "1"}}}, + map[string]any{"metadata": map[string]any{"labels": map[string]any{"a": "2"}}}) + modified.DryRun = &dt.DryRunInfo{SkipReason: dt.DryRunSkipForbidden, Detail: "no create"} + equal := rdiff(cmGVK, "cm", dt.DiffTypeEqual, nil, nil) + key := added.GetDiffKey() + + labelChange := []plan.FieldChange{{Path: "metadata.labels.a", Actual: &plan.Value{Value: "1"}, Planned: &plan.Value{Value: "2"}}} + providerChange := []plan.FieldChange{{Path: "spec.forProvider.size", Actual: &plan.Value{Value: "1"}, Planned: &plan.Value{Value: "2"}}} + forbidden := &plan.Verification{SkipReason: "forbidden", Detail: "no create"} + cm := func(p plan.ResourcePlan) *plan.ResourcePlan { + p.APIVersion, p.Kind, p.Name, p.Composite = "v1", "ConfigMap", "cm", &xrPlan + return &p + } + + cases := map[string]struct { + reason string + diff *dt.ResourceDiff + plans map[string]*plan.ResourcePlan + wantPlans []*plan.ResourcePlan + wantStatus plan.CompositeStatus + }{ + "NoProviderEntry": { + reason: "With no provider entry the engine plan stands, and an empty plans map drops the not-run note.", + diff: modified, + plans: map[string]*plan.ResourcePlan{}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionUpdate, Changes: labelChange, DryRun: forbidden})}, + wantStatus: plan.CompositeChanged, + }, + "ProviderError": { + reason: "A provider error is an error plan keeping the engine's identity, composite and DryRun.", + diff: modified, + plans: map[string]*plan.ResourcePlan{key: {Kind: "Other", Name: "x", Error: "diff server cannot plan: boom"}}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Error: "diff server cannot plan: boom", DryRun: forbidden})}, + wantStatus: plan.CompositeChanged, + }, + "ProviderApproximate": { + reason: "A NotSupported fallback keeps the engine's diff, marked approximate with the provider's reason.", + diff: modified, + plans: map[string]*plan.ResourcePlan{key: { + Action: plan.ActionUpdate, Changes: providerChange, Approximate: true, FallbackReason: "the provider cannot compute a diff", + }}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{ + Action: plan.ActionUpdate, Changes: labelChange, DryRun: forbidden, Approximate: true, FallbackReason: "the provider cannot compute a diff", + })}, + wantStatus: plan.CompositeChanged, + }, + "AddedProviderCreate": { + reason: "A provider create replaces the engine's changes.", + diff: added, + plans: map[string]*plan.ResourcePlan{key: {Action: plan.ActionCreate, Changes: providerChange}}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionCreate, Changes: providerChange})}, + wantStatus: plan.CompositeChanged, + }, + "ModifiedProviderReplace": { + reason: "A provider replace takes the action, changes and replace paths, keeping the engine's DryRun.", + diff: modified, + plans: map[string]*plan.ResourcePlan{key: { + Action: plan.ActionReplace, Changes: providerChange, ReplacePaths: []string{"spec.forProvider.size"}, + }}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{ + Action: plan.ActionReplace, Changes: providerChange, ReplacePaths: []string{"spec.forProvider.size"}, DryRun: forbidden, + })}, + wantStatus: plan.CompositeChanged, + }, + "ModifiedProviderNoOp": { + reason: "The cluster object still changes when the provider finds nothing, so the engine's update stands.", + diff: modified, + plans: map[string]*plan.ResourcePlan{key: {Action: plan.ActionNoOp}}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionUpdate, Changes: labelChange, DryRun: forbidden})}, + wantStatus: plan.CompositeChanged, + }, + "EqualProviderUpdateUpgradesComposite": { + reason: "Cloud drift on an MR the cluster finds equal is an update, and makes its unchanged composite changed.", + diff: equal, + plans: map[string]*plan.ResourcePlan{key: {Action: plan.ActionUpdate, Changes: providerChange}}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionUpdate, Changes: providerChange})}, + wantStatus: plan.CompositeChanged, + }, + "EqualProviderNoOpDropped": { + reason: "A kept-equal MR the provider finds unchanged is dropped, and its composite stays unchanged.", + diff: equal, + plans: map[string]*plan.ResourcePlan{key: {Action: plan.ActionNoOp}}, + wantStatus: plan.CompositeUnchanged, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + xr := rdiff(xnetGVK, "prod", dt.DiffTypeEqual, nil, nil) + // Plans sort by kind, so the XR's own no-op plan comes last. + wantPlans := append(slices.Clone(tc.wantPlans), &plan.ResourcePlan{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Action: plan.ActionNoOp, Composite: &xrPlan}) + + gotXR := FromXRReport(&dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(xr, tc.diff)}}}, tc.plans) + if diff := cmp.Diff(wantPlans, gotXR.Plans, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nFromXRReport(...).Plans: -want, +got:\n%s", tc.reason, diff) + } + if got := gotXR.Composites[0].Status; got != tc.wantStatus { + t.Errorf("%s\nFromXRReport(...): want composite %q, got %q", tc.reason, tc.wantStatus, got) + } + if len(gotXR.Notes) != 0 { + t.Errorf("%s\nFromXRReport(...): want no note with provider plans, got %v", tc.reason, gotXR.Notes) + } + + // The composition path merges the same way. An Unchanged impact + // carries diffs only when the engine kept equal ones. + status := dt.XRStatusUnchanged + if tc.diff.DiffType != dt.DiffTypeEqual { + status = dt.XRStatusChanged + } + gotComp := FromCompReport(&dt.CompDiffOutput{Compositions: []dt.CompositionDiff{{ + Name: "net", + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: status, Diffs: diffs(tc.diff)}}, + }}}, tc.plans) + if diff := cmp.Diff(tc.wantPlans, gotComp.Plans[1:], cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nFromCompReport(...).Plans: -want, +got:\n%s", tc.reason, diff) + } + if got := gotComp.Composites[0].Status; got != tc.wantStatus { + t.Errorf("%s\nFromCompReport(...): want composite %q, got %q", tc.reason, tc.wantStatus, got) + } + }) + } +} diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go index 105faef0..273360a4 100644 --- a/internal/simulate/simulate.go +++ b/internal/simulate/simulate.go @@ -132,6 +132,12 @@ func (p *Planner) PlanWithLive(ctx context.Context, desired, live *unstructured. return rp } +// CanPlan reports whether a diff server serves resources of kind gk. +func (p *Planner) CanPlan(gk schema.GroupKind) bool { + _, ok := p.router.Route(gk) + return ok +} + // fetchLive returns the live counterpart of the desired resource, or nil when // it does not exist (including when its kind is not installed on the // cluster), in which case the resource plans as a create. @@ -285,33 +291,12 @@ func RelevantProviderImages(ctx context.Context, kubeClient client.Client, rmapp } seenGK[gk] = true - mapping, err := rmapper.RESTMapping(gk, gvk.Version) + revision, crdName, err := controllingRevision(ctx, kubeClient, rmapper, gvk) if err != nil { - continue // The kind is not installed; nothing serves it. - } - - crd := &unstructured.Unstructured{} - crd.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) - crdName := mapping.Resource.Resource + "." + gk.Group - if err := kubeClient.Get(ctx, client.ObjectKey{Name: crdName}, crd); err != nil { - if apierrors.IsNotFound(err) { - continue - } - return nil, errors.Wrapf(err, "cannot get CRD %s", crdName) - } - - // After an upgrade every revision of the provider still owns the CRD; - // only the active revision holds the controller reference, and that is - // the one serving the resources. - revision := "" - for _, ref := range crd.GetOwnerReferences() { - if ref.Kind == "ProviderRevision" && strings.HasPrefix(ref.APIVersion, "pkg.crossplane.io/") && ref.Controller != nil && *ref.Controller { - revision = ref.Name - break - } + return nil, err } if revision == "" { - continue // Not provider-installed (e.g. a hand-applied CRD). + continue } prv := &pkgv1.ProviderRevision{} @@ -339,3 +324,55 @@ func RelevantProviderImages(ctx context.Context, kubeClient client.Client, rmapp } return images, nil } + +// ProviderManaged returns a predicate reporting whether a resource's kind is +// served by a provider: whether its CRD is controlled by a ProviderRevision, +// as RelevantProviderImages discovers. Answers are cached per group and kind. +// 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 { + managed := map[schema.GroupKind]bool{} + + // The engine diffs one resource at a time, so the cache needs no lock. + return func(u *unstructured.Unstructured) bool { + gvk := u.GroupVersionKind() + if m, ok := managed[gvk.GroupKind()]; ok { + return m + } + revision, _, err := controllingRevision(ctx, kubeClient, rmapper, gvk) + managed[gvk.GroupKind()] = err == nil && revision != "" + return managed[gvk.GroupKind()] + } +} + +// controllingRevision returns the name of the ProviderRevision controlling the +// CRD that serves gvk, and that CRD's name. The revision is empty when the +// kind is not installed or its CRD is not provider-installed. +func controllingRevision(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, gvk schema.GroupVersionKind) (string, string, error) { + gk := gvk.GroupKind() + mapping, err := rmapper.RESTMapping(gk, gvk.Version) + if err != nil { + return "", "", nil //nolint:nilerr // Intentional: the kind is not installed, so nothing serves it. + } + + crd := &unstructured.Unstructured{} + crd.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) + crdName := mapping.Resource.Resource + "." + gk.Group + if err := kubeClient.Get(ctx, client.ObjectKey{Name: crdName}, crd); err != nil { + if apierrors.IsNotFound(err) { + return "", crdName, nil + } + return "", crdName, errors.Wrapf(err, "cannot get CRD %s", crdName) + } + + // After an upgrade every revision of the provider still owns the CRD; + // only the active revision holds the controller reference, and that is + // the one serving the resources. + for _, ref := range crd.GetOwnerReferences() { + if ref.Kind == "ProviderRevision" && strings.HasPrefix(ref.APIVersion, "pkg.crossplane.io/") && ref.Controller != nil && *ref.Controller { + return ref.Name, crdName, nil + } + } + return "", crdName, nil // Not provider-installed (e.g. a hand-applied CRD). +} diff --git a/internal/simulate/simulate_test.go b/internal/simulate/simulate_test.go index 76dca721..21cb0704 100644 --- a/internal/simulate/simulate_test.go +++ b/internal/simulate/simulate_test.go @@ -405,6 +405,16 @@ func TestPlanner(t *testing.T) { if len(got) != 1 || got[0] != want[0] { t.Errorf("RelevantProviderImages(...): want %v, got %v", want, got) } + + // ProviderManaged follows the same chain: a VPC's CRD is controlled + // by a ProviderRevision; an unserved kind has no CRD. + managed := ProviderManaged(ctx, kube, rm) + if !managed(vpc("a", "10.0.0.0/16")) { + t.Errorf("ProviderManaged(...)(VPC): want true for a provider-installed kind") + } + if managed(unserved) { + t.Errorf("ProviderManaged(...)(Key): want false for a kind with no CRD") + } }) t.Run("UnservedKindPlansCreate", func(t *testing.T) { From 204acc6b4178d609d2bbb9e3344779bdc9e40933 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 03:14:49 -0400 Subject: [PATCH 058/126] refactor(simulate): share diff server lifecycle; load project provider dependencies Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/simulate/simulate.go | 273 +-------------- internal/project/render.go | 63 +++- internal/project/render_test.go | 99 ++++++ internal/simulate/servers.go | 318 ++++++++++++++++++ .../simulate/servers_test.go | 12 +- 5 files changed, 479 insertions(+), 286 deletions(-) create mode 100644 internal/project/render_test.go create mode 100644 internal/simulate/servers.go rename cmd/crossplane/simulate/simulate_test.go => internal/simulate/servers_test.go (95%) diff --git a/cmd/crossplane/simulate/simulate.go b/cmd/crossplane/simulate/simulate.go index 659d2e3e..c051cdb6 100644 --- a/cmd/crossplane/simulate/simulate.go +++ b/cmd/crossplane/simulate/simulate.go @@ -21,8 +21,6 @@ package simulate import ( "context" - "fmt" - "net/netip" "os" "path/filepath" "strings" @@ -31,28 +29,16 @@ import ( "github.com/alecthomas/kong" "github.com/spf13/afero" "k8s.io/apimachinery/pkg/api/meta" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/client-go/discovery" - "k8s.io/client-go/discovery/cached/memory" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/restmapper" "sigs.k8s.io/controller-runtime/pkg/client" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - "github.com/crossplane/crossplane/apis/v2/pkg" - pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" - "github.com/crossplane/cli/v2/cmd/crossplane/common/load" - "github.com/crossplane/cli/v2/internal/async" - "github.com/crossplane/cli/v2/internal/containerruntime" "github.com/crossplane/cli/v2/internal/diffserver" "github.com/crossplane/cli/v2/internal/docker" "github.com/crossplane/cli/v2/internal/kube" "github.com/crossplane/cli/v2/internal/plan" - "github.com/crossplane/cli/v2/internal/project/projectfile" isimulate "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" @@ -63,45 +49,6 @@ import ( //go:embed help/simulate.md var helpDetail string -// Per-provider runtime annotations on Provider manifests passed via -// --providers, mirroring render's per-function runtime annotations. -const ( - // AnnotationKeyRuntime selects how a provider's diff server is run: - // Docker (the default) runs the package image as a local container, - // Development connects to a diff server already running outside Docker, - // for example from source or under a debugger. - AnnotationKeyRuntime = "simulate.crossplane.io/runtime" - - // AnnotationKeyDockerPublishAddress sets the address Docker publishes - // this provider's diff server port on. Defaults to loopback only. - AnnotationKeyDockerPublishAddress = "simulate.crossplane.io/runtime-docker-publish-address" - // AnnotationKeyDockerTarget sets the host used to connect to this - // provider's diff server when it differs from the publish address, e.g. - // with a VM-based or remote Docker daemon. - AnnotationKeyDockerTarget = "simulate.crossplane.io/runtime-docker-target" - - // AnnotationKeyDockerCleanup configures how a provider's diff server - // container is cleaned up once the run is done: Remove (the default) - // tears it down, Orphan leaves it running so later runs reuse it without - // paying startup. - AnnotationKeyDockerCleanup = "simulate.crossplane.io/runtime-docker-cleanup" - - // AnnotationKeyDevelopmentTarget is the gRPC target of a Development - // provider's diff server. Defaults to localhost:9099, upjet's default - // for `internal diff-server --address`. - AnnotationKeyDevelopmentTarget = "simulate.crossplane.io/runtime-development-target" - - // AnnotationKeyDevelopmentAPIGroups lists, comma-separated, the API - // groups a Development provider's diff server serves. No package is - // fetched for a Development provider, so nothing else says which - // resources route to it. Legacy and namespaced (.m.) groups are distinct - // and must both be listed. - AnnotationKeyDevelopmentAPIGroups = "simulate.crossplane.io/runtime-development-api-groups" - - // defaultDevelopmentTarget is upjet's default diff server address. - defaultDevelopmentTarget = "localhost:9099" -) - // Cmd previews what applying managed resources would do. type Cmd struct { Filename string `help:"YAML files or directory with managed resources, or '-' for stdin (comma-separated)." predictor:"yaml_file_or_directory" required:"" short:"f"` @@ -158,14 +105,14 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s var inputs []diffserver.Input if c.Providers != "" { - if inputs, err = loadProviderInputs(c.Providers); err != nil { + if inputs, err = isimulate.LoadProviderInputs(c.Providers); err != nil { return errors.Wrapf(err, "cannot load provider manifests from %q", c.Providers) } } // Only a provider whose package image runs as a container needs Docker; // a Development provider's server is already running. Discovery from the // cluster always yields the former. - if c.Providers == "" || needsDocker(inputs) { + if c.Providers == "" || isimulate.NeedsDocker(inputs) { if err := docker.Check(ctx); err != nil { return errors.Wrap(err, "cannot connect to Docker, which simulate needs to run provider diff servers") } @@ -176,7 +123,7 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s return err } - imageConfigs, err := c.imageConfigs() + imageConfigs, err := isimulate.ImageConfigs(c.ImageConfigs, c.ProjectFile) if err != nil { return err } @@ -191,10 +138,6 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s } inputs = diffserver.Images(images) } - if len(inputs) == 0 { - _, _ = fmt.Fprintln(k.Stderr, "No providers found to run as diff servers; all results will be approximate.") - } - pkgClient, err := clixpkg.NewClient(clixpkg.NewRemoteFetcher(), clixpkg.WithCacheDir(afero.NewOsFs(), c.CacheDir), clixpkg.WithImageConfigs(imageConfigs), @@ -203,25 +146,12 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s return errors.Wrap(err, "cannot create package client") } - mgr := diffserver.NewManager(pkgClient, logger, diffserver.WithSessionID(c.SessionID)) - var skipped []diffserver.Skipped - if err := sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { - var err error - skipped, err = mgr.Start(ctx, inputs, ch) + mgr, stop, err := isimulate.StartDiffServers(ctx, sp, logger, k.Stderr, pkgClient, inputs, c.SessionID, + "No providers found to run as diff servers; all results will be approximate.") + if err != nil { return err - }); err != nil { - return errors.Wrap(err, "cannot start diff servers") - } - // Cleanup runs even when ctx was cancelled by a signal, so an interrupted - // run still tears its containers down. - defer func() { - if err := mgr.Stop(context.WithoutCancel(ctx)); err != nil { - _, _ = fmt.Fprintf(k.Stderr, "Warning: cannot clean up diff servers: %s\n", err) - } - }() - for _, s := range skipped { - _, _ = fmt.Fprintf(k.Stderr, "Not running a diff server for %s: %s\n", s.Image, s.Reason) } + defer stop() plans := make([]*plan.ResourcePlan, 0, len(resources)) planner := isimulate.NewPlanner(kubeClient, rmapper, defaultNamespace, mgr, c.Timeout) @@ -232,183 +162,6 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s return renderer.Render(k.Stdout, plan.NewResult(plans)) } -// imageConfigs returns the ImageConfigs to rewrite provider image paths with: -// the --image-configs file when given, else the imageConfigs of the project -// the command happens to run in, if any. Running outside a project is the -// norm for this command, so a missing project file is only an error when the -// user asked for one explicitly with --project-file. -func (c *Cmd) imageConfigs() ([]pkgv1beta1.ImageConfig, error) { - if c.ImageConfigs != "" { - ics, err := loadImageConfigs(c.ImageConfigs) - return ics, errors.Wrapf(err, "cannot load image configs from %q", c.ImageConfigs) - } - - name, explicit := c.ProjectFile, true - if name == "" { - name, explicit = clixpkg.ProjectFile, false - } - projFilePath, err := filepath.Abs(name) - if err != nil { - return nil, errors.Wrapf(err, "cannot determine path for %q", name) - } - if _, err := os.Stat(projFilePath); err != nil { - if explicit { - return nil, errors.Wrapf(err, "cannot read project file %q", name) - } - return nil, nil // Best effort: no project file means no project image configs. - } - projFS := afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)) - proj, err := projectfile.Parse(projFS, filepath.Base(projFilePath)) - if err != nil { - return nil, errors.Wrapf(err, "cannot parse project file %q", name) - } - return proj.Spec.ImageConfigs, nil -} - -// needsDocker reports whether any input runs its diff server as a container. -func needsDocker(inputs []diffserver.Input) bool { - for _, in := range inputs { - if in.Runtime != diffserver.RuntimeDevelopment { - return true - } - } - return false -} - -// loadProviderInputs reads Provider manifests and turns them into diff server -// inputs per their simulate.crossplane.io runtime annotations: the package -// image from spec.package with per-provider Docker overrides, or, for the -// Development runtime, the target of an already running server and the API -// groups it serves. A group can route to only one development server, so -// manifests that declare the same group are rejected up front. -func loadProviderInputs(path string) ([]diffserver.Input, error) { - loader, err := load.NewLoader(path) - if err != nil { - return nil, err - } - objs, err := loader.Load() - if err != nil { - return nil, err - } - - inputs := make([]diffserver.Input, 0, len(objs)) - declared := map[string]string{} // API group -> the Development provider declaring it - for _, o := range objs { - if o.GetKind() != "Provider" { - return nil, errors.Errorf("expected only Provider manifests, found %s", o.GetKind()) - } - in, err := providerInput(o) - if err != nil { - return nil, err - } - for _, g := range in.APIGroups { - if other, ok := declared[g]; ok { - return nil, errors.Errorf("providers %q and %q both declare the API group %q in their %s annotation: a group can route to only one development diff server", - other, o.GetName(), g, AnnotationKeyDevelopmentAPIGroups) - } - declared[g] = o.GetName() - } - inputs = append(inputs, in) - } - return inputs, nil -} - -// providerInput turns one Provider manifest into a diff server input per its -// runtime annotation. -func providerInput(o *unstructured.Unstructured) (diffserver.Input, error) { - switch r := diffserver.Runtime(o.GetAnnotations()[AnnotationKeyRuntime]); r { - case diffserver.RuntimeDocker, "": - return dockerInput(o) - case diffserver.RuntimeDevelopment: - return developmentInput(o) - default: - return diffserver.Input{}, errors.Errorf("provider %q has an unsupported %s annotation value %q: must be %s or %s", - o.GetName(), AnnotationKeyRuntime, r, diffserver.RuntimeDocker, diffserver.RuntimeDevelopment) - } -} - -// dockerInput reads a provider whose package image runs as a container: the -// image from spec.package and the Docker overrides from its annotations. -func dockerInput(o *unstructured.Unstructured) (diffserver.Input, error) { - in := diffserver.Input{} - if in.Image, _, _ = unstructured.NestedString(o.Object, "spec", "package"); in.Image == "" { - return diffserver.Input{}, errors.Errorf("provider %q has no spec.package", o.GetName()) - } - annotations := o.GetAnnotations() - if v := annotations[AnnotationKeyDockerPublishAddress]; v != "" { - addr, err := netip.ParseAddr(v) - if err != nil { - return diffserver.Input{}, errors.Wrapf(err, "provider %q has an invalid %s annotation", o.GetName(), AnnotationKeyDockerPublishAddress) - } - in.PublishAddress = addr - } - in.Target = annotations[AnnotationKeyDockerTarget] - switch cleanup := diffserver.Cleanup(annotations[AnnotationKeyDockerCleanup]); cleanup { - case diffserver.CleanupRemove, diffserver.CleanupOrphan, "": - in.Cleanup = cleanup - case containerruntime.CleanupStop: - // Render's Stop policy is deliberately unsupported: a stopped - // diff server serves nothing, and restarting one costs nearly as - // much as starting it. - fallthrough - default: - return diffserver.Input{}, errors.Errorf("provider %q has an invalid %s annotation %q: must be %s or %s", - o.GetName(), AnnotationKeyDockerCleanup, cleanup, diffserver.CleanupRemove, diffserver.CleanupOrphan) - } - return in, nil -} - -// developmentInput reads a provider whose diff server the user already runs. -// Nothing is fetched and nothing starts for it, so spec.package is not -// needed and the Docker annotations are ignored, the way render ignores them -// on a Development function. The served API groups must be declared instead. -func developmentInput(o *unstructured.Unstructured) (diffserver.Input, error) { - annotations := o.GetAnnotations() - in := diffserver.Input{ - Runtime: diffserver.RuntimeDevelopment, - Name: o.GetName(), - DevelopmentTarget: defaultDevelopmentTarget, - } - if t := annotations[AnnotationKeyDevelopmentTarget]; t != "" { - in.DevelopmentTarget = t - } - for g := range strings.SplitSeq(annotations[AnnotationKeyDevelopmentAPIGroups], ",") { - if g = strings.TrimSpace(g); g != "" { - in.APIGroups = append(in.APIGroups, g) - } - } - if len(in.APIGroups) == 0 { - return diffserver.Input{}, errors.Errorf("provider %q uses the Development runtime but declares no API groups: set its %s annotation to the comma-separated groups its diff server serves, for example ec2.aws.upbound.io,ec2.aws.m.upbound.io", - o.GetName(), AnnotationKeyDevelopmentAPIGroups) - } - return in, nil -} - -// loadImageConfigs reads ImageConfig manifests from a YAML file. -func loadImageConfigs(path string) ([]pkgv1beta1.ImageConfig, error) { - loader, err := load.NewLoader(path) - if err != nil { - return nil, err - } - objs, err := loader.Load() - if err != nil { - return nil, err - } - - ics := make([]pkgv1beta1.ImageConfig, 0, len(objs)) - for _, o := range objs { - if o.GetKind() != "ImageConfig" { - return nil, errors.Errorf("expected only ImageConfig manifests, found %s", o.GetKind()) - } - ic := pkgv1beta1.ImageConfig{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.Object, &ic); err != nil { - return nil, errors.Wrapf(err, "cannot parse ImageConfig %s", o.GetName()) - } - ics = append(ics, ic) - } - return ics, nil -} - func (c *Cmd) setupKubeClient(logger logging.Logger) (client.Client, meta.RESTMapper, string, error) { kubeconfig, clientconfig, err := kube.RESTConfig(c.Context, c.Impersonation) if err != nil { @@ -417,18 +170,10 @@ func (c *Cmd) setupKubeClient(logger logging.Logger) (client.Client, meta.RESTMa logger.Debug("Found kubeconfig") - cl, err := client.New(kubeconfig, client.Options{Scheme: scheme.Scheme}) + cl, rmapper, err := isimulate.NewKubeClient(kubeconfig) if err != nil { - return nil, nil, "", errors.Wrap(err, "cannot init kubeclient") - } - _ = pkg.AddToScheme(cl.Scheme()) - - discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeconfig) - if err != nil { - return nil, nil, "", errors.Wrap(err, "cannot get discovery client") + return nil, nil, "", err } - d := memory.NewMemCacheClient(discoveryClient) - rmapper := restmapper.NewShortcutExpander(restmapper.NewDeferredDiscoveryRESTMapper(d), d, nil) namespace, _, err := clientconfig.Namespace() if err != nil { diff --git a/internal/project/render.go b/internal/project/render.go index 7207d895..f0061ad8 100644 --- a/internal/project/render.go +++ b/internal/project/render.go @@ -46,7 +46,49 @@ type Resolver interface { // LoadFunctionDependencies loads function manifests from a project's // dependencies, resolving version constraints using the provided resolver. func LoadFunctionDependencies(resolver Resolver, proj *devv1alpha1.Project) ([]pkgv1.Function, error) { - fns := make([]pkgv1.Function, 0, len(proj.Spec.Dependencies)) + refs, err := dependencyRefs(resolver, proj, pkgv1.FunctionKind) + if err != nil { + return nil, err + } + + fns := make([]pkgv1.Function, 0, len(refs)) + for _, resolved := range refs { + f := pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: xpkg.ToDNSLabel(resolved.Context().RepositoryStr()), + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: resolved.Name(), + }, + }, + } + fns = append(fns, f) + } + + return fns, nil +} + +// LoadProviderDependencies returns the resolved image references of a +// project's direct, non-apiOnly provider dependencies. +func LoadProviderDependencies(resolver Resolver, proj *devv1alpha1.Project) ([]string, error) { + refs, err := dependencyRefs(resolver, proj, pkgv1.ProviderKind) + if err != nil { + return nil, err + } + + images := make([]string, 0, len(refs)) + for _, resolved := range refs { + images = append(images, resolved.Name()) + } + return images, nil +} + +// dependencyRefs resolves a project's direct, non-apiOnly xpkg dependencies +// of the given kind, resolving version constraints using the provided +// resolver. +func dependencyRefs(resolver Resolver, proj *devv1alpha1.Project, kind string) ([]name.Reference, error) { + refs := make([]name.Reference, 0, len(proj.Spec.Dependencies)) for _, dep := range proj.Spec.Dependencies { if dep.Type != devv1alpha1.DependencyTypeXpkg { continue @@ -54,7 +96,7 @@ func LoadFunctionDependencies(resolver Resolver, proj *devv1alpha1.Project) ([]p if dep.Xpkg == nil || dep.Xpkg.APIOnly { continue } - if dep.Xpkg.Kind != pkgv1.FunctionKind { + if dep.Xpkg.Kind != kind { continue } @@ -67,23 +109,12 @@ func LoadFunctionDependencies(resolver Resolver, proj *devv1alpha1.Project) ([]p resolved, err := resolver.ResolveRef(ref) if err != nil { - return nil, errors.Wrapf(err, "cannot resolve function dependency %s", ref) + return nil, errors.Wrapf(err, "cannot resolve %s dependency %s", strings.ToLower(kind), ref) } - - f := pkgv1.Function{ - ObjectMeta: metav1.ObjectMeta{ - Name: xpkg.ToDNSLabel(resolved.Context().RepositoryStr()), - }, - Spec: pkgv1.FunctionSpec{ - PackageSpec: pkgv1.PackageSpec{ - Package: resolved.Name(), - }, - }, - } - fns = append(fns, f) + refs = append(refs, resolved) } - return fns, nil + return refs, nil } // EmbeddedFunctionsToDaemon loads each compatible image in the ImageTagMap into diff --git a/internal/project/render_test.go b/internal/project/render_test.go new file mode 100644 index 00000000..a1ca2c9c --- /dev/null +++ b/internal/project/render_test.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +// fakeResolver resolves a ref to itself, or fails for refs in errs. +type fakeResolver struct { + errs map[string]bool +} + +func (r fakeResolver) ResolveRef(ref string) (name.Reference, error) { + if r.errs[ref] { + return nil, errors.New("boom") + } + return name.ParseReference(ref) +} + +func xpkgDep(kind, pkg string, apiOnly bool) devv1alpha1.Dependency { + return devv1alpha1.Dependency{ + Type: devv1alpha1.DependencyTypeXpkg, + Xpkg: &devv1alpha1.XpkgDependency{Kind: kind, Package: pkg, Version: "v1.0.0", APIOnly: apiOnly}, + } +} + +func TestLoadProviderDependencies(t *testing.T) { + t.Parallel() + + const digest = "sha256:0000000000000000000000000000000000000000000000000000000000000000" + digestDep := xpkgDep(pkgv1.ProviderKind, "xpkg.example.com/acme/provider-pinned", false) + digestDep.Xpkg.Version = digest + + cases := map[string]struct { + deps []devv1alpha1.Dependency + resolver fakeResolver + want []string + wantErr bool + }{ + "ProvidersOnly": { + deps: []devv1alpha1.Dependency{ + xpkgDep(pkgv1.ProviderKind, "xpkg.example.com/acme/provider-a", false), + xpkgDep(pkgv1.FunctionKind, "xpkg.example.com/acme/function-a", false), + xpkgDep(pkgv1.ConfigurationKind, "xpkg.example.com/acme/configuration-a", false), + xpkgDep(pkgv1.ProviderKind, "xpkg.example.com/acme/provider-api", true), + {Type: devv1alpha1.DependencyTypeXpkg}, + digestDep, + }, + want: []string{ + "xpkg.example.com/acme/provider-a:v1.0.0", + "xpkg.example.com/acme/provider-pinned@" + digest, + }, + }, + "ResolveError": { + deps: []devv1alpha1.Dependency{xpkgDep(pkgv1.ProviderKind, "xpkg.example.com/acme/provider-a", false)}, + resolver: fakeResolver{errs: map[string]bool{"xpkg.example.com/acme/provider-a:v1.0.0": true}}, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + proj := &devv1alpha1.Project{Spec: devv1alpha1.ProjectSpec{Dependencies: tc.deps}} + got, err := LoadProviderDependencies(tc.resolver, proj) + if (err != nil) != tc.wantErr { + t.Fatalf("LoadProviderDependencies(...): error = %v, wantErr %v", err, tc.wantErr) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("LoadProviderDependencies(...): -want, +got:\n%s", diff) + } + }) + } +} diff --git a/internal/simulate/servers.go b/internal/simulate/servers.go new file mode 100644 index 00000000..5f2ecd6b --- /dev/null +++ b/internal/simulate/servers.go @@ -0,0 +1,318 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "context" + "fmt" + "io" + "net/netip" + "os" + "path/filepath" + "strings" + + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + "github.com/crossplane/crossplane/apis/v2/pkg" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/load" + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/containerruntime" + "github.com/crossplane/cli/v2/internal/diffserver" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// Per-provider runtime annotations on Provider manifests passed via +// --providers, mirroring render's per-function runtime annotations. +const ( + // AnnotationKeyRuntime selects how a provider's diff server is run: + // Docker (the default) runs the package image as a local container, + // Development connects to a diff server already running outside Docker, + // for example from source or under a debugger. + AnnotationKeyRuntime = "simulate.crossplane.io/runtime" + + // AnnotationKeyDockerPublishAddress sets the address Docker publishes + // this provider's diff server port on. Defaults to loopback only. + AnnotationKeyDockerPublishAddress = "simulate.crossplane.io/runtime-docker-publish-address" + // AnnotationKeyDockerTarget sets the host used to connect to this + // provider's diff server when it differs from the publish address, e.g. + // with a VM-based or remote Docker daemon. + AnnotationKeyDockerTarget = "simulate.crossplane.io/runtime-docker-target" + + // AnnotationKeyDockerCleanup configures how a provider's diff server + // container is cleaned up once the run is done: Remove (the default) + // tears it down, Orphan leaves it running so later runs reuse it without + // paying startup. + AnnotationKeyDockerCleanup = "simulate.crossplane.io/runtime-docker-cleanup" + + // AnnotationKeyDevelopmentTarget is the gRPC target of a Development + // provider's diff server. Defaults to localhost:9099, upjet's default + // for `internal diff-server --address`. + AnnotationKeyDevelopmentTarget = "simulate.crossplane.io/runtime-development-target" + + // AnnotationKeyDevelopmentAPIGroups lists, comma-separated, the API + // groups a Development provider's diff server serves. No package is + // fetched for a Development provider, so nothing else says which + // resources route to it. Legacy and namespaced (.m.) groups are distinct + // and must both be listed. + AnnotationKeyDevelopmentAPIGroups = "simulate.crossplane.io/runtime-development-api-groups" + + // defaultDevelopmentTarget is upjet's default diff server address. + defaultDevelopmentTarget = "localhost:9099" +) + + +// StartDiffServers starts a diff server for each input and returns the +// manager to plan through and a func that stops the servers. It prints +// noProviders to stderr when there are no inputs, and the servers it skipped. +func StartDiffServers(ctx context.Context, sp terminal.SpinnerPrinter, log logging.Logger, stderr io.Writer, + pkgs runtimexpkg.Client, inputs []diffserver.Input, session, noProviders string, +) (*diffserver.Manager, func(), error) { + if len(inputs) == 0 { + _, _ = fmt.Fprintln(stderr, noProviders) + } + + mgr := diffserver.NewManager(pkgs, log, diffserver.WithSessionID(session)) + var skipped []diffserver.Skipped + if err := sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { + var err error + skipped, err = mgr.Start(ctx, inputs, ch) + return err + }); err != nil { + return nil, nil, errors.Wrap(err, "cannot start diff servers") + } + // Cleanup runs even when ctx was cancelled by a signal, so an interrupted + // run still tears its containers down. + stop := func() { + if err := mgr.Stop(context.WithoutCancel(ctx)); err != nil { + _, _ = fmt.Fprintf(stderr, "Warning: cannot clean up diff servers: %s\n", err) + } + } + for _, s := range skipped { + _, _ = fmt.Fprintf(stderr, "Not running a diff server for %s: %s\n", s.Image, s.Reason) + } + return mgr, stop, nil +} + +// ImageConfigs returns the ImageConfigs to rewrite provider image paths with: +// the imageConfigsFile when given, else the imageConfigs of the project +// the command happens to run in, if any. Running outside a project is the +// norm for this command, so a missing project file is only an error when the +// user asked for one explicitly with projectFile. +func ImageConfigs(imageConfigsFile, projectFile string) ([]pkgv1beta1.ImageConfig, error) { + if imageConfigsFile != "" { + ics, err := LoadImageConfigs(imageConfigsFile) + return ics, errors.Wrapf(err, "cannot load image configs from %q", imageConfigsFile) + } + + name, explicit := projectFile, true + if name == "" { + name, explicit = clixpkg.ProjectFile, false + } + projFilePath, err := filepath.Abs(name) + if err != nil { + return nil, errors.Wrapf(err, "cannot determine path for %q", name) + } + if _, err := os.Stat(projFilePath); err != nil { + if explicit { + return nil, errors.Wrapf(err, "cannot read project file %q", name) + } + return nil, nil // Best effort: no project file means no project image configs. + } + projFS := afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)) + proj, err := projectfile.Parse(projFS, filepath.Base(projFilePath)) + if err != nil { + return nil, errors.Wrapf(err, "cannot parse project file %q", name) + } + return proj.Spec.ImageConfigs, nil +} + +// NeedsDocker reports whether any input runs its diff server as a container. +func NeedsDocker(inputs []diffserver.Input) bool { + for _, in := range inputs { + if in.Runtime != diffserver.RuntimeDevelopment { + return true + } + } + return false +} + +// LoadProviderInputs reads Provider manifests and turns them into diff server +// inputs per their simulate.crossplane.io runtime annotations: the package +// image from spec.package with per-provider Docker overrides, or, for the +// Development runtime, the target of an already running server and the API +// groups it serves. A group can route to only one development server, so +// manifests that declare the same group are rejected up front. +func LoadProviderInputs(path string) ([]diffserver.Input, error) { + loader, err := load.NewLoader(path) + if err != nil { + return nil, err + } + objs, err := loader.Load() + if err != nil { + return nil, err + } + + inputs := make([]diffserver.Input, 0, len(objs)) + declared := map[string]string{} // API group -> the Development provider declaring it + for _, o := range objs { + if o.GetKind() != "Provider" { + return nil, errors.Errorf("expected only Provider manifests, found %s", o.GetKind()) + } + in, err := providerInput(o) + if err != nil { + return nil, err + } + for _, g := range in.APIGroups { + if other, ok := declared[g]; ok { + return nil, errors.Errorf("providers %q and %q both declare the API group %q in their %s annotation: a group can route to only one development diff server", + other, o.GetName(), g, AnnotationKeyDevelopmentAPIGroups) + } + declared[g] = o.GetName() + } + inputs = append(inputs, in) + } + return inputs, nil +} + +// providerInput turns one Provider manifest into a diff server input per its +// runtime annotation. +func providerInput(o *unstructured.Unstructured) (diffserver.Input, error) { + switch r := diffserver.Runtime(o.GetAnnotations()[AnnotationKeyRuntime]); r { + case diffserver.RuntimeDocker, "": + return dockerInput(o) + case diffserver.RuntimeDevelopment: + return developmentInput(o) + default: + return diffserver.Input{}, errors.Errorf("provider %q has an unsupported %s annotation value %q: must be %s or %s", + o.GetName(), AnnotationKeyRuntime, r, diffserver.RuntimeDocker, diffserver.RuntimeDevelopment) + } +} + +// dockerInput reads a provider whose package image runs as a container: the +// image from spec.package and the Docker overrides from its annotations. +func dockerInput(o *unstructured.Unstructured) (diffserver.Input, error) { + in := diffserver.Input{} + if in.Image, _, _ = unstructured.NestedString(o.Object, "spec", "package"); in.Image == "" { + return diffserver.Input{}, errors.Errorf("provider %q has no spec.package", o.GetName()) + } + annotations := o.GetAnnotations() + if v := annotations[AnnotationKeyDockerPublishAddress]; v != "" { + addr, err := netip.ParseAddr(v) + if err != nil { + return diffserver.Input{}, errors.Wrapf(err, "provider %q has an invalid %s annotation", o.GetName(), AnnotationKeyDockerPublishAddress) + } + in.PublishAddress = addr + } + in.Target = annotations[AnnotationKeyDockerTarget] + switch cleanup := diffserver.Cleanup(annotations[AnnotationKeyDockerCleanup]); cleanup { + case diffserver.CleanupRemove, diffserver.CleanupOrphan, "": + in.Cleanup = cleanup + case containerruntime.CleanupStop: + // Render's Stop policy is deliberately unsupported: a stopped + // diff server serves nothing, and restarting one costs nearly as + // much as starting it. + fallthrough + default: + return diffserver.Input{}, errors.Errorf("provider %q has an invalid %s annotation %q: must be %s or %s", + o.GetName(), AnnotationKeyDockerCleanup, cleanup, diffserver.CleanupRemove, diffserver.CleanupOrphan) + } + return in, nil +} + +// developmentInput reads a provider whose diff server the user already runs. +// Nothing is fetched and nothing starts for it, so spec.package is not +// needed and the Docker annotations are ignored, the way render ignores them +// on a Development function. The served API groups must be declared instead. +func developmentInput(o *unstructured.Unstructured) (diffserver.Input, error) { + annotations := o.GetAnnotations() + in := diffserver.Input{ + Runtime: diffserver.RuntimeDevelopment, + Name: o.GetName(), + DevelopmentTarget: defaultDevelopmentTarget, + } + if t := annotations[AnnotationKeyDevelopmentTarget]; t != "" { + in.DevelopmentTarget = t + } + for g := range strings.SplitSeq(annotations[AnnotationKeyDevelopmentAPIGroups], ",") { + if g = strings.TrimSpace(g); g != "" { + in.APIGroups = append(in.APIGroups, g) + } + } + if len(in.APIGroups) == 0 { + return diffserver.Input{}, errors.Errorf("provider %q uses the Development runtime but declares no API groups: set its %s annotation to the comma-separated groups its diff server serves, for example ec2.aws.upbound.io,ec2.aws.m.upbound.io", + o.GetName(), AnnotationKeyDevelopmentAPIGroups) + } + return in, nil +} + +// LoadImageConfigs reads ImageConfig manifests from a YAML file. +func LoadImageConfigs(path string) ([]pkgv1beta1.ImageConfig, error) { + loader, err := load.NewLoader(path) + if err != nil { + return nil, err + } + objs, err := loader.Load() + if err != nil { + return nil, err + } + + ics := make([]pkgv1beta1.ImageConfig, 0, len(objs)) + for _, o := range objs { + if o.GetKind() != "ImageConfig" { + return nil, errors.Errorf("expected only ImageConfig manifests, found %s", o.GetKind()) + } + ic := pkgv1beta1.ImageConfig{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(o.Object, &ic); err != nil { + return nil, errors.Wrapf(err, "cannot parse ImageConfig %s", o.GetName()) + } + ics = append(ics, ic) + } + return ics, nil +} + +// NewKubeClient returns a client and a discovery-backed REST mapper for cfg. +func NewKubeClient(cfg *rest.Config) (client.Client, meta.RESTMapper, error) { + cl, err := client.New(cfg, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot init kubeclient") + } + _ = pkg.AddToScheme(cl.Scheme()) + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot get discovery client") + } + d := memory.NewMemCacheClient(discoveryClient) + return cl, restmapper.NewShortcutExpander(restmapper.NewDeferredDiscoveryRESTMapper(d), d, nil), nil +} diff --git a/cmd/crossplane/simulate/simulate_test.go b/internal/simulate/servers_test.go similarity index 95% rename from cmd/crossplane/simulate/simulate_test.go rename to internal/simulate/servers_test.go index 6a48c7bc..82e17a1d 100644 --- a/cmd/crossplane/simulate/simulate_test.go +++ b/internal/simulate/servers_test.go @@ -180,18 +180,18 @@ spec: t.Fatalf("cannot write manifest: %v", err) } - got, err := loadProviderInputs(path) + got, err := LoadProviderInputs(path) if tc.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tc.wantErr) { - t.Fatalf("%s\nloadProviderInputs(...): want an error containing %q, got %v", tc.reason, tc.wantErr, err) + t.Fatalf("%s\nLoadProviderInputs(...): want an error containing %q, got %v", tc.reason, tc.wantErr, err) } return } if err != nil { - t.Fatalf("%s\nloadProviderInputs(...): unexpected error: %v", tc.reason, err) + t.Fatalf("%s\nLoadProviderInputs(...): unexpected error: %v", tc.reason, err) } if diff := cmp.Diff(tc.want, got, cmp.Comparer(func(a, b netip.Addr) bool { return a == b })); diff != "" { - t.Errorf("%s\nloadProviderInputs(...): -want, +got:\n%s", tc.reason, diff) + t.Errorf("%s\nLoadProviderInputs(...): -want, +got:\n%s", tc.reason, diff) } }) } @@ -221,8 +221,8 @@ func TestNeedsDocker(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - if got := needsDocker(tc.inputs); got != tc.want { - t.Errorf("%s\nneedsDocker(...): want %t, got %t", tc.reason, tc.want, got) + if got := NeedsDocker(tc.inputs); got != tc.want { + t.Errorf("%s\nNeedsDocker(...): want %t, got %t", tc.reason, tc.want, got) } }) } From 24936f64b68bb86995ec64486c9aee3a108e5aea Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 05:12:27 -0400 Subject: [PATCH 059/126] feat(simulate): annotate -o diff with provider plans 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/renderer/diff_formatter.go | 4 + internal/diff/renderer/diff_renderer.go | 21 +- internal/diff/renderer/diff_renderer_test.go | 73 +++ internal/plan/output.go | 10 +- internal/simulate/annotate.go | 626 +++++++++++++++++++ internal/simulate/annotate_test.go | 440 +++++++++++++ 6 files changed, 1165 insertions(+), 9 deletions(-) create mode 100644 internal/simulate/annotate.go create mode 100644 internal/simulate/annotate_test.go diff --git a/internal/diff/renderer/diff_formatter.go b/internal/diff/renderer/diff_formatter.go index db57b411..cb1c2277 100644 --- a/internal/diff/renderer/diff_formatter.go +++ b/internal/diff/renderer/diff_formatter.go @@ -49,6 +49,10 @@ type DiffOptions struct { // per composition, omitting the full YAML diff body. Only consumed by the // human-readable composition diff renderer; structured output is unaffected. MinimizeComposition bool + + // Replaced, when set, reports whether a diff's resource would be replaced; + // its header and the summary say so. Nil reports none. + Replaced func(*t.ResourceDiff) bool } // OutputFormat represents the desired output format for diffs. diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index 3ab63c7b..62819abb 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -81,6 +81,7 @@ type diffCounts struct { modified int removed int equal int + replaced int output int } @@ -126,6 +127,11 @@ func (r *DefaultDiffRenderer) renderDiffList(diffs map[string]*dt.ResourceDiff) continue } + if r.diffOpts.Replaced != nil && r.diffOpts.Replaced(diff) { + counts.replaced++ + header += " (requires replacement)" + } + content := FormatDiff(diff.LineDiffs, r.diffOpts) if content != "" { @@ -153,6 +159,7 @@ func (c diffCounts) plus(other diffCounts) diffCounts { modified: c.modified + other.modified, removed: c.removed + other.removed, equal: c.equal + other.equal, + replaced: c.replaced + other.replaced, output: c.output + other.output, } } @@ -163,12 +170,13 @@ func (c diffCounts) plus(other diffCounts) diffCounts { // structured-logging pass could later swap this for a logr.Marshaler to regain // per-field queryability.) func (c diffCounts) String() string { - return fmt.Sprintf("added=%d modified=%d removed=%d equal=%d output=%d", - c.added, c.modified, c.removed, c.equal, c.output) + return fmt.Sprintf("added=%d modified=%d removed=%d equal=%d replaced=%d output=%d", + c.added, c.modified, c.removed, c.equal, c.replaced, c.output) } // summaryLine formats a "N added, N modified, N removed" fragment, omitting -// zero categories. Returns "" when there is nothing to report. +// zero categories, then " (N requires replacement)" when any diff would be +// replaced. Returns "" when there is nothing to report. func (c diffCounts) summaryLine() string { parts := make([]string, 0, 3) @@ -184,7 +192,12 @@ func (c diffCounts) summaryLine() string { parts = append(parts, fmt.Sprintf("%d removed", c.removed)) } - return strings.Join(parts, ", ") + line := strings.Join(parts, ", ") + if c.replaced > 0 && line != "" { + line += fmt.Sprintf(" (%d requires replacement)", c.replaced) + } + + return line } // hasIdentity reports whether a group carries input-XR identity. The diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index 32e42914..7856fb5f 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -2,6 +2,7 @@ package renderer import ( "bytes" + "slices" "strings" "testing" @@ -304,6 +305,49 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { "=== /", "=== (unknown)", // no header for the identity-less group }, }, + + // --- The Replaced hook --- + + // A replaced diff's header says so, and the summary counts it. + "ReplacedHeaderAndSummary": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + addedDiff.GetDiffKey(): addedDiff, + modifiedDiff.GetDiffKey(): modifiedDiff, + }), + options: replacing(noColor, "modified-resource"), + expectedOutputs: []string{ + "~~~ TestResource/modified-resource (requires replacement)\n", + "+++ TestResource/added-resource\n", + "Summary: 1 added, 1 modified (1 requires replacement)\n", + }, + }, + // The hook reaches every summary: each section's, and the footer's. + "ReplacedGroupedSummaries": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "first-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "second-xr", map[string]*dt.ResourceDiff{ + "q": modifiedDiffFor("Queue", "real-queue"), + }), + }, + options: replacing(noColor, "my-xr-bucket"), + expectedOutputs: []string{ + "~~~ Bucket/my-xr-bucket (requires replacement)\n", + "~~~ Queue/real-queue\n", + "Summary: 1 modified (1 requires replacement)\n", + "Summary: 1 modified\n", + "Total: 2 modified (1 requires replacement) across 2 XRs\n", + }, + }, + // An equal diff renders nothing, so it is never reported replaced. + "ReplacedEqualIsSkipped": { + groups: identitylessGroups(map[string]*dt.ResourceDiff{ + equalDiff.GetDiffKey(): equalDiff, + }), + options: replacing(noColor, "equal-resource"), + notExpected: []string{"requires replacement", "Summary:"}, + }, } for name, tt := range tests { @@ -398,6 +442,35 @@ func TestDefaultDiffRenderer_RenderDiffs_WithErrors(t *testing.T) { } } +// replacing returns opts with a Replaced hook reporting the named resources. +func replacing(opts DiffOptions, names ...string) DiffOptions { + opts.Replaced = func(d *dt.ResourceDiff) bool { return slices.Contains(names, d.ResourceName) } + return opts +} + +// TestDefaultDiffRenderer_NilReplacedIsUnchanged pins that a hook reporting +// nothing replaced renders exactly what no hook does. +func TestDefaultDiffRenderer_NilReplacedIsUnchanged(t *testing.T) { + groups := []dt.XRDiffGroup{ + xrGroup("XNopResource", "first-xr", map[string]*dt.ResourceDiff{"b": modifiedDiffFor("Bucket", "b")}), + xrGroup("XNopResource", "second-xr", map[string]*dt.ResourceDiff{"q": modifiedDiffFor("Queue", "q")}), + } + + render := func(opts DiffOptions) string { + var stdout bytes.Buffer + opts.Stdout, opts.Stderr = &stdout, &bytes.Buffer{} + if err := NewDiffRenderer(tu.TestLogger(t, false), opts).RenderDiffs(groups, nil, nil); err != nil { + t.Fatalf("RenderDiffs(): %v", err) + } + return stdout.String() + } + + want := render(DefaultDiffOptions()) + if got := render(replacing(DefaultDiffOptions())); got != want { + t.Errorf("RenderDiffs() with a hook replacing nothing:\n%q\nwant:\n%q", got, want) + } +} + // modifiedDiffFor builds a minimal modified diff for a composed resource of the // given kind/name, used to populate identity-bearing groups in grouped tests. func modifiedDiffFor(kind, name string) *dt.ResourceDiff { diff --git a/internal/plan/output.go b/internal/plan/output.go index 60f4a06c..2ab41e15 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -486,11 +486,11 @@ func renderChangeTree(w io.Writer, changes []FieldChange) { } switch { case c.Actual != nil && c.Planned != nil: - _, _ = fmt.Fprintf(w, "%s~ %s: %s -> %s%s\n", indent, leaf, formatValue(c.Actual), formatValue(c.Planned), note) + _, _ = fmt.Fprintf(w, "%s~ %s: %s -> %s%s\n", indent, leaf, FormatValue(c.Actual), FormatValue(c.Planned), note) case c.Planned != nil: - _, _ = fmt.Fprintf(w, "%s+ %s: %s%s\n", indent, leaf, formatValue(c.Planned), note) + _, _ = fmt.Fprintf(w, "%s+ %s: %s%s\n", indent, leaf, FormatValue(c.Planned), note) case c.Actual != nil: - _, _ = fmt.Fprintf(w, "%s- %s: %s%s\n", indent, leaf, formatValue(c.Actual), note) + _, _ = fmt.Fprintf(w, "%s- %s: %s%s\n", indent, leaf, FormatValue(c.Actual), note) default: _, _ = fmt.Fprintf(w, "%s! %s: (value not reported)%s\n", indent, leaf, note) } @@ -581,8 +581,8 @@ func sortedChanges(changes []FieldChange) []FieldChange { return out } -// formatValue renders one side of a change for humans. -func formatValue(v *Value) string { +// FormatValue renders one side of a change for humans. +func FormatValue(v *Value) string { switch { case v == nil: return "(none)" diff --git a/internal/simulate/annotate.go b/internal/simulate/annotate.go new file mode 100644 index 00000000..32a0fcc0 --- /dev/null +++ b/internal/simulate/annotate.go @@ -0,0 +1,626 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "maps" + "slices" + "strconv" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// The -o diff annotations for provider plans, as terraform writes them. +const ( + knownAfterApply = "(known after apply)" + forcesReplacement = " # forces replacement" + opInsert, opDelete = diffmatchpatch.DiffInsert, diffmatchpatch.DiffDelete + opEqual = diffmatchpatch.DiffEqual +) + +// AnnotateXRReport returns r with each resource diff annotated with its +// provider plan from plans (see annotateDiff), for the -o diff renderer. It +// never mutates r: the result is a shallow copy with cloned groups and diff +// maps, and each annotated diff is a copy with new LineDiffs. Empty plans +// return an equivalent copy, which renders exactly as r does. +func AnnotateXRReport(r *dt.XRReport, plans map[string]*plan.ResourcePlan) *dt.XRReport { + if r == nil { + return nil + } + + out := *r + out.Groups = slices.Clone(r.Groups) + for i := range out.Groups { + out.Groups[i].Diffs, _ = annotateDiffs(out.Groups[i].Diffs, plans) + } + + return &out +} + +// AnnotateCompReport returns o with each affected composite's diffs annotated +// with their provider plans, as AnnotateXRReport does. An unchanged composite +// whose provider plans find drift becomes changed, so the composition +// renderer lists and counts it. +func AnnotateCompReport(o *dt.CompDiffOutput, plans map[string]*plan.ResourcePlan) *dt.CompDiffOutput { + if o == nil { + return nil + } + + out := *o + out.Compositions = slices.Clone(o.Compositions) + for i := range out.Compositions { + comp := &out.Compositions[i] + comp.ImpactAnalysis = slices.Clone(comp.ImpactAnalysis) + for j := range comp.ImpactAnalysis { + impact := &comp.ImpactAnalysis[j] + diffs, drifted := annotateDiffs(impact.Diffs, plans) + impact.Diffs = diffs + if drifted && impact.Status == dt.XRStatusUnchanged { + impact.Status = dt.XRStatusChanged + comp.AffectedResources.Unchanged-- + comp.AffectedResources.WithChanges++ + } + } + } + + return &out +} + +// ReplacedBy returns the renderer.DiffOptions.Replaced hook for plans: a diff +// is replaced when its provider plan replaces it. It returns nil for empty +// plans, so -o diff without provider plans renders as it always has. +func ReplacedBy(plans map[string]*plan.ResourcePlan) func(*dt.ResourceDiff) bool { + if len(plans) == 0 { + return nil + } + + return func(d *dt.ResourceDiff) bool { + pp := plans[d.GetDiffKey()] + return pp != nil && pp.Error == "" && !pp.Approximate && pp.Action == plan.ActionReplace + } +} + +// annotateDiffs returns a copy of diffs with each diff annotated with its +// provider plan, and whether an equal diff became a change. Unannotated +// diffs are shared, not copied. A nil map stays nil. +func annotateDiffs(diffs map[string]*dt.ResourceDiff, plans map[string]*plan.ResourcePlan) (map[string]*dt.ResourceDiff, bool) { + if diffs == nil { + return nil, false + } + + out := maps.Clone(diffs) + drifted := false + for k, d := range diffs { + a := annotateDiff(d, plans[d.GetDiffKey()]) + out[k] = a + drifted = drifted || (d.DiffType == dt.DiffTypeEqual && a.DiffType != dt.DiffTypeEqual) + } + + return out, drifted +} + +// annotateDiff returns d annotated with its provider plan pp, or d itself +// when there is nothing to annotate. Only a provider's own plan annotates: an +// approximate or failed one carries no replacement or computed values. +// +// In an added or modified diff, a field the provider computes prints as +// "(known after apply)", a value the provider marks sensitive as "(sensitive +// value)", and the "+" line of a field that forces replacement ends +// " # forces replacement". Other provider changes, such as defaults, show +// only in -o plan. +func annotateDiff(d *dt.ResourceDiff, pp *plan.ResourcePlan) *dt.ResourceDiff { + if pp == nil || pp.Error != "" || pp.Approximate || len(pp.Changes) == 0 { + return d + } + + switch d.DiffType { + case dt.DiffTypeAdded, dt.DiffTypeModified: + case dt.DiffTypeEqual: + return driftDiff(d, pp) + case dt.DiffTypeRemoved: + return d + } + + lines := flatten(d.LineDiffs) + walk(lines, opDelete, func(op diffmatchpatch.Operation) bool { return op != opInsert }) + walk(lines, opInsert, desired) + + changes := sortedChanges(pp.Changes) + changed := false + for _, c := range changes { + var ok bool + switch { + case c.Planned != nil && c.Planned.Unknown: + lines, ok = markUnknown(lines, plan.PathSegments(c.Path)) + case (c.Planned != nil && c.Planned.Sensitive) || (c.Actual != nil && c.Actual.Sensitive): + lines, ok = markSensitive(lines, plan.PathSegments(c.Path)) + } + changed = changed || ok + } + for _, c := range changes { + if c.RequiresReplace { + changed = markReplace(lines, plan.PathSegments(c.Path), d.DiffType == dt.DiffTypeAdded) || changed + } + } + + if !changed { + return d + } + + out := *d + out.LineDiffs = chunk(lines) + + return &out +} + +// driftDiff renders an equal diff the provider would change, which is drift +// between the cluster and the external resource, as a modified block: a +// "- path: actual" and "+ path: planned" pair per change, with computed +// values and replacement marked as elsewhere. It returns d when no change has +// a value to print. +// +// ponytail: D-17 does not cover drift, and this block is pending the user's +// confirmation; drop or change it here alone. +func driftDiff(d *dt.ResourceDiff, pp *plan.ResourcePlan) *dt.ResourceDiff { + if !isChange(pp.Action) { + return d + } + + var lines []line + for _, c := range sortedChanges(pp.Changes) { + note := "" + if c.RequiresReplace { + note = forcesReplacement + } + if c.Actual != nil { + text := c.Path + ": " + plan.FormatValue(c.Actual) + if c.Planned == nil { + text += note + } + lines = append(lines, line{op: opDelete, text: text}) + } + if c.Planned != nil { + lines = append(lines, line{op: opInsert, text: c.Path + ": " + plan.FormatValue(c.Planned) + note}) + } + } + if len(lines) == 0 { + return d + } + + out := *d + out.DiffType = dt.DiffTypeModified + out.LineDiffs = chunk(lines) + + return &out +} + +// A line is one line of a diff body, with the field it is on. +type line struct { + op diffmatchpatch.Operation + text string + // segs is the field path of the line, as plan.PathSegments splits it, + // on the side the line belongs to: the desired side for an equal line. + segs []string + // value is the offset in text of the field's scalar value, or -1 when + // the line has none: a parent, or a continuation. A block scalar's value + // starts at its "|" or ">". + value int + // parent means the line opens a map or list; children starts its + // children's column. + parent bool + children int + // item is the path of the list item a "- key: value" line opens, and + // itemCol its keys' column. + item []string + itemCol int + // cont means the line continues the scalar of the line before it. + cont bool +} + +func desired(op diffmatchpatch.Operation) bool { return op != opDelete } + +// flatten splits diffs into lines, as the renderer's formatter does. +func flatten(diffs []diffmatchpatch.Diff) []line { + var lines []line + for _, d := range diffs { + texts := strings.Split(d.Text, "\n") + if strings.HasSuffix(d.Text, "\n") { + texts = texts[:len(texts)-1] + } + if len(texts) == 0 { + texts = []string{""} + } + for _, t := range texts { + lines = append(lines, line{op: d.Type, text: t, value: -1}) + } + } + + return lines +} + +// chunk joins consecutive lines of one op back into diffs. +func chunk(lines []line) []diffmatchpatch.Diff { + var out []diffmatchpatch.Diff + for _, l := range lines { + if n := len(out); n > 0 && out[n-1].Type == l.op { + out[n-1].Text += l.text + "\n" + continue + } + out = append(out, diffmatchpatch.Diff{Type: l.op, Text: l.text + "\n"}) + } + + return out +} + +// A frame is an open map key or list item while walking. +type frame struct { + indent int + segs []string + items int + item bool +} + +// walker sets the field path of the lines on one side of a diff. +type walker struct { + stack []frame + // scalar is the indent the open scalar's continuation lines are deeper + // than, and scalarSegs its path; nil when no scalar is open. + scalar int + scalarSegs []string +} + +// walk sets the field path of the lines on one side of a diff: the lines +// side reports true for, of which only those with op own (and the equal +// lines, for the desired side) are written. The body is YAML as +// sigs.k8s.io/yaml writes it: two-space indents, with a list's items at its +// key's indent. +// +// ponytail: an indent-to-key stack places map keys and positional list items +// only. A list of maps keyed by a field, or a value inside a block scalar, +// is not placed, and gets no inline annotation; the header, the summary and +// -o plan still carry it. Upgrade path: map by the YAML AST (yaml.v3 node +// positions) instead. +func walk(lines []line, own diffmatchpatch.Operation, side func(diffmatchpatch.Operation) bool) { + w := &walker{stack: []frame{{indent: -1}}} + for i := range lines { + l := &lines[i] + if !side(l.op) { + continue + } + var ignore line + if l.op != own && (own != opInsert || l.op != opEqual) { + // Walked for its structure only. + ignore = *l + l = &ignore + } + w.line(l) + } +} + +// line places one line. +func (w *walker) line(l *line) { + indent := len(l.text) - len(strings.TrimLeft(l.text, " ")) + blank := strings.TrimSpace(l.text) == "" + if w.scalarSegs != nil && (blank || indent > w.scalar) { + l.segs, l.cont = w.scalarSegs, true + return + } + w.scalarSegs = nil + if blank { + return + } + + col, rest := indent, l.text[indent:] + for rest == "-" || strings.HasPrefix(rest, "- ") { + segs := w.item(col) + if rest == "-" { + l.segs, l.parent, l.children = segs, true, col+2 + return + } + rest, col = rest[2:], col+2 + if rest == "-" || strings.HasPrefix(rest, "- ") { + // The item opens a list of its own. + continue + } + if _, _, ok := splitKey(rest); !ok { + // A scalar item. + l.segs, l.value = segs, col + w.scalar, w.scalarSegs = col-2, segs + return + } + l.item, l.itemCol = segs, col + } + + key, off, ok := splitKey(rest) + if !ok { + // Not a field: leave it unplaced. + return + } + segs := w.key(col, key) + l.segs = segs + if off < 0 { + l.parent, l.children = true, col+2 + return + } + // A scalar, which may continue on deeper lines: folded, or a block. + l.value = col + off + w.scalar, w.scalarSegs = col, segs +} + +// item opens the next list item at col, returning its path. +func (w *walker) item(col int) []string { + for top := w.stack[len(w.stack)-1]; top.indent > col || (top.item && top.indent == col); top = w.stack[len(w.stack)-1] { + w.stack = w.stack[:len(w.stack)-1] + } + p := &w.stack[len(w.stack)-1] + segs := withIndex(p.segs, p.items) + p.items++ + w.stack = append(w.stack, frame{indent: col, segs: segs, item: true}) + + return segs +} + +// key opens the map key at col, returning its path. +func (w *walker) key(col int, key string) []string { + for w.stack[len(w.stack)-1].indent >= col { + w.stack = w.stack[:len(w.stack)-1] + } + segs := append(slices.Clone(w.stack[len(w.stack)-1].segs), key) + w.stack = append(w.stack, frame{indent: col, segs: segs}) + + return segs +} + +// splitKey splits a "key: value" or "key:" line into its unquoted key and +// the offset of its value, -1 for none. +func splitKey(rest string) (string, int, bool) { + var key, after string + switch { + case rest == "": + return "", 0, false + case rest[0] == '"': + q, err := strconv.QuotedPrefix(rest) + if err != nil { + return "", 0, false + } + key, _ = strconv.Unquote(q) + after = rest[len(q):] + case rest[0] == '\'': + end := -1 + for i := 1; i < len(rest) && end < 0; i++ { + switch { + case rest[i] != '\'': + case strings.HasPrefix(rest[i:], "''"): + i++ + default: + end = i + } + } + if end < 0 { + return "", 0, false + } + key, after = strings.ReplaceAll(rest[1:end], "''", "'"), rest[end+1:] + default: + i := strings.Index(rest, ": ") + if i < 0 { + if !strings.HasSuffix(rest, ":") { + return "", 0, false + } + i = len(rest) - 1 + } + key, after = rest[:i], rest[i:] + } + + switch { + case after == ":": + return key, -1, true + case !strings.HasPrefix(after, ": "): + return "", 0, false + } + return key, len(rest) - len(after) + 2, true +} + +func withIndex(segs []string, n int) []string { + idx := "[" + strconv.Itoa(n) + "]" + if len(segs) == 0 { + return []string{idx} + } + out := slices.Clone(segs) + out[len(out)-1] += idx + + return out +} + +// find returns the index of the first line of an op in ops on the field +// segs, skipping continuations, or -1. +func find(lines []line, segs []string, ops ...diffmatchpatch.Operation) int { + return slices.IndexFunc(lines, func(l line) bool { + return !l.cont && slices.Contains(ops, l.op) && slices.Equal(l.segs, segs) + }) +} + +// markUnknown marks the field segs as known only after apply: its "+" line's +// value is replaced; its equal line is split into the old value removed and +// the unknown one added; a "-" line only is followed by the unknown one +// added; and when the field is absent, a line is added after the deepest +// ancestor present, naming the missing levels dotted. It returns the new +// lines, and whether it placed the field. +func markUnknown(lines []line, segs []string) ([]line, bool) { + if len(segs) == 0 { + return lines, false + } + + i := find(lines, segs, opInsert, opEqual) + if i < 0 { + i = find(lines, segs, opDelete) + } + if i >= 0 { + if lines[i].value < 0 { + return lines, false + } + return setValue(lines, i, knownAfterApply, true), true + } + + k, anchor, indent, ok := ancestor(lines, segs) + if !ok { + return lines, false + } + text := strings.Repeat(" ", indent) + strings.Join(segs[k:], ".") + ": " + knownAfterApply + added := line{op: opInsert, text: text, segs: segs, value: len(text) - len(knownAfterApply)} + + return slices.Insert(lines, anchor+1, added), true +} + +// markSensitive replaces every value of the field segs with "(sensitive +// value)", on each side. It returns the new lines, and whether it replaced +// any. +func markSensitive(lines []line, segs []string) ([]line, bool) { + sensitive := plan.FormatValue(&plan.Value{Sensitive: true}) + changed := false + for i := 0; i < len(lines); i++ { + if l := lines[i]; !l.cont && l.value >= 0 && slices.Equal(l.segs, segs) { + lines = setValue(lines, i, sensitive, false) + changed = true + } + } + + return lines, changed +} + +// setValue sets the scalar value of line i to v, dropping its continuation +// lines. With split, the old value stays as removed lines and v is added +// after them, unless line i is already added. Without, the value is set on +// each side the value spans: a changed value stays a change. +func setValue(lines []line, i int, v string, split bool) []line { + l := lines[i] + end := i + 1 + for end < len(lines) && lines[end].cont && slices.Equal(lines[end].segs, l.segs) { + end++ + } + span := lines[i:end] + set := l + set.text = l.text[:l.value] + v + with := func(op diffmatchpatch.Operation) line { s := set; s.op = op; return s } + + var out []line + switch { + case split && l.op != opInsert: + // The old value is the header and its continuations on the current + // side, all removed. + for _, c := range span { + if c.op != opInsert { + c.op = opDelete + out = append(out, c) + } + } + out = append(out, with(opInsert)) + case slices.ContainsFunc(span, func(c line) bool { return c.op != l.op }): + out = []line{with(opDelete), with(opInsert)} + default: + out = []line{set} + } + + return slices.Replace(lines, i, end, out...) +} + +// ancestor finds the deepest ancestor of segs on the desired side: the +// number of its path segments, the index of the last line of its subtree, +// and its children's column. The document itself is the ancestor of last +// resort. ok is false when the deepest ancestor holds a scalar, which can +// have no children. +func ancestor(lines []line, segs []string) (int, int, int, bool) { + for k := len(segs) - 1; k >= 1; k-- { + at := segs[:k] + i := slices.IndexFunc(lines, func(l line) bool { + return desired(l.op) && !l.cont && (slices.Equal(l.segs, at) || slices.Equal(l.item, at)) + }) + if i < 0 { + continue + } + children := lines[i].itemCol + if !slices.Equal(lines[i].item, at) { + if !lines[i].parent { + return 0, 0, 0, false + } + children = lines[i].children + } + last := i + for j := i + 1; j < len(lines); j++ { + if desired(lines[j].op) && within(lines[j].segs, at) { + last = j + } + } + + return k, last, children, true + } + + last := -1 + for j, l := range lines { + if desired(l.op) { + last = j + } + } + + return 0, last, 0, last >= 0 +} + +// markReplace ends the "+" line of the field segs with the replacement note. +// When the field has no "+" line of its own, the first "+" line in its +// subtree is marked; failing that, in an added diff, the "+" line of its +// deepest ancestor. It reports whether it marked a line. +func markReplace(lines []line, segs []string, added bool) bool { + if len(segs) == 0 { + return false + } + i := find(lines, segs, opInsert) + if i < 0 { + i = slices.IndexFunc(lines, func(l line) bool { return l.op == opInsert && !l.cont && within(l.segs, segs) }) + } + for k := len(segs) - 1; i < 0 && added && k >= 1; k-- { + at := segs[:k] + i = slices.IndexFunc(lines, func(l line) bool { + return l.op == opInsert && !l.cont && (slices.Equal(l.segs, at) || slices.Equal(l.item, at)) + }) + } + if i < 0 || strings.HasSuffix(lines[i].text, forcesReplacement) { + return false + } + lines[i].text += forcesReplacement + + return true +} + +// within reports whether the path segs is in the subtree at the path at, +// which includes a list's items: spec.ports[0] is within spec.ports. +func within(segs, at []string) bool { + n := len(at) + if len(segs) < n || !slices.Equal(segs[:n-1], at[:n-1]) { + return false + } + + return segs[n-1] == at[n-1] || strings.HasPrefix(segs[n-1], at[n-1]+"[") +} + +func sortedChanges(changes []plan.FieldChange) []plan.FieldChange { + return slices.SortedStableFunc(slices.Values(changes), func(a, b plan.FieldChange) int { + return strings.Compare(a.Path, b.Path) + }) +} diff --git a/internal/simulate/annotate_test.go b/internal/simulate/annotate_test.go new file mode 100644 index 00000000..84b23923 --- /dev/null +++ b/internal/simulate/annotate_test.go @@ -0,0 +1,440 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "bytes" + "maps" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/sergi/go-diff/diffmatchpatch" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + sigsyaml "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/compare" + "github.com/crossplane/cli/v2/internal/diff/renderer" + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +var dbGVK = schema.GroupVersionKind{Group: "rds.aws.upbound.io", Version: "v1beta1", Kind: "Instance"} + +// lineDiff builds a resource diff of dbGVK as the engine does: the YAML of +// each side, diffed by line. A nil side is absent. +func lineDiff(t *testing.T, name string, typ dt.DiffType, current, desired map[string]any) *dt.ResourceDiff { + t.Helper() + + text := func(spec map[string]any) (*unstructured.Unstructured, string) { + if spec == nil { + return nil, "" + } + u := obj(dbGVK, name, map[string]any{"spec": spec}) + b, err := sigsyaml.Marshal(u.Object) + if err != nil { + t.Fatalf("Marshal(): %v", err) + } + return u, string(b) + } + cur, curText := text(current) + des, desText := text(desired) + + d := &dt.ResourceDiff{ + Gvk: dbGVK, ResourceName: name, DiffType: typ, + Current: dt.ResourceViews{Raw: cur}, Desired: dt.ResourceViews{Raw: des}, + } + if typ != dt.DiffTypeEqual { + d.LineDiffs = compare.GetLineDiff(curText, desText) + } + + return d +} + +// render renders r as -o diff does with plans: annotated, and with the +// Replaced hook set. +func render(t *testing.T, r *dt.XRReport, plans map[string]*plan.ResourcePlan, opts renderer.DiffOptions) string { + t.Helper() + + var stdout bytes.Buffer + opts.Stdout, opts.Stderr = &stdout, &bytes.Buffer{} + opts.Replaced = ReplacedBy(plans) + if err := renderer.NewDiffRenderer(logging.NewNopLogger(), opts).RenderDiffs(AnnotateXRReport(r, plans).Groups, nil, nil); err != nil { + t.Fatalf("RenderDiffs(): %v", err) + } + + return stdout.String() +} + +func noColor() renderer.DiffOptions { + o := renderer.DefaultDiffOptions() + o.UseColors = false + return o +} + +func str(s string) *plan.Value { return &plan.Value{Value: s} } +func unknown() *plan.Value { return &plan.Value{Unknown: true} } +func sensitive() *plan.Value { return &plan.Value{Sensitive: true} } +func report(ds ...*dt.ResourceDiff) *dt.XRReport { + return &dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(ds...)}}} +} + +func planFor(d *dt.ResourceDiff, a plan.Action, changes ...plan.FieldChange) map[string]*plan.ResourcePlan { + return map[string]*plan.ResourcePlan{d.GetDiffKey(): { + APIVersion: d.Gvk.GroupVersion().String(), Kind: d.Gvk.Kind, Name: d.ResourceName, Action: a, Changes: changes, + }} +} + +func TestAnnotateXRReport(t *testing.T) { + fp := func(m map[string]any) map[string]any { return map[string]any{"forProvider": m} } + + modified := lineDiff(t, "db", dt.DiffTypeModified, + fp(map[string]any{"region": "us-east-1", "engineVersion": "5.7", "arn": "arn:old"}), + fp(map[string]any{"region": "us-west-2", "engineVersion": "5.7", "arn": "arn:old"})) + added := lineDiff(t, "db", dt.DiffTypeAdded, nil, + fp(map[string]any{"region": "us-west-2", "arn": "", "ports": []any{map[string]any{"name": "a", "port": int64(1)}}})) + secret := lineDiff(t, "db", dt.DiffTypeModified, + fp(map[string]any{"password": "hunter2", "size": int64(1)}), + fp(map[string]any{"password": "hunter3", "size": int64(2)})) + equal := lineDiff(t, "db", dt.DiffTypeEqual, fp(map[string]any{"region": "us-east-1"}), fp(map[string]any{"region": "us-east-1"})) + policy := lineDiff(t, "db", dt.DiffTypeModified, + fp(map[string]any{"policy": "a\nb\n", "tags": []any{map[string]any{"key": "k", "value": "1"}}}), + fp(map[string]any{"policy": "a\nc\n", "tags": []any{map[string]any{"key": "k", "value": "2"}}})) + + cases := map[string]struct { + reason string + in *dt.XRReport + plans map[string]*plan.ResourcePlan + opts renderer.DiffOptions + want string + }{ + "ModifiedReplaceAndUnknown": { + reason: "A forcing field's + line is marked, the header and summary say replace, an equal computed field splits, and an absent one is added under its deepest ancestor.", + in: report(modified), + plans: planFor(modified, plan.ActionReplace, + plan.FieldChange{Path: "spec.forProvider.region", Actual: str("us-east-1"), Planned: str("us-west-2"), RequiresReplace: true}, + plan.FieldChange{Path: "spec.forProvider.arn", Actual: str("arn:old"), Planned: unknown()}, + plan.FieldChange{Path: "spec.forProvider.endpoint.address", Planned: unknown()}, + plan.FieldChange{Path: "status.atProvider.id", Planned: unknown()}, + ), + opts: noColor(), + want: `~~~ Instance/db (requires replacement) + apiVersion: rds.aws.upbound.io/v1beta1 + kind: Instance + metadata: + name: db + spec: + forProvider: +- arn: arn:old ++ arn: (known after apply) + engineVersion: "5.7" +- region: us-east-1 ++ region: us-west-2 # forces replacement ++ endpoint.address: (known after apply) ++ status.atProvider.id: (known after apply) + +--- + +Summary: 1 modified (1 requires replacement) +`, + }, + "AddedCreate": { + reason: "On a create, a + value the provider computes is rewritten, a field in a list item is added under it, and an absent forcing field marks its deepest ancestor's line.", + in: report(added), + plans: planFor(added, plan.ActionCreate, + plan.FieldChange{Path: "spec.forProvider.arn", Planned: unknown()}, + plan.FieldChange{Path: "spec.forProvider.ports[0].id", Planned: unknown()}, + plan.FieldChange{Path: "spec.forProvider.subnetIds", Planned: str("x"), RequiresReplace: true}, + ), + opts: noColor(), + want: `+++ Instance/db ++ apiVersion: rds.aws.upbound.io/v1beta1 ++ kind: Instance ++ metadata: ++ name: db ++ spec: ++ forProvider: # forces replacement ++ arn: (known after apply) ++ ports: ++ - name: a ++ port: 1 ++ id: (known after apply) ++ region: us-west-2 + +--- + +Summary: 1 added +`, + }, + "Compact": { + reason: "Compact mode sees the annotated lines as ordinary changes, with its context around them.", + in: report(modified), + plans: planFor(modified, plan.ActionUpdate, + plan.FieldChange{Path: "spec.forProvider.arn", Actual: str("arn:old"), Planned: unknown()}, + ), + opts: func() renderer.DiffOptions { o := noColor(); o.Compact, o.ContextLines = true, 1; return o }(), + want: `~~~ Instance/db + forProvider: +- arn: arn:old ++ arn: (known after apply) + engineVersion: "5.7" +- region: us-east-1 ++ region: us-west-2 + +--- + +Summary: 1 modified +`, + }, + "Colors": { + reason: "With colors on, annotated lines are colored as the side they are on.", + in: report(modified), + plans: planFor(modified, plan.ActionReplace, + plan.FieldChange{Path: "spec.forProvider.region", Actual: str("us-east-1"), Planned: str("us-west-2"), RequiresReplace: true}, + ), + opts: func() renderer.DiffOptions { + o := renderer.DefaultDiffOptions() + o.Compact, o.ContextLines = true, 0 + return o + }(), + want: "~~~ Instance/db (requires replacement)\n" + + dt.ColorRed + "- region: us-east-1" + dt.ColorReset + "\n" + + dt.ColorGreen + "+ region: us-west-2 # forces replacement" + dt.ColorReset + "\n" + + "\n---\n\nSummary: 1 modified (1 requires replacement)\n", + }, + "Sensitive": { + reason: "A value the provider marks sensitive is never printed, on either side.", + in: report(secret), + plans: planFor(secret, plan.ActionUpdate, + plan.FieldChange{Path: "spec.forProvider.password", Actual: sensitive(), Planned: sensitive()}, + ), + opts: func() renderer.DiffOptions { o := noColor(); o.Compact, o.ContextLines = true, 0; return o }(), + want: `~~~ Instance/db +- password: (sensitive value) +- size: 1 ++ password: (sensitive value) ++ size: 2 + +--- + +Summary: 1 modified +`, + }, + "Unplaceable": { + reason: "A path the walker cannot place (a list keyed by a field, a field under a scalar) gets no inline annotation; the header and summary still say replace.", + in: report(policy), + plans: planFor(policy, plan.ActionReplace, + plan.FieldChange{Path: `spec.forProvider.tags[key="k"].value`, Actual: str("1"), Planned: str("2"), RequiresReplace: true}, + plan.FieldChange{Path: "spec.forProvider.policy.version", Planned: unknown()}, + ), + opts: func() renderer.DiffOptions { o := noColor(); o.Compact, o.ContextLines = true, 0; return o }(), + want: `~~~ Instance/db (requires replacement) +- b ++ c +... +- value: "1" ++ value: "2" + +--- + +Summary: 1 modified (1 requires replacement) +`, + }, + "Drift": { + reason: "An equal diff the provider would change renders as a modified block of its changes, marked as elsewhere.", + in: report(equal), + plans: planFor(equal, plan.ActionReplace, + plan.FieldChange{Path: "spec.forProvider.region", Actual: str("us-east-1"), Planned: str("us-west-2"), RequiresReplace: true}, + plan.FieldChange{Path: "spec.forProvider.arn", Actual: str("arn:old"), Planned: unknown()}, + plan.FieldChange{Path: "spec.forProvider.password", Actual: sensitive(), Planned: sensitive()}, + plan.FieldChange{Path: "spec.forProvider.zone", Actual: str("a")}, + ), + opts: noColor(), + want: `~~~ Instance/db (requires replacement) +- spec.forProvider.arn: "arn:old" ++ spec.forProvider.arn: (known after apply) +- spec.forProvider.password: (sensitive value) ++ spec.forProvider.password: (sensitive value) +- spec.forProvider.region: "us-east-1" ++ spec.forProvider.region: "us-west-2" # forces replacement +- spec.forProvider.zone: "a" + +--- + +Summary: 1 modified (1 requires replacement) +`, + }, + "DriftNoOp": { + reason: "An equal diff the provider would not change stays equal and renders nothing.", + in: report(equal), + plans: planFor(equal, plan.ActionNoOp, plan.FieldChange{Path: "spec.forProvider.region", Actual: str("a"), Planned: str("a")}), + opts: noColor(), + want: "", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := render(t, tc.in, tc.plans, tc.opts) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("\n%s\nrender(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +// TestAnnotateIdentity pins that no provider plan, or one that cannot +// annotate, renders -o diff exactly as it renders without annotation. +func TestAnnotateIdentity(t *testing.T) { + fp := func(m map[string]any) map[string]any { return map[string]any{"forProvider": m} } + d := lineDiff(t, "db", dt.DiffTypeModified, fp(map[string]any{"region": "a"}), fp(map[string]any{"region": "b"})) + r := report(d) + + var want bytes.Buffer + opts := noColor() + opts.Stdout, opts.Stderr = &want, &bytes.Buffer{} + if err := renderer.NewDiffRenderer(logging.NewNopLogger(), opts).RenderDiffs(r.Groups, nil, nil); err != nil { + t.Fatalf("RenderDiffs(): %v", err) + } + + region := plan.FieldChange{Path: "spec.forProvider.region", Actual: str("a"), Planned: unknown(), RequiresReplace: true} + cases := map[string]map[string]*plan.ResourcePlan{ + "NilPlans": nil, + "EmptyPlans": {}, + "OtherKey": {"other": {Action: plan.ActionReplace, Changes: []plan.FieldChange{region}}}, + "Approximate": {d.GetDiffKey(): {Action: plan.ActionUpdate, Approximate: true, Changes: []plan.FieldChange{region}}}, + "Error": {d.GetDiffKey(): {Error: "boom", Changes: []plan.FieldChange{region}}}, + } + for name, plans := range cases { + t.Run(name, func(t *testing.T) { + if diff := cmp.Diff(want.String(), render(t, r, plans, noColor())); diff != "" { + t.Errorf("render(...): -want, +got:\n%s", diff) + } + }) + } + + if ReplacedBy(nil) != nil || ReplacedBy(map[string]*plan.ResourcePlan{}) != nil { + t.Error("ReplacedBy(empty): want nil, so -o diff takes its unannotated path") + } +} + +// TestAnnotateDoesNotMutate pins that annotation works on copies only. +func TestAnnotateDoesNotMutate(t *testing.T) { + fp := func(m map[string]any) map[string]any { return map[string]any{"forProvider": m} } + mod := lineDiff(t, "db", dt.DiffTypeModified, fp(map[string]any{"region": "a"}), fp(map[string]any{"region": "b"})) + eq := lineDiff(t, "eq", dt.DiffTypeEqual, fp(map[string]any{"region": "a"}), fp(map[string]any{"region": "a"})) + plans := planFor(mod, plan.ActionReplace, + plan.FieldChange{Path: "spec.forProvider.region", Actual: str("a"), Planned: str("b"), RequiresReplace: true}, + plan.FieldChange{Path: "spec.forProvider.id", Planned: unknown()}) + maps.Copy(plans, planFor(eq, plan.ActionUpdate, plan.FieldChange{Path: "spec.forProvider.region", Actual: str("a"), Planned: str("b")})) + + xr := report(mod, eq) + comp := &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{{ + Name: "net", + AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusUnchanged, Diffs: diffs(eq)}}, + }}} + wantXR, wantComp := cloneXR(xr), cloneComp(comp) + + gotXR, gotComp := AnnotateXRReport(xr, plans), AnnotateCompReport(comp, plans) + + if diff := cmp.Diff(wantXR, xr); diff != "" { + t.Errorf("AnnotateXRReport(...) mutated its input: -want, +got:\n%s", diff) + } + if diff := cmp.Diff(wantComp, comp); diff != "" { + t.Errorf("AnnotateCompReport(...) mutated its input: -want, +got:\n%s", diff) + } + + // The copies are annotated. + if got := gotXR.Groups[0].Diffs[eq.GetDiffKey()].DiffType; got != dt.DiffTypeModified { + t.Errorf("AnnotateXRReport(...): drifted diff type %q, want %q", got, dt.DiffTypeModified) + } + c := gotComp.Compositions[0] + if c.ImpactAnalysis[0].Status != dt.XRStatusChanged || c.AffectedResources.Unchanged != 0 || c.AffectedResources.WithChanges != 1 { + t.Errorf("AnnotateCompReport(...): drifted composite %q with %+v, want changed and counted", c.ImpactAnalysis[0].Status, c.AffectedResources) + } +} + +// TestAnnotateCompReport pins that a composite the provider finds drift +// under renders as changed, with its drift in the impact analysis. +func TestAnnotateCompReport(t *testing.T) { + fp := func(m map[string]any) map[string]any { return map[string]any{"forProvider": m} } + eq := lineDiff(t, "db", dt.DiffTypeEqual, fp(map[string]any{"region": "a"}), fp(map[string]any{"region": "a"})) + plans := planFor(eq, plan.ActionUpdate, plan.FieldChange{Path: "spec.forProvider.region", Actual: str("a"), Planned: str("b")}) + comp := &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{{ + Name: "net", + AffectedResources: dt.AffectedResourcesSummary{Total: 1, Unchanged: 1}, + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusUnchanged, Diffs: diffs(eq)}}, + }}} + + var stdout bytes.Buffer + opts := noColor() + opts.Stdout, opts.Stderr, opts.Replaced = &stdout, &bytes.Buffer{}, ReplacedBy(plans) + log := logging.NewNopLogger() + r := renderer.NewDefaultCompDiffRenderer(log, renderer.NewDiffRenderer(log, opts), opts) + if err := r.RenderCompDiff(AnnotateCompReport(comp, plans)); err != nil { + t.Fatalf("RenderCompDiff(): %v", err) + } + + for _, want := range []string{ + "XNetwork/prod (cluster-scoped)", + "1 resource with changes", + "~~~ Instance/db\n- spec.forProvider.region: \"a\"\n+ spec.forProvider.region: \"b\"\n", + } { + if !strings.Contains(stdout.String(), want) { + t.Errorf("RenderCompDiff(...): want %q in:\n%s", want, stdout.String()) + } + } +} + +func cloneXR(r *dt.XRReport) *dt.XRReport { + out := *r + out.Groups = nil + for _, g := range r.Groups { + g.Diffs = cloneDiffs(g.Diffs) + out.Groups = append(out.Groups, g) + } + return &out +} + +func cloneComp(o *dt.CompDiffOutput) *dt.CompDiffOutput { + out := *o + out.Compositions = nil + for _, c := range o.Compositions { + impacts := make([]dt.XRImpact, 0, len(c.ImpactAnalysis)) + for _, i := range c.ImpactAnalysis { + i.Diffs = cloneDiffs(i.Diffs) + impacts = append(impacts, i) + } + c.ImpactAnalysis = impacts + out.Compositions = append(out.Compositions, c) + } + return &out +} + +func cloneDiffs(m map[string]*dt.ResourceDiff) map[string]*dt.ResourceDiff { + out := map[string]*dt.ResourceDiff{} + for k, d := range m { + c := *d + c.LineDiffs = append([]diffmatchpatch.Diff(nil), d.LineDiffs...) + out[k] = &c + } + return out +} From 87f139baeba4ce06806d868aa93186d4eb9867d0 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 05:20:48 -0400 Subject: [PATCH 060/126] feat(project): detect changed embedded functions in project simulate Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 56 +++++++++--- .../project/simulate_changed_test.go | 85 +++++++++++++++++++ internal/project/functions_load.go | 23 +++-- internal/project/sort.go | 33 +++++++ 4 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 cmd/crossplane/project/simulate_changed_test.go diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 6bc4d411..d352910a 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -23,12 +23,15 @@ import ( "io" "os" "path/filepath" + "slices" "time" "github.com/alecthomas/kong" "github.com/charmbracelet/x/term" + "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/afero" corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" k8stypes "k8s.io/apimachinery/pkg/types" @@ -47,6 +50,7 @@ import ( "github.com/crossplane/cli/v2/internal/dependency" "github.com/crossplane/cli/v2/internal/diff/client" xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" + k8client "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/result" @@ -226,9 +230,10 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin } var fns []pkgv1.Function + var digests map[string]project.EmbeddedDigest comps := in.resources if in.mode == projectMode { - comps, fns, err = c.buildProject(sigCtx, logger, sp, cliCfg, in.projectFile) + comps, fns, digests, err = c.buildProject(sigCtx, logger, sp, cliCfg, in.projectFile) if err != nil { return err } @@ -239,7 +244,12 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin return err } - opts := c.processorOptions(k, logger, fns) + changed, err := changedFunctions(sigCtx, clients.K8sClients.Resource, digests) + if err != nil { + return err + } + + opts := append(c.processorOptions(k, logger, fns), dp.WithChangedFunctions(changed...)) xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) // ponytail: the timeout bounds the whole simulation because the diff @@ -288,32 +298,58 @@ func (c *simulateCmd) restConfig() (*rest.Config, error) { // buildProject returns the project's compositions and its locally built // functions. -func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config, projectFile string) ([]*un.Unstructured, []pkgv1.Function, error) { +func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config, projectFile string) ([]*un.Unstructured, []pkgv1.Function, map[string]project.EmbeddedDigest, error) { projFilePath, err := filepath.Abs(projectFile) if err != nil { - return nil, nil, err + return nil, nil, nil, err } projFS := afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)) projFileName := filepath.Base(projFilePath) proj, err := projectfile.Parse(projFS, projFileName) if err != nil { - return nil, nil, errors.Wrapf(err, "failed to parse project file %q", projectFile) + return nil, nil, nil, errors.Wrapf(err, "failed to parse project file %q", projectFile) } comps, err := project.LoadCompositions(proj, projFS) if err != nil { - return nil, nil, errors.Wrap(err, "cannot load compositions from project") + return nil, nil, nil, errors.Wrap(err, "cannot load compositions from project") } if len(comps) == 0 { - return nil, nil, errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) + return nil, nil, nil, errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) } - fns, err := project.LoadFunctions(ctx, logger, sp, cliCfg, projFS, projFilePath, projFileName, c.MaxConcurrency, c.newClientAndResolver) + fns, digests, err := project.LoadFunctionsWithDigests(ctx, logger, sp, cliCfg, projFS, projFilePath, projFileName, c.MaxConcurrency, c.newClientAndResolver) if err != nil { - return nil, nil, err + return nil, nil, nil, err + } + return comps, fns, digests, nil +} + +// changedFunctions returns the cluster names of the embedded functions whose +// local build differs from the cluster's Function of the same name. A +// Function is unchanged only when it exists, its package is from the same +// repository, and it pins the local index digest; anything else, including a +// tag-only package, counts as changed. +func changedFunctions(ctx context.Context, rc k8client.ResourceClient, digests map[string]project.EmbeddedDigest) ([]string, error) { + var changed []string + for fnName, d := range digests { + fn, err := rc.GetResource(ctx, pkgv1.FunctionGroupVersionKind, "", fnName) + if kerrors.IsNotFound(err) { + changed = append(changed, fnName) + continue + } + if err != nil { + return nil, errors.Wrapf(err, "cannot get Function %q to check whether embedded function changed", fnName) + } + pkg, _, _ := un.NestedString(fn.Object, "spec", "package") + ref, err := name.NewDigest(pkg) + if err != nil || ref.Context().Name() != d.Repository.Name() || ref.DigestStr() != d.Digest.String() { + changed = append(changed, fnName) + } } - return comps, fns, nil + slices.Sort(changed) + return changed, nil } // diffCompositions returns the diff simulate runs for Compositions: their diff --git a/cmd/crossplane/project/simulate_changed_test.go b/cmd/crossplane/project/simulate_changed_test.go new file mode 100644 index 00000000..02e647e1 --- /dev/null +++ b/cmd/crossplane/project/simulate_changed_test.go @@ -0,0 +1,85 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/project" +) + +func TestChangedFunctions(t *testing.T) { + local := v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("a", 64)} + other := v1.Hash{Algorithm: "sha256", Hex: strings.Repeat("b", 64)} + repo, err := name.NewRepository("xpkg.example.com/acme/proj_fn") + if err != nil { + t.Fatal(err) + } + const fnName = "acme-proj-fn" + digests := map[string]project.EmbeddedDigest{fnName: {Repository: repo, Digest: local}} + + fnWithPackage := func(pkg string) func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) { + return func(_ context.Context, gvk schema.GroupVersionKind, _, n string) (*un.Unstructured, error) { + if gvk != pkgv1.FunctionGroupVersionKind || n != fnName { + t.Errorf("GetResource(%v, %q): want Function %q", gvk, n, fnName) + } + return &un.Unstructured{Object: map[string]any{"spec": map[string]any{"package": pkg}}}, nil + } + } + errBoom := errors.New("boom") + + cases := map[string]struct { + get func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) + want []string + wantErr bool + }{ + "EqualDigest": {get: fnWithPackage("xpkg.example.com/acme/proj_fn@" + local.String())}, + "DifferentDigest": {get: fnWithPackage("xpkg.example.com/acme/proj_fn@" + other.String()), want: []string{fnName}}, + "TagOnly": {get: fnWithPackage("xpkg.example.com/acme/proj_fn:v1"), want: []string{fnName}}, + "RepoMismatch": {get: fnWithPackage("xpkg.example.com/other/proj_fn@" + local.String()), want: []string{fnName}}, + "NotFound": {get: func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) { + return nil, kerrors.NewNotFound(schema.GroupResource{Group: "pkg.crossplane.io", Resource: "functions"}, fnName) + }, want: []string{fnName}}, + "GetError": {get: func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) { + return nil, errBoom + }, wantErr: true}, + } + for n, tc := range cases { + t.Run(n, func(t *testing.T) { + got, err := changedFunctions(t.Context(), &testutils.MockResourceClient{GetResourceFn: tc.get}, digests) + if (err != nil) != tc.wantErr || (tc.wantErr && !errors.Is(err, errBoom)) { + t.Fatalf("changedFunctions(...): err = %v, wantErr %t", err, tc.wantErr) + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("changedFunctions(...): -want, +got:\n%s", diff) + } + }) + } +} diff --git a/internal/project/functions_load.go b/internal/project/functions_load.go index 3d81e13c..6135af59 100644 --- a/internal/project/functions_load.go +++ b/internal/project/functions_load.go @@ -48,16 +48,23 @@ type NewXpkgClientFn func(opts ...clixpkg.ClientOption) (runtimexpkg.Client, *cl // embedded functions, loading their images into the docker daemon. It returns // a Function for each dependency and each embedded function. func LoadFunctions(ctx context.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config, projFS afero.Fs, projFilePath, projFileName string, maxConcurrency uint, newClient NewXpkgClientFn) ([]pkgv1.Function, error) { + fns, _, err := LoadFunctionsWithDigests(ctx, log, sp, cfg, projFS, projFilePath, projFileName, maxConcurrency, newClient) + return fns, err +} + +// LoadFunctionsWithDigests is LoadFunctions, and also returns the index +// digest of each embedded function, as EmbeddedFunctionDigests does. +func LoadFunctionsWithDigests(ctx context.Context, log logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config, projFS afero.Fs, projFilePath, projFileName string, maxConcurrency uint, newClient NewXpkgClientFn) ([]pkgv1.Function, map[string]EmbeddedDigest, error) { log.Debug("Loading functions from project", "project-file", projFilePath) proj, err := projectfile.Parse(projFS, projFileName) if err != nil { - return nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) + return nil, nil, errors.Wrapf(err, "cannot parse project file %q", projFilePath) } xpkgClient, resolver, err := newClient(clixpkg.WithImageConfigs(proj.Spec.ImageConfigs)) if err != nil { - return nil, err + return nil, nil, err } generators := generator.AllLanguages( @@ -73,12 +80,13 @@ func LoadFunctions(ctx context.Context, log logging.Logger, sp terminal.SpinnerP ) var fns []pkgv1.Function + var digests map[string]EmbeddedDigest if err := sp.WrapWithSuccessSpinner("Resolving function dependencies", func() error { var err error fns, err = LoadFunctionDependencies(depMgr, proj) return err }); err != nil { - return nil, errors.Wrap(err, "cannot load project functions") + return nil, nil, errors.Wrap(err, "cannot load project functions") } if err := sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { @@ -109,12 +117,17 @@ func LoadFunctions(ctx context.Context, log logging.Logger, sp terminal.SpinnerP return err } + digests, err = EmbeddedFunctionDigests(imgMap, proj.Spec.Repository) + if err != nil { + return err + } + embeddedFns, err := EmbeddedFunctionsToDaemon(ctx, imgMap) fns = append(fns, embeddedFns...) return err }); err != nil { - return nil, errors.Wrap(err, "cannot build embedded functions") + return nil, nil, errors.Wrap(err, "cannot build embedded functions") } - return fns, nil + return fns, digests, nil } diff --git a/internal/project/sort.go b/internal/project/sort.go index cd38d42a..d0f6e1a3 100644 --- a/internal/project/sort.go +++ b/internal/project/sort.go @@ -23,6 +23,7 @@ import ( v1 "github.com/google/go-containerregistry/pkg/v1" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" ) // SortImages analyzes an image map produced by the project builder and picks @@ -51,3 +52,35 @@ func SortImages(imgMap ImageTagMap, repo string) (cfgImage v1.Image, fnImages ma return cfgImage, fnImages, nil } + +// An EmbeddedDigest is the repository and index digest an embedded function +// is pushed with. +type EmbeddedDigest struct { + Repository name.Repository + Digest v1.Hash +} + +// EmbeddedFunctionDigests returns the repository and index digest of each +// embedded function in an image map produced by the project builder, keyed by +// the function's name in the cluster. The digest is computed as push does, so +// it matches the digest a Function installed from the pushed package pins. +func EmbeddedFunctionDigests(imgMap ImageTagMap, repo string) (map[string]EmbeddedDigest, error) { + _, fnImages, err := SortImages(imgMap, repo) + if err != nil { + return nil, err + } + + digests := make(map[string]EmbeddedDigest, len(fnImages)) + for r, imgs := range fnImages { + idx, _, err := BuildIndex(imgs...) + if err != nil { + return nil, errors.Wrapf(err, "failed to construct index for function %q", r) + } + dgst, err := idx.Digest() + if err != nil { + return nil, errors.Wrapf(err, "failed to get index digest for function %q", r) + } + digests[xpkg.ToDNSLabel(r.RepositoryStr())] = EmbeddedDigest{Repository: r, Digest: dgst} + } + return digests, nil +} From 36c2eb78186b6ba4d92cb624099e48508e8bd036 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 12:32:46 -0400 Subject: [PATCH 061/126] test(diff): keep engine tests free of CLI packages 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) Signed-off-by: Jonathan Ogilvie --- .../diff/diffprocessor/comp_processor_test.go | 41 +-- internal/diff/integration/integration_test.go | 249 +++++++++--------- internal/diff/testutils/mock_builder.go | 1 + .../simtest}/plan_assertions.go | 2 +- .../simtest}/structured_assertions.go | 5 +- .../simtest}/structured_assertions_test.go | 2 +- 6 files changed, 152 insertions(+), 148 deletions(-) rename internal/{diff/testutils => simulate/simtest}/plan_assertions.go (99%) rename internal/{diff/testutils => simulate/simtest}/structured_assertions.go (99%) rename internal/{diff/testutils => simulate/simtest}/structured_assertions_test.go (99%) diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 84212e98..ec791aea 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "strings" "testing" @@ -41,7 +42,6 @@ import ( dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/diff/types" - "github.com/crossplane/cli/v2/internal/simulate" ) func TestDefaultCompDiffProcessor_findResourcesUsingComposition(t *testing.T) { @@ -208,23 +208,30 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { // verifyOutput runs whether or not DiffComposition returns an error, since a failed composition // is still reported, and so still rendered. verifyOutput func(t *testing.T, output string) + // verifyReport checks the report itself, before rendering. + verifyReport func(t *testing.T, output *dt.CompDiffOutput) wantErr bool // wantErrContains, when set, must appear in the returned error. wantErrContains string }{ - // A composition that fails to process carries its error on its plan, and the top-level errors[] - // lists it too. + // A composition that fails to process carries its error on its own entry. (project simulate's + // mapper also lists it in the top-level errors; see simulate's report tests.) "FailedCompositionReportsStructuredErrors": { namespace: "default", compositions: []*un.Unstructured{malformedComp()}, - outputFormat: renderer.OutputFormatJSON, setupMocks: singleXRMocks, - verifyOutput: func(t *testing.T, output string) { + verifyReport: func(t *testing.T, output *dt.CompDiffOutput) { t.Helper() - tu.AssertStructuredCompDiff(t, output, tu.ExpectCompDiff(). - WithError("Composition/test-composition"). - WithComposition("test-composition"). - WithCompositionErrorContaining("cannot convert composition")) + + i := slices.IndexFunc(output.Compositions, func(c dt.CompositionDiff) bool { return c.Name == "test-composition" }) + if i < 0 { + t.Fatalf("Compositions: got %+v, want test-composition", output.Compositions) + } + + if e := output.Compositions[i].Error; e == nil || e.ResourceID != "Composition/test-composition" || + !strings.Contains(e.Message, "cannot convert composition") { + t.Errorf("Compositions[test-composition].Error: got %+v, want Composition/test-composition containing %q", e, "cannot convert composition") + } }, wantErr: true, // The renderer prints no errors, so the returned one must name the composition. @@ -483,6 +490,10 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { t.Errorf("DiffComposition() error = %v, want it to contain %q", err, tt.wantErrContains) } + if tt.verifyReport != nil { + tt.verifyReport(t, output) + } + if tt.verifyOutput != nil { tt.verifyOutput(t, renderCompReport(t, output, tt.outputFormat)) } @@ -2129,8 +2140,7 @@ func TestDefaultCompDiffProcessor_DiffComposition_ResourceMode(t *testing.T) { } // renderCompReport renders a DiffComposition report in the given format (empty means the -// human-readable diff) and returns what it wrote to stdout. JSON is project simulate's: the report -// mapped onto plan.Result. +// human-readable diff) and returns what it wrote to stdout. func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.OutputFormat) string { t.Helper() @@ -2138,15 +2148,6 @@ func renderCompReport(t *testing.T, output *dt.CompDiffOutput, format renderer.O return "" } - if format == renderer.OutputFormatJSON { - out, err := json.Marshal(simulate.FromCompReport(output, nil)) - if err != nil { - t.Fatalf("json.Marshal(): %v", err) - } - - return string(out) - } - var stdout bytes.Buffer opts := renderer.DefaultDiffOptions() diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 73872e64..cb42669d 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -35,6 +35,7 @@ import ( dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/simulate/simtest" ) const ( @@ -120,8 +121,8 @@ type IntegrationTestCase struct { // need not climb back to the root — but concrete field types here made that unreachable, which is // why every case in this file used to end with a trailing And()/AndXR()/AndComp(). A chain can now // simply end where it ends. - expectedStructuredOutput tu.DiffExpectation // for XR JSON output assertions - expectedStructuredCompOutput tu.CompDiffExpectation // for comp JSON output assertions + expectedStructuredOutput simtest.DiffExpectation // for XR JSON output assertions + expectedStructuredCompOutput simtest.CompDiffExpectation // for comp JSON output assertions } type XrdAPIVersion int @@ -520,12 +521,12 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC // Handle JSON output format with structured assertions if tt.outputFormat == "json" && tt.expectedStructuredOutput != nil { - tu.AssertStructuredDiff(t, outputStr, tt.expectedStructuredOutput) + simtest.AssertStructuredDiff(t, outputStr, tt.expectedStructuredOutput) return } if tt.outputFormat == "json" && tt.expectedStructuredCompOutput != nil { - tu.AssertStructuredCompDiff(t, outputStr, tt.expectedStructuredCompOutput) + simtest.AssertStructuredCompDiff(t, outputStr, tt.expectedStructuredCompOutput) return } @@ -605,7 +606,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-no-namespace-patch.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -628,7 +629,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-configmap.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(3, 0, 0). WithAddedResource("ConfigMap", "test-resource-config", "default"). WithField("data.configData", "new-value"). @@ -690,7 +691,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "test-resource", "nonexistent-namespace"). WithField("spec.coolField", "new-value"). @@ -764,7 +765,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-builtin-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("Deployment", "test-resource-deploy", "default"). // Absence of dryRun is the machine-readable claim that this desired state was @@ -799,7 +800,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-builtin-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("Deployment", "test-resource-deploy", "default"). // No detail substring: "disabled" is the one skip reason the user asked for, so @@ -849,7 +850,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-status.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XStatusResource", "test-resource", "default"). WithDryRunPerformed(). @@ -876,7 +877,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). // Both the XR and its composed resource use generateName, so both exercise the // identity restore. The pattern is the renderer's display form for a @@ -910,7 +911,7 @@ func TestDiffIntegration(t *testing.T) { awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", expectedError: true, expectedExitCode: dp.ExitCodeSchemaValidation, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). // errors[].resourceID names the input the user supplied, so the rejected composed // resource has to be identifiable from the message and the validationFailures entry. WithError("XNopResource/test-resource"). @@ -945,7 +946,7 @@ func TestDiffIntegration(t *testing.T) { inputFiles: []string{"testdata/diff/modified-xr.yaml"}, expectedError: true, expectedExitCode: dp.ExitCodeSchemaValidation, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithError("XNopResource/test-resource"). WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). @@ -965,20 +966,20 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). // Aggregate flat view still reports the merged total (2 adds per XR). WithSummary(4, 0, 0). // Grouped view: one XR(...) sub-expression per input XR, each with // its own changed tree. WithXRs( - tu.XR("XNopResource", "test-resource", "default"). + simtest.XR("XNopResource", "test-resource", "default"). Status("changed"). Summary(2, 0, 0). Change("added", "XNopResource", "test-resource", "default"). WithField("spec.coolField", "new-value"). Change("added", "XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"), - tu.XR("XNopResource", "second-resource", "default"). + simtest.XR("XNopResource", "second-resource", "default"). Status("changed"). Summary(2, 0, 0). Change("added", "XNopResource", "second-resource", "default"). @@ -1002,18 +1003,18 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). // The top-level summary counts distinct resources. WithSummary(4, 0, 0). WithXRs( - tu.XR("XNopResource", "test-resource", "default"). + simtest.XR("XNopResource", "test-resource", "default"). Status("changed"). Summary(2, 0, 0). Change("added", "XNopResource", "test-resource", "default"). WithField("spec.coolField", "new-value"). Change("added", "XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"), - tu.XR("XNopResource", "second-resource", "default"). + simtest.XR("XNopResource", "second-resource", "default"). Status("changed"). Summary(2, 0, 0). Change("added", "XNopResource", "second-resource", "default"). @@ -1100,7 +1101,7 @@ Summary: 2 modified`, "metadata.labels[argocd.argoproj.io/instance]", }, outputFormat: "json", - expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), expectedError: false, }, "IgnorePathsMixedChanges": { @@ -1130,7 +1131,7 @@ Summary: 2 modified`, "metadata.labels[argocd.argoproj.io/instance]", }, outputFormat: "json", - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "new-value", "modified-value"). @@ -1153,7 +1154,7 @@ Summary: 2 modified`, "testdata/diff/resources/existing-xr.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 1, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "modified-value"). @@ -1181,7 +1182,7 @@ Summary: 2 modified`, }, deleteAfterSetup: []string{"testdata/diff/resources/existing-xr-deleting.yaml"}, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1221,7 +1222,7 @@ Summary: 2 modified`, }, deleteAfterSetup: []string{"testdata/diff/resources/removal-test-ns-downstream-resource2.yaml"}, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 0, 0). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ @@ -1253,7 +1254,7 @@ Summary: 2 modified`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-env-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamEnvResource", "test-env-resource", ""). WithFieldChange("spec.forProvider.configData", "existing-config-value", "modified-config-value"). @@ -1282,7 +1283,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "testing-external-resource-data"). @@ -1317,7 +1318,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-with-external-dep"). @@ -1351,7 +1352,7 @@ Summary: 2 modified`, "testdata/diff/resources/external-res-gotpl-composition.yaml", }, inputFiles: []string{"testdata/diff/new-xr-with-cross-ns-external-dep.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -1382,7 +1383,7 @@ Summary: 2 modified`, "testdata/diff/resources/external-res-matchname-gotpl-composition.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -1488,7 +1489,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-cross-ns-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-cross-ns-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-cross-ns-data-existing-named-data-old-cross-ns-role", "cross-namespace-data-another-cross-namespace-data-external-named-clusterrole"). @@ -1626,7 +1627,7 @@ Summary: 2 modified, 2 removed`, // Function-sequencer sees both resources exist and does NOT remove them from desired state. // Since existing resources match rendered resources, there should be no changes. // XR diff outputs nothing when there are no changes (unlike comp diff which says "No changes detected"). - expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), expectedError: false, expectedExitCode: dp.ExitCodeSuccess, }, @@ -1660,7 +1661,7 @@ Summary: 2 modified, 2 removed`, }, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, // XR has NO changes // Expected: the nested XR resource2 and what it composes are removed, but XR and resource1 have no changes - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 0, 2). WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). // dryRun must be absent on a removal. A removal has no desired state to preview, so it can @@ -1706,7 +1707,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", }, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 0, 1). WithRemovedResource("XDownstreamResource", "resource-to-be-removed", "default"). WithField("spec.forProvider.configData", "existing-value"), @@ -1737,7 +1738,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 2). WithModifiedResource("XDownstreamResource", "resource-to-be-kept", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1778,7 +1779,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 2). WithModifiedResource("XDownstreamResource", "resource-to-be-kept", ""). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1814,7 +1815,7 @@ Summary: 2 modified, 2 removed`, }, // Use a composition that uses generateName for composed resources inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"). @@ -1842,7 +1843,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-downstream-with-generated-name.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 1, 0). WithAddedResource("XNopResource", "test-resource", "default"). And(). @@ -1863,11 +1864,11 @@ Summary: 2 modified, 2 removed`, }, inputFiles: []string{"testdata/diff/new-xr.yaml", "testdata/diff/generated-name-xr.yaml"}, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(4, 0, 0). WithXRs( - tu.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0), - tu.XR("XNopResource", "generated-xr-(generated)", "default").Status("changed").Summary(2, 0, 0), + simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0), + simtest.XR("XNopResource", "generated-xr-(generated)", "default").Status("changed").Summary(2, 0, 0), ), }, "NewXRWithGenerateName": { @@ -1880,7 +1881,7 @@ Summary: 2 modified, 2 removed`, // We don't add any existing XR, as we're testing creation of a new one }, inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "", "default"). WithNamePattern(`generated-xr-\(generated\)`). @@ -1895,7 +1896,7 @@ Summary: 2 modified, 2 removed`, // carries the effective (synthesized) name — the same one the // changes above are keyed by. WithXRs( - tu.XR("XNopResource", "generated-xr-(generated)", "default"). + simtest.XR("XNopResource", "generated-xr-(generated)", "default"). Status("changed"). Summary(2, 0, 0), ), @@ -1919,9 +1920,9 @@ Summary: 2 modified, 2 removed`, expectedExitCode: dp.ExitCodeDiffDetected, expectedStderrContains: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, // The warning channel's contract is both halves: stderr above, and warnings[] here. - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). - WithXRs(tu.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0)). + WithXRs(simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0)). WithWarning("Ignoring a duplicate input: it is identical to an earlier one"). WithWarningContext(map[string]string{"resource": "XNopResource/test-resource", "input": "2", "duplicateOf": "1"}), }, @@ -2003,7 +2004,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-downstream-resource.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 1, 0). WithAddedResource("XNopResource", "test-resource", "default"). WithField("spec.coolField", "new-value"). @@ -2032,7 +2033,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/first-xr.yaml", "testdata/diff/modified-xr.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 2, 0). WithAddedResource("XDownstreamResource", "first-resource", "default"). WithField("spec.forProvider.configData", "first-value"). @@ -2062,7 +2063,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{ "testdata/diff/xr-with-composition-ref.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -2087,7 +2088,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{ "testdata/diff/xr-with-composition-selector.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -2135,7 +2136,7 @@ Summary: 2 modified, 2 removed`, // ours to assert; the embedded k8s message wording is // left unasserted (apimachinery upgrades can shift its // phrasing without changing our output contract). - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithError("XNopResource/invalid-schema-xr"). WithValidationFailure("ns.diff.example.org/v1alpha1", "XNopResource", "invalid-schema-xr", "default"). WithStatus("invalid"). @@ -2163,7 +2164,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/new-claim.yaml"}, // The claim is defaulted with its own CRD (generated from claim-xrd.yaml), which shares the XRD's // user default spec.tier with the XR's CRD but, unlike it, does not default compositionUpdatePolicy. - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("NopClaim", "test-claim", "existing-namespace"). WithField("spec.coolField", "new-value"). @@ -2185,7 +2186,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-claim-with-claimref-composition.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("NopClaim", "test-claim", "existing-namespace"). WithField("spec.coolField", "new-value"). @@ -2215,7 +2216,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-claim.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). // The composed resource is labelled with the backing XR the claim's resourceRef names, so it // already belongs to this claim: no ownership advisory (#534). WithNoWarnings(). @@ -2251,7 +2252,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml", fieldManager: "platform-operator"}, }, inputFiles: []string{"testdata/diff/modified-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). // As in ModifiedClaimShowsDiff: the composed resource belongs to the claim's backing XR (#534). WithNoWarnings(). WithSummary(0, 2, 0). @@ -2280,7 +2281,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). WithDryRunPerformed(). @@ -2303,7 +2304,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). WithDryRunSkipped("disabled", ""). @@ -2326,7 +2327,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). WithField("spec.region", "us-east-1"). @@ -2346,7 +2347,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-rendered-defaults-xrs.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(4, 0, 0). WithAddedResource("XRegionalDatabase", "modern-database", "default"). WithField("spec.region", "us-east-1"). @@ -2389,7 +2390,7 @@ Summary: 2 modified, 2 removed`, fieldManager: "platform-operator", }, }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 0, 0). WithAddedResource("XNopResource", "test-resource", "default"), expectedError: false, @@ -2414,7 +2415,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml", fieldManager: CompositeReconcilerFieldManager}, {file: "testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml", fieldManager: "platform-operator"}, }, - expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), expectedError: false, expectedExitCode: dp.ExitCodeSuccess, }, @@ -2432,7 +2433,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). WithDryRunSkipped("disabled", ""). @@ -2452,7 +2453,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDefaultedDownstreamResource", "test-resource-defaulted", "default"). WithDryRunSkipped("disabled", ""). @@ -2479,7 +2480,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XVersionedDownstreamResource", "test-resource-versioned", "default"). WithDryRunSkipped("disabled", ""). @@ -2527,7 +2528,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-overrides", "default"). WithField("spec.region", "us-west-2"). @@ -2553,7 +2554,7 @@ Summary: 2 modified, 2 removed`, // Each XR should produce 3 base resources + 2 additional resources = 5 resources per XR // Plus the XR itself = 6 additions per XR // Total: 5 XRs * 6 additions = 30 additions - expectedStructuredOutput: tu.ExpectDiff().WithSummary(30, 0, 0), + expectedStructuredOutput: simtest.ExpectDiff().WithSummary(30, 0, 0), expectedError: false, expectedExitCode: dp.ExitCodeDiffDetected, }, @@ -2572,7 +2573,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-nested-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(3, 0, 0). WithAddedResource("XChildResource", "test-parent-child", "default"). WithField("spec.childField", "parent-value"). @@ -2602,7 +2603,7 @@ Summary: 2 modified, 2 removed`, expectedError: true, expectedErrorContains: "maximum nesting depth exceeded: XCycleB/test-cycle-c-c-c-c-c-c-c-c-c-c-c (nested depth 11) is nested 11 levels deep, but --max-nested-depth is 10", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithError("XCycleA/test-cycle"). WithMessageContaining("maximum nesting depth exceeded"), }, @@ -2634,7 +2635,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 3, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -2677,7 +2678,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml", fieldManager: "platform-operator"}, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 3, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -2708,7 +2709,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-v1.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). @@ -2735,7 +2736,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-automatic", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2766,7 +2767,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-selector-stable.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-selector", "default"). // stable-* (not preview-*) proves the selector, not "latest overall", chose the revision. @@ -2810,7 +2811,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-upgrade-to-v2.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2857,7 +2858,7 @@ Summary: 2 modified, 2 removed`, // Key assertion: nothing is added or removed, proving API version migration works. The // XApiMigrateResource is stored identically at either version, so it renders unchanged; an // unmatched one would be a +++ addition plus a --- removal. Only the XR's revision ref changes. - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 1, 0). WithModifiedResource("XNopResource", "test-api-version-xr", "default"), expectedError: false, @@ -2885,7 +2886,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2-changed-field.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XApiMigrateResource", "test-api-version-xr-api-resource", "default"). WithFieldChange("spec.forProvider.configData", "test-value", "changed-value"). @@ -2911,7 +2912,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-switch-to-automatic.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2931,7 +2932,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-xr-manual-no-ref.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-manual-no-ref", "default"). WithField("spec.forProvider.configData", "v2-new-value"). @@ -2959,7 +2960,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2990,7 +2991,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/existing-xr-revision-ref-omitted.yaml"}, - expectedStructuredOutput: tu.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), expectedExitCode: dp.ExitCodeSuccess, }, // Issue #499, the consequence for which revision renders. The cluster's ref is inherited before the @@ -3014,7 +3015,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). // v1-* (not v2-*) proves the pinned revision, not the latest, rendered. @@ -3042,7 +3043,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). @@ -3080,7 +3081,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -3116,7 +3117,7 @@ Summary: 2 modified, 2 removed`, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/unchanged-automatic-pinned-claim.yaml"}, // No warnings either: the composed resource belongs to the claim's backing XR (#534). - expectedStructuredOutput: tu.ExpectDiff().WithNoWarnings().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.ExpectDiff().WithNoWarnings().WithSummary(0, 0, 0), expectedExitCode: dp.ExitCodeSuccess, }, // A Manual claim's pin lives on its backing XR: the claim syncer copies the ref back to a claim only @@ -3141,7 +3142,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-manual-pinned-claim.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). // The composed resource is labelled with the backing XR's name, which the claim's resourceRef // names, so it already belongs to this claim: no ownership advisory (#534). WithNoWarnings(). @@ -3175,7 +3176,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/unchanged-lagging-automatic-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 1, 0). WithModifiedResource("XDownstreamResource", "lagging-xr", ""). WithFieldChange("spec.forProvider.configData", @@ -3195,7 +3196,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-manual-default-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XManualDefault", "new-manual-default", "default"). And(). @@ -3222,7 +3223,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-selector-bound.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "selector-bound", "default"). WithFieldChange("spec.forProvider.configData", "a-existing-value", "a-modified-value"). @@ -3247,7 +3248,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-v2xrd-v1paths", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -3330,7 +3331,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-claim-nested.yaml"}, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 3, 0). WithModifiedResource("ClusterNopResource", "existing-parent-claim-82crv-nop", ""). And(). @@ -3358,7 +3359,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). WithField("spec.forProvider.configData", "test-value-creds"). @@ -3385,7 +3386,7 @@ Summary: 2 modified, 2 removed`, // instead, so the shortfall advisory must NOT fire. It used to, because the advisory was // raised inside the credential client, which runs before the CLI-supplied secrets are merged // in — telling a user who had just solved the problem to go and solve it. - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithNoWarnings(). WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). @@ -3413,7 +3414,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 added; stage1 NOT present (gated). - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "sequencer-test", "default"). WithField("spec.coolField", "test-value"). @@ -3435,7 +3436,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(3, 0, 0). WithAddedResource("XNopResource", "sequencer-test", "default"). WithField("spec.coolField", "test-value"). @@ -3467,7 +3468,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR and stage0 modified, stage1 added (unlocked by stage0's synthesized Ready). - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(1, 2, 0). WithModifiedResource("XNopResource", "sequencer-test", "default"). WithFieldChange("spec.coolField", "existing-value", "test-value"). @@ -3498,7 +3499,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml"), }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(0, 3, 0). WithModifiedResource("XNopResource", "sequencer-test", "default"). WithFieldChange("spec.coolField", "existing-value", "test-value"). @@ -3527,7 +3528,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). @@ -3549,7 +3550,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 + stage1 all added. - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(3, 0, 0). WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). @@ -3581,7 +3582,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/environment-config-v1beta1.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: tu.ExpectDiff(). + expectedStructuredOutput: simtest.ExpectDiff(). WithSummary(3, 0, 0). // 2 downstream resources + 1 XR WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -3764,7 +3765,7 @@ Summary: 2 modified`, // Total counts both discovered XRs; only the live one is evaluated (1 with changes), and // the deleting one is reported via filteredByDeletion. In default-discovery mode filtered // XRs are counted but not surfaced as impact entries, so only test-resource appears. - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 1, 0, 0). @@ -3793,7 +3794,7 @@ Summary: 2 modified`, resources: []string{"default/deleting-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithFilteredByDeletion(1). @@ -3823,7 +3824,7 @@ Summary: 2 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ "resource": "XDownstreamResource/test-resource", @@ -3868,7 +3869,7 @@ Summary: 2 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("removal-test-composition"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -3995,7 +3996,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // composition's template read the revision name, the resulting change would go undetected. "Could not predict the name of the CompositionRevision", }, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). // The same advisory reaches warnings[] for machine consumers, keyed by composition so that two // compositions declining to predict stay two warnings rather than being merged as duplicates. WithWarning("Could not predict the name of the CompositionRevision"). @@ -4031,7 +4032,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithNoWarnings(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). @@ -4071,7 +4072,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). // The name was predictable, so the could-not-predict advisory must stay silent. WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). @@ -4119,7 +4120,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl includeManual: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). // One composite re-points; the Manual one is kept but adopts nothing. @@ -4160,7 +4161,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "test-namespace", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). // The claim's composed resource is labelled with its backing XR's name, which is the claim's // owner, so no "already belongs to another composite" advisory is raised (#534). WithNoWarnings(). @@ -4201,7 +4202,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, expectedStderrContains: []string{"Could not predict the name of the CompositionRevision"}, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). // The could-not-predict advisory is the only warning: the claim's composed resource is labelled // with its backing XR's name, so it raises no ownership advisory (#534). WithWarning("Could not predict the name of the CompositionRevision"). @@ -4238,7 +4239,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl analyzeOn: "spec-change", outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). WithRevisionImpact("metadata", true, 1). @@ -4272,7 +4273,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl analyzeUnchanged: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithAffectedResources(1, 1, 0, 0). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -4417,7 +4418,7 @@ Summary: 1 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -5414,7 +5415,7 @@ Summary: 1 modified`, expectedError: true, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). WithXRImpact("XCycleA", "test-cycle", "default", "error"). @@ -5436,7 +5437,7 @@ Summary: 1 modified`, expectedError: true, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithError("XCycleA/test-cycle"). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). @@ -5624,7 +5625,7 @@ Summary: 2 modified`, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: impact shows stage0 modified (its configData changed); stage1 is absent from // downstream impact because sequencer gates it. - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -5652,7 +5653,7 @@ Summary: 2 modified`, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, // Expect: impact shows stage0 modified and stage1 added (eventual-state unlocks stage1). - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -5687,7 +5688,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -5714,7 +5715,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -5747,7 +5748,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -5775,7 +5776,7 @@ Summary: 2 modified`, `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=/v1, Kind=ConfigMap, namespace=nonexistent-namespace)`, }, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithWarning("their namespace does not exist yet"). WithWarningContext(map[string]string{ "gvk": "/v1, Kind=ConfigMap", @@ -5817,7 +5818,7 @@ Summary: 2 modified`, resources: []string{"default/test-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -5844,7 +5845,7 @@ Summary: 2 modified`, resourcesCSV: "default/test-resource,default/another-resource", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -5888,7 +5889,7 @@ Summary: 2 modified`, resources: []string{"default/manual-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "filtered"). @@ -5912,7 +5913,7 @@ Summary: 2 modified`, includeManual: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "changed"), @@ -5932,7 +5933,7 @@ Summary: 2 modified`, resources: []string{"default/selector-mismatch-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). @@ -5956,7 +5957,7 @@ Summary: 2 modified`, resources: []string{"default/selector-match-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: tu.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.ExpectCompDiff(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-match-resource", "default", "changed"), diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go index 6dda7ef2..5109b11b 100644 --- a/internal/diff/testutils/mock_builder.go +++ b/internal/diff/testutils/mock_builder.go @@ -1,3 +1,4 @@ +// Package testutils provides test utilities for crossplane-diff. package testutils import ( diff --git a/internal/diff/testutils/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go similarity index 99% rename from internal/diff/testutils/plan_assertions.go rename to internal/simulate/simtest/plan_assertions.go index 757d0af9..89ca067c 100644 --- a/internal/diff/testutils/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -1,4 +1,4 @@ -package testutils +package simtest import ( "encoding/json" diff --git a/internal/diff/testutils/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go similarity index 99% rename from internal/diff/testutils/structured_assertions.go rename to internal/simulate/simtest/structured_assertions.go index 4dfaa183..ba47bb73 100644 --- a/internal/diff/testutils/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -1,5 +1,6 @@ -// Package testutils provides test utilities for crossplane-diff. -package testutils +// Package simtest asserts on project simulate's structured output, plan.Result, through +// crossplane-diff's expectation builders. +package simtest import ( "regexp" diff --git a/internal/diff/testutils/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go similarity index 99% rename from internal/diff/testutils/structured_assertions_test.go rename to internal/simulate/simtest/structured_assertions_test.go index 62887a7b..8d9d75f6 100644 --- a/internal/diff/testutils/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -1,4 +1,4 @@ -package testutils +package simtest import ( "encoding/json" From bf9adc63b5e7e3b3a7207b3dbac4d80125fe1943 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 12:33:15 -0400 Subject: [PATCH 062/126] test(simulate): mark the unchanged-field check as reduced coverage 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) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 89ca067c..7b60bd75 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -450,7 +450,9 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri for path, want := range changes { // FieldChanges are leaf changes only, so a field rendered identically on both sides has no - // plan.Result home: all that can be asserted is that nothing at or under it changed. + // plan.Result home, and the plan's object (Desired) is not serialized: all that can be asserted + // is that nothing at or under it changed, not its value. ponytail: reduced coverage, see + // revisit.md R14; restore the value check once plan.Result carries the object. if want[0] != nil && reflect.DeepEqual(want[0], want[1]) { if c := changesUnder(t, p.Changes, path); len(c) > 0 { t.Errorf("%s: field %s: expected unchanged (%v), got changes %+v", id, path, want[0], c) From d2674c05075c491855e8f389c133890c3de92f5e Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 12:33:42 -0400 Subject: [PATCH 063/126] fix(simulate): tidy provider-plan review nits 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) Signed-off-by: Jonathan Ogilvie --- internal/diff/renderer/diff_renderer.go | 8 ++++++-- internal/diff/renderer/diff_renderer_test.go | 11 +++++++++++ internal/simulate/annotate.go | 3 --- internal/simulate/report.go | 14 ++++++++++---- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index 62819abb..5e8be4ab 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -170,8 +170,12 @@ func (c diffCounts) plus(other diffCounts) diffCounts { // structured-logging pass could later swap this for a logr.Marshaler to regain // per-field queryability.) func (c diffCounts) String() string { - return fmt.Sprintf("added=%d modified=%d removed=%d equal=%d replaced=%d output=%d", - c.added, c.modified, c.removed, c.equal, c.replaced, c.output) + s := fmt.Sprintf("added=%d modified=%d removed=%d equal=%d", c.added, c.modified, c.removed, c.equal) + if c.replaced > 0 { + s += fmt.Sprintf(" replaced=%d", c.replaced) + } + + return s + fmt.Sprintf(" output=%d", c.output) } // summaryLine formats a "N added, N modified, N removed" fragment, omitting diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index 7856fb5f..79ff7557 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -492,3 +492,14 @@ func xrGroup(kind, name string, diffs map[string]*dt.ResourceDiff) dt.XRDiffGrou Diffs: diffs, } } + +// The counts string keeps upstream's format, adding replaced= only when a diff is replaced. +func TestDiffCountsString(t *testing.T) { + if got, want := (diffCounts{added: 1}).String(), "added=1 modified=0 removed=0 equal=0 output=0"; got != want { + t.Errorf("String(): got %q, want %q", got, want) + } + + if got, want := (diffCounts{modified: 2, replaced: 1}).String(), "added=0 modified=2 removed=0 equal=0 replaced=1 output=0"; got != want { + t.Errorf("String(): got %q, want %q", got, want) + } +} diff --git a/internal/simulate/annotate.go b/internal/simulate/annotate.go index 32a0fcc0..adc4988d 100644 --- a/internal/simulate/annotate.go +++ b/internal/simulate/annotate.go @@ -176,9 +176,6 @@ func annotateDiff(d *dt.ResourceDiff, pp *plan.ResourcePlan) *dt.ResourceDiff { // "- path: actual" and "+ path: planned" pair per change, with computed // values and replacement marked as elsewhere. It returns d when no change has // a value to print. -// -// ponytail: D-17 does not cover drift, and this block is pending the user's -// confirmation; drop or change it here alone. func driftDiff(d *dt.ResourceDiff, pp *plan.ResourcePlan) *dt.ResourceDiff { if !isChange(pp.Action) { return d diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 6c663933..a47b8896 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -200,9 +200,12 @@ type assembler struct { } type listedChange struct { - action plan.Action - changes []plan.FieldChange - dryRun *plan.Verification + action plan.Action + changes []plan.FieldChange + dryRun *plan.Verification + err string + approximate bool + replacePaths []string } func newAssembler(provider map[string]*plan.ResourcePlan) *assembler { @@ -235,7 +238,10 @@ func (a *assembler) addDiff(d *dt.ResourceDiff, composite *plan.ResourceRef) boo p.Composite = composite changed := p.Error == "" && isChange(p.Action) - lc := listedChange{action: p.Action, changes: p.Changes, dryRun: p.DryRun} + lc := listedChange{ + action: p.Action, changes: p.Changes, dryRun: p.DryRun, + err: p.Error, approximate: p.Approximate, replacePaths: p.ReplacePaths, + } if slices.ContainsFunc(a.listed[key], func(l listedChange) bool { return reflect.DeepEqual(l, lc) }) { return changed } From dfaf72aefe5a1425aa1c48afd5aa5e1ce87e1649 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 14:29:22 -0400 Subject: [PATCH 064/126] feat(plan)!: carry before and after objects in plan.Result 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) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 6 +- cmd/crossplane/simulate/help/simulate.md | 4 +- internal/plan/fallback.go | 1 + internal/plan/fallback_test.go | 75 +++++++++++++++++ internal/plan/output.go | 2 +- internal/plan/plan.go | 7 +- internal/plan/plan_test.go | 6 ++ internal/plan/proto_test.go | 3 +- internal/plan/types.go | 49 ++++++++++-- internal/simulate/report.go | 5 ++ internal/simulate/report_test.go | 80 ++++++++++++++++++- internal/simulate/simtest/plan_assertions.go | 52 ++++++++++-- .../simtest/structured_assertions_test.go | 23 +++++- 13 files changed, 292 insertions(+), 21 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index e76d70aa..7c8c0275 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -48,7 +48,11 @@ The `-o` flag picks the output format: the composite resource it belongs to. A changed Composition prints first, with whether applying it creates a new Composition revision. - `json` and `yaml` print the same plan as a structured result, for CI and - other tools. + other tools. Each plan carries the objects it compares, as `before` (the + live object, absent for a create) and `after` (the rendered object, absent + for a delete), with ignored paths, status and server-managed metadata + removed and Secret values redacted. A provider-planned resource's `after` + is the manifest sent to the provider, not the provider's view. - `diff` prints the line diff `crossplane-diff` prints. For example, a changed Composition prints a plan like this: diff --git a/cmd/crossplane/simulate/help/simulate.md b/cmd/crossplane/simulate/help/simulate.md index 324dbccf..2962e4c6 100644 --- a/cmd/crossplane/simulate/help/simulate.md +++ b/cmd/crossplane/simulate/help/simulate.md @@ -86,7 +86,9 @@ repository, then simulate with a Provider manifest annotated with crossplane resource simulate -f resources.yaml --providers providers.yaml ``` -Emit machine-readable results for CI gating: +Emit machine-readable results for CI gating. Each plan carries the live +object as `before` (absent for a create) and the desired manifest as `after`, +with Secret values redacted: ```shell crossplane resource simulate -f resources.yaml -o json diff --git a/internal/plan/fallback.go b/internal/plan/fallback.go index f0525578..89981e25 100644 --- a/internal/plan/fallback.go +++ b/internal/plan/fallback.go @@ -41,6 +41,7 @@ func Fallback(desired, live *unstructured.Unstructured, reason string) *Resource p := NewResourcePlan(desired) p.Approximate = true p.FallbackReason = reason + p.Before, p.After = RedactSecret(live), RedactSecret(desired) desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec") diff --git a/internal/plan/fallback_test.go b/internal/plan/fallback_test.go index 85982d3c..c288c187 100644 --- a/internal/plan/fallback_test.go +++ b/internal/plan/fallback_test.go @@ -307,3 +307,78 @@ func TestFieldChanges(t *testing.T) { }) } } + +func TestFallbackObjects(t *testing.T) { + desired := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}) + live := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}) + + t.Run("Update", func(t *testing.T) { + got := Fallback(desired, live, "test") + if diff := cmp.Diff(live, got.Before); diff != "" { + t.Errorf("Fallback(...).Before: -want live, +got:\n%s", diff) + } + if diff := cmp.Diff(desired, got.After); diff != "" { + t.Errorf("Fallback(...).After: -want desired, +got:\n%s", diff) + } + }) + + t.Run("Create", func(t *testing.T) { + if got := Fallback(desired, nil, "test"); got.Before != nil { + t.Errorf("Fallback(...).Before: want nil for a create, got %v", got.Before) + } + }) +} + +func TestRedactSecret(t *testing.T) { + secret := func(data, stringData any) *unstructured.Unstructured { + u := &unstructured.Unstructured{Object: map[string]any{"apiVersion": "v1", "kind": "Secret", "metadata": map[string]any{"name": "s"}}} + if data != nil { + u.Object["data"] = data + } + if stringData != nil { + u.Object["stringData"] = stringData + } + return u + } + + cases := map[string]struct { + reason string + in *unstructured.Unstructured + want *unstructured.Unstructured + }{ + "Nil": { + reason: "Nil stays nil.", + }, + "NotASecret": { + reason: "Any other kind is returned as is.", + in: mr(map[string]any{"data": map[string]any{"k": "v"}}), + want: mr(map[string]any{"data": map[string]any{"k": "v"}}), + }, + "Secret": { + reason: "Each data and stringData value is redacted; keys and other fields stay.", + in: secret(map[string]any{"a": "x", "b": "y"}, map[string]any{"c": "z"}), + want: secret(map[string]any{"a": "(sensitive value)", "b": "(sensitive value)"}, map[string]any{"c": "(sensitive value)"}), + }, + "MalformedData": { + reason: "A data field that is not a map is redacted whole.", + in: secret("raw", nil), + want: secret("(sensitive value)", nil), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var before *unstructured.Unstructured + if tc.in != nil { + before = tc.in.DeepCopy() + } + got := RedactSecret(tc.in) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("\n%s\nRedactSecret(...): -want, +got:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(before, tc.in); diff != "" { + t.Errorf("\n%s\nRedactSecret(...): must not modify its input: -before, +after:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/internal/plan/output.go b/internal/plan/output.go index 2ab41e15..064cd5d5 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -87,7 +87,7 @@ func Summarize(plans []*ResourcePlan) Summary { // ResultFormatVersion versions the serialized result schema so automation // consuming it can detect incompatible changes. The schema is alpha; the // version moves to 1.0 when it stabilizes. -const ResultFormatVersion = "0.2" +const ResultFormatVersion = "0.3" // CompositeStatus is the outcome of simulating one composite resource. type CompositeStatus string diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 581665d3..fa65a350 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -54,5 +54,10 @@ func Plan(ctx context.Context, c diffv1alpha1.PlanServiceClient, desired, actual if err != nil { return nil, err } - return FromResponse(desired, rsp) + p, err := FromResponse(desired, rsp) + if err != nil { + return nil, err + } + p.Before, p.After = RedactSecret(actual), RedactSecret(desired) + return p, nil } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index ee8e01cc..bb22c11b 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -76,6 +76,9 @@ func TestPlan(t *testing.T) { if c.got.GetActualResource() == nil { t.Errorf("Plan(...): actual resource not carried in request") } + if got.Before != actual || got.After != desired { + t.Errorf("Plan(...): want the plan to carry actual as before and desired as after") + } if len(c.got.GetKubernetesObjectStore()) != 1 { t.Errorf("Plan(...): want 1 object store entry, got %d", len(c.got.GetKubernetesObjectStore())) } @@ -94,6 +97,9 @@ func TestPlan(t *testing.T) { if c.got.GetActualResource() != nil { t.Errorf("Plan(...): actual resource should be unset for a create") } + if got.Before != nil || got.After != desired { + t.Errorf("Plan(...): want a create to carry no before and desired as after") + } }) t.Run("ServerErrorsPassThrough", func(t *testing.T) { diff --git a/internal/plan/proto_test.go b/internal/plan/proto_test.go index 76a89429..835529c9 100644 --- a/internal/plan/proto_test.go +++ b/internal/plan/proto_test.go @@ -21,7 +21,6 @@ import ( diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" "github.com/google/go-cmp/cmp" - "github.com/google/go-cmp/cmp/cmpopts" "google.golang.org/genproto/googleapis/rpc/errdetails" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -202,7 +201,7 @@ func TestFromResponse(t *testing.T) { if err != nil { t.Fatalf("\n%s\nFromResponse(...): unexpected error: %v", tc.reason, err) } - if diff := cmp.Diff(tc.want.plan, got, cmpopts.IgnoreFields(ResourcePlan{}, "Desired")); diff != "" { + if diff := cmp.Diff(tc.want.plan, got); diff != "" { t.Errorf("\n%s\nFromResponse(...): -want, +got:\n%s", tc.reason, diff) } }) diff --git a/internal/plan/types.go b/internal/plan/types.go index 0ca376d1..3997823e 100644 --- a/internal/plan/types.go +++ b/internal/plan/types.go @@ -108,10 +108,18 @@ type ResourcePlan struct { // against the API server; nil means it was verified (or not applicable). DryRun *Verification `json:"dryRun,omitempty"` - // Desired is the resource the plan is for. It is carried for rendering - // (the plan output shows the user's own manifest, annotated) and is not - // part of the serialized result. - Desired *unstructured.Unstructured `json:"-"` + // Before is the live object the plan compares, nil for a create. After + // is the desired object, nil for a delete. They let a consumer match the + // plan to its input and render its own diff. For project simulate both + // are the cleaned views the cluster layer diffs, with ignored paths, + // status and server-managed metadata removed, so they agree with Changes; + // both are nil when the cluster layer found the resource unchanged. For a + // provider-planned resource After is the manifest sent to the provider, + // not the provider's view of the result: computed values and provider + // defaults appear only in Changes. A Secret's data and stringData values + // are redacted (see RedactSecret). + Before *unstructured.Unstructured `json:"before,omitempty"` + After *unstructured.Unstructured `json:"after,omitempty"` } // ResourceRef identifies a resource. @@ -146,6 +154,38 @@ func (p *ResourcePlan) ResourceRef() ResourceRef { return ResourceRef{APIVersion: p.APIVersion, Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} } +// RedactSecret returns u with each data and stringData value replaced by the +// sensitive-value marker when u is a core Secret, as a copy; any other object +// is returned as is. Keys are kept, so a consumer still sees which keys exist. +func RedactSecret(u *unstructured.Unstructured) *unstructured.Unstructured { + if u == nil { + return nil + } + if gvk := u.GroupVersionKind(); gvk.Group != "" || gvk.Kind != "Secret" { + return u + } + + sensitive := FormatValue(&Value{Sensitive: true}) + out := u.DeepCopy() + for _, field := range []string{"data", "stringData"} { + v, ok := out.Object[field] + if !ok { + continue + } + m, ok := v.(map[string]any) + if !ok { + // Malformed: redact the whole field rather than leak it. + out.Object[field] = sensitive + continue + } + for k := range m { + m[k] = sensitive + } + } + + return out +} + // NewResourcePlan returns a ResourcePlan identifying the given desired // resource, with no action decided yet. func NewResourcePlan(desired *unstructured.Unstructured) *ResourcePlan { @@ -154,6 +194,5 @@ func NewResourcePlan(desired *unstructured.Unstructured) *ResourcePlan { Kind: desired.GetKind(), Name: desired.GetName(), Namespace: desired.GetNamespace(), - Desired: desired, } } diff --git a/internal/simulate/report.go b/internal/simulate/report.go index a47b8896..16da93ea 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -342,6 +342,11 @@ func resourcePlan(d *dt.ResourceDiff) *plan.ResourcePlan { Name: d.ResourceName, Namespace: d.Namespace, DryRun: verification(d.DryRun), + // The views the changes are diffed from. Clean is nil for an equal + // diff, so a kept-equal diff a provider plan upgrades carries + // neither: its Raw views are uncleaned, unlike every other plan's. + Before: plan.RedactSecret(d.Current.Clean), + After: plan.RedactSecret(d.Desired.Clean), } switch d.DiffType { diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index 9141a820..a91d1ccf 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -292,7 +292,7 @@ func TestFromXRReport(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { got := FromXRReport(tc.in, nil) - if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Before", "After"), cmpopts.EquateEmpty()); diff != "" { t.Errorf("%s\nFromXRReport(...): -want, +got:\n%s", tc.reason, diff) } }) @@ -476,7 +476,7 @@ func TestFromCompReport(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { got := FromCompReport(tc.in, nil) - if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + if diff := cmp.Diff(tc.want, got, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Before", "After"), cmpopts.EquateEmpty()); diff != "" { t.Errorf("%s\nFromCompReport(...): -want, +got:\n%s", tc.reason, diff) } }) @@ -581,7 +581,7 @@ func TestProviderPlanMerge(t *testing.T) { wantPlans := append(slices.Clone(tc.wantPlans), &plan.ResourcePlan{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Action: plan.ActionNoOp, Composite: &xrPlan}) gotXR := FromXRReport(&dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(xr, tc.diff)}}}, tc.plans) - if diff := cmp.Diff(wantPlans, gotXR.Plans, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + if diff := cmp.Diff(wantPlans, gotXR.Plans, cmpopts.IgnoreFields(plan.ResourcePlan{}, "Before", "After"), cmpopts.EquateEmpty()); diff != "" { t.Errorf("%s\nFromXRReport(...).Plans: -want, +got:\n%s", tc.reason, diff) } if got := gotXR.Composites[0].Status; got != tc.wantStatus { @@ -601,7 +601,7 @@ func TestProviderPlanMerge(t *testing.T) { Name: "net", ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: status, Diffs: diffs(tc.diff)}}, }}}, tc.plans) - if diff := cmp.Diff(tc.wantPlans, gotComp.Plans[1:], cmpopts.IgnoreFields(plan.ResourcePlan{}, "Desired"), cmpopts.EquateEmpty()); diff != "" { + if diff := cmp.Diff(tc.wantPlans, gotComp.Plans[1:], cmpopts.IgnoreFields(plan.ResourcePlan{}, "Before", "After"), cmpopts.EquateEmpty()); diff != "" { t.Errorf("%s\nFromCompReport(...).Plans: -want, +got:\n%s", tc.reason, diff) } if got := gotComp.Composites[0].Status; got != tc.wantStatus { @@ -610,3 +610,75 @@ func TestProviderPlanMerge(t *testing.T) { }) } } + +// TestPlanObjects covers the before and after objects each plan carries: the +// engine's Clean views, nil on the side that does not exist, Secret values +// redacted, and kept through a provider plan's merge. +func TestPlanObjects(t *testing.T) { + data := func(v string) map[string]any { return map[string]any{"data": map[string]any{"k": v}} } + cm := func(v string) *unstructured.Unstructured { return obj(cmGVK, "cm", data(v)) } + redacted := obj(secGVK, "cm", data("(sensitive value)")) + + cases := map[string]struct { + reason string + diff *dt.ResourceDiff + plans map[string]*plan.ResourcePlan + wantBefore *unstructured.Unstructured + wantAfter *unstructured.Unstructured + }{ + "Create": { + reason: "A create has no before.", + diff: rdiff(cmGVK, "cm", dt.DiffTypeAdded, nil, data("v")), + wantAfter: cm("v"), + }, + "Update": { + reason: "An update carries both Clean views.", + diff: rdiff(cmGVK, "cm", dt.DiffTypeModified, data("old"), data("new")), + wantBefore: cm("old"), + wantAfter: cm("new"), + }, + "Delete": { + reason: "A delete has no after.", + diff: rdiff(cmGVK, "cm", dt.DiffTypeRemoved, data("gone"), nil), + wantBefore: cm("gone"), + }, + "Secret": { + reason: "A Secret's data values are redacted on both sides, as its changes are.", + diff: rdiff(secGVK, "cm", dt.DiffTypeModified, data("old"), data("new")), + wantBefore: redacted, + wantAfter: redacted, + }, + "ProviderPlanned": { + reason: "A provider plan replaces the changes but keeps the engine's objects.", + diff: rdiff(cmGVK, "cm", dt.DiffTypeModified, data("old"), data("new")), + plans: map[string]*plan.ResourcePlan{rdiff(cmGVK, "cm", dt.DiffTypeModified, nil, nil).GetDiffKey(): { + Action: plan.ActionReplace, Before: cm("provider"), After: cm("provider"), + }}, + wantBefore: cm("old"), + wantAfter: cm("new"), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := FromXRReport(&dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(tc.diff)}}}, tc.plans) + if len(got.Plans) != 1 { + t.Fatalf("%s\nFromXRReport(...): want 1 plan, got %d", tc.reason, len(got.Plans)) + } + if diff := cmp.Diff(tc.wantBefore, got.Plans[0].Before); diff != "" { + t.Errorf("%s\nFromXRReport(...).Before: -want, +got:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.wantAfter, got.Plans[0].After); diff != "" { + t.Errorf("%s\nFromXRReport(...).After: -want, +got:\n%s", tc.reason, diff) + } + }) + } + + t.Run("SecretSourceUntouched", func(t *testing.T) { + d := rdiff(secGVK, "cm", dt.DiffTypeAdded, nil, data("v")) + FromXRReport(&dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(d)}}}, nil) + if diff := cmp.Diff(obj(secGVK, "cm", data("v")), d.Desired.Clean); diff != "" { + t.Errorf("FromXRReport(...): redaction must not modify the engine's view: -want, +got:\n%s", diff) + } + }) +} diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 7b60bd75..5f7b1322 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/plan" ) @@ -20,7 +22,8 @@ import ( // // - a change is a ResourcePlan, its type the plan's Action (create/update/delete); // - a field value or old/new pair is read from the plan's leaf FieldChanges, a non-leaf path being -// re-assembled from the leaves under it; +// re-assembled from the leaves under it, or, for a pair whose sides are equal, from the plan's +// before and after objects; // - an xrs[] or impactAnalysis[] entry is a Composites entry, its changes the plans grouped under it; // - a composition is a Compositions entry plus its own (ungrouped) plan; // - an error's resourceID ("Kind/name") is the Kind and Name of its Errors[].resource. @@ -449,15 +452,19 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri } for path, want := range changes { - // FieldChanges are leaf changes only, so a field rendered identically on both sides has no - // plan.Result home, and the plan's object (Desired) is not serialized: all that can be asserted - // is that nothing at or under it changed, not its value. ponytail: reduced coverage, see - // revisit.md R14; restore the value check once plan.Result carries the object. + // FieldChanges are leaf changes only, so a field rendered identically on both sides is read from + // the plan's objects instead, and must have no change at or under it. if want[0] != nil && reflect.DeepEqual(want[0], want[1]) { if c := changesUnder(t, p.Changes, path); len(c) > 0 { t.Errorf("%s: field %s: expected unchanged (%v), got changes %+v", id, path, want[0], c) } + for i, u := range []*unstructured.Unstructured{p.Before, p.After} { + if got, ok := objectValueAt(t, u, path); !ok || !reflect.DeepEqual(got, want[0]) { + t.Errorf("%s: field %s in %s: expected %v, got %v (present: %t)", id, path, []string{"before", "after"}[i], want[0], got, ok) + } + } + continue } @@ -896,6 +903,41 @@ func changesUnder(t *testing.T, changes []plan.FieldChange, path string) []plan. return out } +// objectValueAt returns the value at path in u, and whether it is there at all. +func objectValueAt(t *testing.T, u *unstructured.Unstructured, path string) (any, bool) { + t.Helper() + + if u == nil { + return nil, false + } + + var node any = u.Object + + for _, tok := range mustParsePath(t, path) { + if tok.isIndex { + l, ok := node.([]any) + if !ok || tok.index >= len(l) { + return nil, false + } + + node = l[tok.index] + + continue + } + + m, ok := node.(map[string]any) + if !ok { + return nil, false + } + + if node, ok = m[tok.key]; !ok { + return nil, false + } + } + + return node, true +} + // valueAt returns the value at path on one side of a plan's changes: the planned side, or else the // actual. A leaf is its change's value; a non-leaf is re-assembled from the changed leaves under it. // Nil means no change sets anything at or under path on that side. diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 8d9d75f6..92c08bf6 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -5,6 +5,8 @@ import ( "reflect" "testing" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/plan" ) @@ -41,12 +43,22 @@ var ( xrC = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "broken-xr", Namespace: "default"} ) +// bucket is bucket-a's object in region, with an unchanged tier. +func bucket(region string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", "kind": "Bucket", + "metadata": map[string]any{"name": "bucket-a", "namespace": "default"}, + "spec": map[string]any{"region": region, "tier": "premium"}, + }} +} + // xrResult is an xr run: one changed XR (a modified Bucket, a created ConfigMap and a deleted Secret), // one unchanged XR, and one errored XR. func xrResult() plan.Result { plans := []*plan.ResourcePlan{ { APIVersion: "example.org/v1", Kind: "Bucket", Name: "bucket-a", Namespace: "default", Action: plan.ActionUpdate, Composite: &xrA, + Before: bucket("us-east-1"), After: bucket("us-west-2"), Changes: []plan.FieldChange{ {Path: "spec.region", Actual: val("us-east-1"), Planned: val("us-west-2")}, {Path: `metadata.annotations["example.org/source"]`, Actual: val("old"), Planned: val("new")}, @@ -141,8 +153,17 @@ func TestAssertStructuredDiff(t *testing.T) { "NamePattern": {want: withoutConfigMap().WithAddedResource("ConfigMap", "", "default").WithNamePattern(`^cm-[a-z0-9]{5}$`)}, "BadNamePattern": {want: withoutConfigMap().WithAddedResource("ConfigMap", "", "default").WithNamePattern(`^other-`), wantFail: true}, // A field rendered identically on both sides has no leaf change: asserting it unchanged passes - // when nothing at or under it changed, and fails when something did. + // when the plan's before and after objects both hold the value and nothing at or under it + // changed, and fails otherwise. "UnchangedField": {want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.tier", "premium", "premium")}, + "UnchangedWrongValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.tier", "basic", "basic"), + wantFail: true, + }, + "UnchangedMissing": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.size", "large", "large"), + wantFail: true, + }, "ChangedNotSteady": { want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "us-east-1", "us-east-1"), wantFail: true, From 5f4887e2eabeffe76a59616f8666b5892f1efce7 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 14:44:02 -0400 Subject: [PATCH 065/126] fix(plan): clean before and after in resource simulate Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/compare/compare.go | 7 +++++ internal/plan/fallback.go | 2 +- internal/plan/fallback_test.go | 52 ++++++++++++++++++++++++++++---- internal/plan/plan.go | 2 +- internal/plan/plan_test.go | 14 ++++++--- internal/plan/types.go | 14 +++++++++ 6 files changed, 78 insertions(+), 13 deletions(-) diff --git a/internal/diff/compare/compare.go b/internal/diff/compare/compare.go index a2ddce51..732633ee 100644 --- a/internal/diff/compare/compare.go +++ b/internal/diff/compare/compare.go @@ -350,6 +350,13 @@ func removeNestedPath(obj map[string]any, path string) bool { return false } +// Clean returns a copy of obj with the fields a rendered diff omits removed: status, server-managed +// metadata, a synthetic generated name, and the display-only and ignored paths in options. obj is not +// modified. +func Clean(obj *un.Unstructured, logger logging.Logger, options Options) *un.Unstructured { + return cleanupForDiff(obj.DeepCopy(), logger, options) +} + // cleanupForDiff removes fields that shouldn't be included in the diff. When options.ForVerdict is // set the display-only paths are left in the object, because the caller is asking whether the objects // differ rather than rendering them; see Options.ForVerdict. diff --git a/internal/plan/fallback.go b/internal/plan/fallback.go index 89981e25..550b68e7 100644 --- a/internal/plan/fallback.go +++ b/internal/plan/fallback.go @@ -41,7 +41,7 @@ func Fallback(desired, live *unstructured.Unstructured, reason string) *Resource p := NewResourcePlan(desired) p.Approximate = true p.FallbackReason = reason - p.Before, p.After = RedactSecret(live), RedactSecret(desired) + p.Before, p.After = cleanView(live), cleanView(desired) desiredSpec, _, _ := unstructured.NestedMap(desired.Object, "spec") diff --git a/internal/plan/fallback_test.go b/internal/plan/fallback_test.go index c288c187..840b1b26 100644 --- a/internal/plan/fallback_test.go +++ b/internal/plan/fallback_test.go @@ -308,17 +308,57 @@ func TestFieldChanges(t *testing.T) { } } +// served returns spec as a resource carrying the status and server-managed +// metadata a live object has, alongside the identity a plan keeps. +func served(spec map[string]any) *unstructured.Unstructured { + u := mr(spec) + u.Object["metadata"] = map[string]any{ + "name": "my-vpc", + "namespace": "default", + "labels": map[string]any{"team": "net"}, + "annotations": map[string]any{"note": "kept"}, + "uid": "1234", + "resourceVersion": "42", + "generation": int64(3), + "creationTimestamp": "2026-01-01T00:00:00Z", + "managedFields": []any{map[string]any{"manager": "kubectl"}}, + "selfLink": "/apis/x", + "ownerReferences": []any{map[string]any{"name": "owner"}}, + } + u.Object["status"] = map[string]any{"atProvider": map[string]any{"id": "vpc-1"}} + return u +} + +// cleaned is what a plan should carry for a served object: spec and identity +// metadata only. +func cleaned(u *unstructured.Unstructured) *unstructured.Unstructured { + out := u.DeepCopy() + delete(out.Object, "status") + md, _ := out.Object["metadata"].(map[string]any) + for _, f := range []string{"uid", "resourceVersion", "generation", "creationTimestamp", "managedFields", "selfLink", "ownerReferences"} { + delete(md, f) + } + return out +} + func TestFallbackObjects(t *testing.T) { - desired := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}) - live := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}) + desired := served(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}) + live := served(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}) + desiredIn, liveIn := desired.DeepCopy(), live.DeepCopy() t.Run("Update", func(t *testing.T) { got := Fallback(desired, live, "test") - if diff := cmp.Diff(live, got.Before); diff != "" { - t.Errorf("Fallback(...).Before: -want live, +got:\n%s", diff) + if diff := cmp.Diff(cleaned(live), got.Before); diff != "" { + t.Errorf("Fallback(...).Before: -want cleaned live, +got:\n%s", diff) + } + if diff := cmp.Diff(cleaned(desired), got.After); diff != "" { + t.Errorf("Fallback(...).After: -want cleaned desired, +got:\n%s", diff) + } + if diff := cmp.Diff(liveIn, live); diff != "" { + t.Errorf("Fallback(...) mutated live: -want, +got:\n%s", diff) } - if diff := cmp.Diff(desired, got.After); diff != "" { - t.Errorf("Fallback(...).After: -want desired, +got:\n%s", diff) + if diff := cmp.Diff(desiredIn, desired); diff != "" { + t.Errorf("Fallback(...) mutated desired: -want, +got:\n%s", diff) } }) diff --git a/internal/plan/plan.go b/internal/plan/plan.go index fa65a350..10cd922b 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -58,6 +58,6 @@ func Plan(ctx context.Context, c diffv1alpha1.PlanServiceClient, desired, actual if err != nil { return nil, err } - p.Before, p.After = RedactSecret(actual), RedactSecret(desired) + p.Before, p.After = cleanView(actual), cleanView(desired) return p, nil } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index bb22c11b..9344ad31 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -21,6 +21,7 @@ import ( "testing" diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "github.com/google/go-cmp/cmp" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -41,8 +42,8 @@ func (f *fakePlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ } func TestPlan(t *testing.T) { - desired := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}) - actual := mr(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}) + desired := served(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.1.0.0/16"}}) + actual := served(map[string]any{"forProvider": map[string]any{"cidrBlock": "10.0.0.0/16"}}) secret, err := structpb.NewStruct(map[string]any{ "apiVersion": "v1", "kind": "Secret", @@ -76,8 +77,11 @@ func TestPlan(t *testing.T) { if c.got.GetActualResource() == nil { t.Errorf("Plan(...): actual resource not carried in request") } - if got.Before != actual || got.After != desired { - t.Errorf("Plan(...): want the plan to carry actual as before and desired as after") + if diff := cmp.Diff(cleaned(actual), got.Before); diff != "" { + t.Errorf("Plan(...).Before: -want cleaned actual, +got:\n%s", diff) + } + if diff := cmp.Diff(cleaned(desired), got.After); diff != "" { + t.Errorf("Plan(...).After: -want cleaned desired, +got:\n%s", diff) } if len(c.got.GetKubernetesObjectStore()) != 1 { t.Errorf("Plan(...): want 1 object store entry, got %d", len(c.got.GetKubernetesObjectStore())) @@ -97,7 +101,7 @@ func TestPlan(t *testing.T) { if c.got.GetActualResource() != nil { t.Errorf("Plan(...): actual resource should be unset for a create") } - if got.Before != nil || got.After != desired { + if got.Before != nil || cmp.Diff(cleaned(desired), got.After) != "" { t.Errorf("Plan(...): want a create to carry no before and desired as after") } }) diff --git a/internal/plan/types.go b/internal/plan/types.go index 3997823e..e1cc71be 100644 --- a/internal/plan/types.go +++ b/internal/plan/types.go @@ -22,6 +22,10 @@ package plan import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/diff/compare" ) // Action is what applying a resource would do. @@ -154,6 +158,16 @@ func (p *ResourcePlan) ResourceRef() ResourceRef { return ResourceRef{APIVersion: p.APIVersion, Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} } +// cleanView returns the redacted, cleaned copy of u that a plan carries as +// before or after: the same view project simulate shows, via compare.Clean. +// u is not modified; nil stays nil. +func cleanView(u *unstructured.Unstructured) *unstructured.Unstructured { + if u == nil { + return nil + } + return RedactSecret(compare.Clean(u, logging.NewNopLogger(), compare.Options{})) +} + // RedactSecret returns u with each data and stringData value replaced by the // sensitive-value marker when u is a core Secret, as a copy; any other object // is returned as is. Keys are kept, so a consumer still sees which keys exist. From 903db0e6256cd80740d22d2c112308ba7ce71089 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 18:00:39 -0400 Subject: [PATCH 066/126] feat(diff): opt-in per-XR timeout and after-XR hook WithXRTimeout bounds each composite resource's diff, one PerformDiff input or one affected composite of DiffComposition, with its own deadline. A composite that runs out fails alone, as a failed render does, and the next one still renders. WithAfterXRDiff runs a hook on each composite's diffs under that deadline; its error fails the composite the same way. Both are off by default. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/comp_processor.go | 5 +- .../diff/diffprocessor/comp_processor_test.go | 65 +++++++++++++++++ internal/diff/diffprocessor/diff_processor.go | 5 +- .../diff/diffprocessor/diff_processor_test.go | 41 ++++++++++- .../diff/diffprocessor/processor_config.go | 69 +++++++++++++++++++ .../diffprocessor/processor_config_test.go | 17 +++++ internal/diff/diffprocessor/render_engine.go | 3 + 7 files changed, 202 insertions(+), 3 deletions(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 1658ab8c..7e5a4be9 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -636,7 +636,10 @@ func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un for _, xr := range xrs { resourceID := dt.MakeDiffKeyFromResource(xr) - diffs, err := p.xrProc.DiffSingleResource(ctx, xr, compositionProvider) + xrCtx, cancel := p.config.xrContext(ctx) + diffs, err := p.xrProc.DiffSingleResource(xrCtx, xr, compositionProvider) + err = p.config.afterXR(ctx, xrCtx, diffs, err) + cancel() if err != nil { p.config.Logger.Debug("Failed to process resource", "resource", resourceID, "error", err) diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index ec791aea..0a3626f7 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -25,6 +25,7 @@ import ( "slices" "strings" "testing" + "time" gcmp "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" @@ -1273,6 +1274,70 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_RevisionName(t *testing.T) { } } +// TestDefaultCompDiffProcessor_collectXRDiffs_PerXR covers XRTimeout and AfterXRDiff in comp: each +// affected composite gets its own deadline and fails alone, with no diffs, and the hook sees each +// successful composite's diffs. +func TestDefaultCompDiffProcessor_collectXRDiffs_PerXR(t *testing.T) { + slow := tu.NewResource("example.org/v1", "XResource", "slow").Build() + fast := tu.NewResource("example.org/v1", "XResource", "fast").Build() + failing := tu.NewResource("example.org/v1", "XResource", "failing").Build() + diff := &dt.ResourceDiff{ResourceName: "cm", DiffType: dt.DiffTypeAdded} + + var hooked []string + processor := &DefaultCompDiffProcessor{ + xrProc: &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(ctx context.Context, xr *un.Unstructured, _ types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + if xr.GetName() == "slow" { + <-ctx.Done() + return nil, ctx.Err() + } + return map[string]*dt.ResourceDiff{xr.GetName(): diff}, nil + }, + }, + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + XRTimeout: 50 * time.Millisecond, + AfterXRDiff: func(_ context.Context, diffs map[string]*dt.ResourceDiff) error { + for name := range diffs { + hooked = append(hooked, name) + if name == "failing" { + return errors.New("plan failed") + } + } + return nil + }, + }, + } + + comp := tu.NewComposition("comp").WithCompositeTypeRef("example.org/v1", "XResource").WithPipelineMode().BuildAsUnstructured() + results := processor.collectXRDiffs(t.Context(), []*un.Unstructured{slow, fast, failing}, comp, "", nil) + + got := map[string]string{} + for _, xr := range []*un.Unstructured{slow, fast, failing} { + r := results[dt.MakeDiffKeyFromResource(xr)] + if r.Error != nil { + if len(r.Diffs) != 0 { + t.Errorf("collectXRDiffs(): failed composite %s carries diffs", xr.GetName()) + } + got[xr.GetName()] = r.Error.Error() + continue + } + got[xr.GetName()] = fmt.Sprintf("%d diffs", len(r.Diffs)) + } + + want := map[string]string{ + "slow": "unable to process resource example.org/v1/XResource//slow: timed out after 50ms: context deadline exceeded", + "fast": "1 diffs", + "failing": "unable to process resource example.org/v1/XResource//failing: plan failed", + } + if diff := gcmp.Diff(want, got); diff != "" { + t.Errorf("collectXRDiffs(): -want, +got:\n%s", diff) + } + if diff := gcmp.Diff([]string{"fast", "failing"}, hooked); diff != "" { + t.Errorf("collectXRDiffs(): AfterXRDiff calls -want, +got:\n%s", diff) + } +} + // TestPredictedRevisionLabels verifies the label set used to evaluate an XR's // compositionRevisionSelector mirrors what a real CompositionRevision would carry: the composition's // own labels plus crossplane.io/composition-name and crossplane.io/composition-hash. The source diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 1385904f..9f0f14f4 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -286,7 +286,10 @@ func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un. } if in.Err == nil { - diffs, rendered, err := p.diffSingleResourceInternal(ctx, res, compositionProvider, nil, nil, true, 0) + xrCtx, cancel := p.config.xrContext(ctx) + diffs, rendered, err := p.diffSingleResourceInternal(xrCtx, res, compositionProvider, nil, nil, true, 0) + err = p.config.afterXR(ctx, xrCtx, diffs, err) + cancel() validator.RecordRender(i, rendered.Keys(), err) if err == nil { diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index 18bc00ee..c6d60960 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -851,8 +851,41 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { validator *validation // failRender names an input XR whose render fails. failRender string + // slowRender names an input XR whose render runs until its context ends. + slowRender string + xrTimeout time.Duration + afterXR func(context.Context, map[string]*dt.ResourceDiff) error want got }{ + // An XR that runs out of its own XRTimeout fails alone, as a failed render does; the next + // one still renders. + "XRTimeoutFailsOnlyThatXR": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + slowRender: "my-xr-1", + xrTimeout: 50 * time.Millisecond, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + GroupErrs: map[string]string{"my-xr-1": "timed out after 50ms: cannot calculate diffs for composed resources: context deadline exceeded"}, + GlobalErrs: []string{"timed out after 50ms: cannot calculate diffs for composed resources: context deadline exceeded"}, + Err: "unable to process resource XR1/my-xr-1: timed out after 50ms: cannot calculate diffs for composed resources: context deadline exceeded", + }, + }, + // An AfterXRDiff error fails its XR with none of its diffs, as a failed render does. + "AfterXRDiffErrorFailsTheXR": { + resources: []*un.Unstructured{xr("my-xr-1")}, + afterXR: func(_ context.Context, diffs map[string]*dt.ResourceDiff) error { + if diffs[sharedKey] == nil { + return errors.New("hook got no diffs") + } + return errors.New("plan failed") + }, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1")}, + GroupErrs: map[string]string{"my-xr-1": "plan failed"}, + GlobalErrs: []string{"plan failed"}, + Err: "unable to process resource XR1/my-xr-1: plan failed", + }, + }, // An input ToRender already rejected is not rendered; its verdict is a group error, in errors[], // and on the returned error. "InputRejectedBeforeRenderingIsNotRendered": { @@ -975,10 +1008,14 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { }), WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter) DiffCalculator { return &tu.MockDiffCalculator{ - CalculateNonRemovalDiffsFn: func(_ context.Context, rendered *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + CalculateNonRemovalDiffsFn: func(ctx context.Context, rendered *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { if rendered.GetName() == tt.failRender { return nil, types.RenderedResources{}, errors.New("render failed") } + if rendered.GetName() == tt.slowRender { + <-ctx.Done() + return nil, types.RenderedResources{}, ctx.Err() + } diff := bucket("parent", "same", rendered.GetName()) @@ -991,6 +1028,8 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { }), ) + opts = append(opts, WithXRTimeout(tt.xrTimeout), WithAfterXRDiff(tt.afterXR)) + if v := tt.validator; v != nil { opts = append(opts, WithInputValidatorFactory(func(dt.Advisories, []*un.Unstructured) InputValidator { return &tu.MockInputValidator{ diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 295a605e..111e9040 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -1,9 +1,13 @@ package diffprocessor import ( + "context" + "time" + corev1 "k8s.io/api/core/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" @@ -148,6 +152,17 @@ type ProcessorConfig struct { // HasChanges and renderer skips equal diffs, so this only adds diffs a caller can refine further. KeepEqualDiffs func(*un.Unstructured) bool + // XRTimeout, when positive, bounds each composite resource's diff, one input of PerformDiff or one + // affected composite of DiffComposition: its render and AfterXRDiff. A composite that runs out + // fails alone, as a failed render does, and the next one still renders. Zero, the default, adds + // no deadline to the caller's. + XRTimeout time.Duration + + // AfterXRDiff, when set, runs after each composite resource's diff succeeds, under its XRTimeout + // deadline, with the composite's diffs. An error fails the composite as a failed render does: none + // of its diffs are reported. Nil, the default, runs nothing. + AfterXRDiff func(ctx context.Context, diffs map[string]*dt.ResourceDiff) error + // FunctionCredentials holds Secret credentials to pass to Functions during rendering FunctionCredentials []corev1.Secret @@ -302,6 +317,20 @@ func WithIgnorePaths(ignorePaths []string) ProcessorOption { } } +// WithXRTimeout bounds each composite resource's diff (see ProcessorConfig.XRTimeout). +func WithXRTimeout(d time.Duration) ProcessorOption { + return func(config *ProcessorConfig) { + config.XRTimeout = d + } +} + +// WithAfterXRDiff runs fn after each composite resource's diff (see ProcessorConfig.AfterXRDiff). +func WithAfterXRDiff(fn func(ctx context.Context, diffs map[string]*dt.ResourceDiff) error) ProcessorOption { + return func(config *ProcessorConfig) { + config.AfterXRDiff = fn + } +} + // WithKeepEqualDiffs keeps the equal diffs of the composed resources keep reports true for (see // ProcessorConfig.KeepEqualDiffs). func WithKeepEqualDiffs(keep func(*un.Unstructured) bool) ProcessorOption { @@ -425,6 +454,46 @@ func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) } } +// xrContext returns the context one composite resource's diff runs under: ctx bounded by XRTimeout +// when it is set. +func (c *ProcessorConfig) xrContext(ctx context.Context) (context.Context, context.CancelFunc) { + if c.XRTimeout <= 0 { + return ctx, func() {} + } + + return context.WithTimeout(context.WithValue(ctx, runContextKey{}, ctx), c.XRTimeout) +} + +// runContextKey keys the context a composite resource's XRTimeout deadline was added to. +type runContextKey struct{} + +// WithoutXRDeadline returns the context ctx's composite resource deadline was added to, or ctx when +// it has none. Starting function runtimes, which pulls their images, runs under it: that work is +// shared by the whole run, so it must not count against, or fail, the composite that happens to +// need it first. An AfterXRDiff hook uses it the same way for run-wide work of its own. +func WithoutXRDeadline(ctx context.Context) context.Context { + if run, ok := ctx.Value(runContextKey{}).(context.Context); ok { + return run + } + + return ctx +} + +// afterXR finishes one composite resource's diff, which returned diffs and err under xrCtx, a child +// of ctx: it runs AfterXRDiff on a success, and says so when the composite ran out of XRTimeout. +func (c *ProcessorConfig) afterXR(ctx, xrCtx context.Context, diffs map[string]*dt.ResourceDiff, err error) error { + if err == nil && c.AfterXRDiff != nil { + err = c.AfterXRDiff(xrCtx, diffs) + } + + // Only the composite's own deadline is named; the caller's ending reads as it always has. + if err != nil && ctx.Err() == nil && errors.Is(xrCtx.Err(), context.DeadlineExceeded) { + return errors.Wrapf(err, "timed out after %s", c.XRTimeout) + } + + return err +} + // GetCompareOptions returns the compare.Options based on the ProcessorConfig. func (c *ProcessorConfig) GetCompareOptions() compare.Options { return compare.Options{IgnorePaths: c.IgnorePaths, KeepEqualDiffs: c.KeepEqualDiffs} diff --git a/internal/diff/diffprocessor/processor_config_test.go b/internal/diff/diffprocessor/processor_config_test.go index 8d455725..2058d688 100644 --- a/internal/diff/diffprocessor/processor_config_test.go +++ b/internal/diff/diffprocessor/processor_config_test.go @@ -1,7 +1,9 @@ package diffprocessor import ( + "context" "testing" + "time" gcmp "github.com/google/go-cmp/cmp" @@ -180,3 +182,18 @@ func TestWithMaxRecvMessageSize(t *testing.T) { t.Errorf("Expected config.MaxRecvMessageSize to be 16, got: %d", config.MaxRecvMessageSize) } } + +func TestWithoutXRDeadline(t *testing.T) { + cfg := &ProcessorConfig{XRTimeout: time.Nanosecond} + run := context.Background() + xrCtx, cancel := cfg.xrContext(run) + defer cancel() + <-xrCtx.Done() + + if got := WithoutXRDeadline(xrCtx); got.Err() != nil { + t.Errorf("WithoutXRDeadline(expired XR context).Err() = %v, want the run context, which has not ended", got.Err()) + } + if got := WithoutXRDeadline(run); got != run { + t.Errorf("WithoutXRDeadline(run) did not return run unchanged") + } +} diff --git a/internal/diff/diffprocessor/render_engine.go b/internal/diff/diffprocessor/render_engine.go index 44c35eda..45160daf 100644 --- a/internal/diff/diffprocessor/render_engine.go +++ b/internal/diff/diffprocessor/render_engine.go @@ -291,6 +291,9 @@ func (e *EngineRenderFn) startFunctions(ctx context.Context, log logging.Logger, e.mu.Lock() defer e.mu.Unlock() + // Starting functions is shared by the run, so a composite's XRTimeout does not bound it. + ctx = WithoutXRDeadline(ctx) + if e.addrs == nil { e.addrs = make(map[string]string) } From 04483f9335e2706bb4fa6adab74d4775ccf75a19 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 20:42:26 -0400 Subject: [PATCH 067/126] feat(simulate): plan each composite's resources as the engine renders it Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/xrplan.go | 99 +++++++++++++++++++ internal/simulate/xrplan_test.go | 160 +++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 internal/simulate/xrplan.go create mode 100644 internal/simulate/xrplan_test.go diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go new file mode 100644 index 00000000..854f371b --- /dev/null +++ b/internal/simulate/xrplan.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "context" + "fmt" + "maps" + "sort" + "strings" + "sync" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// EnsureServers starts diff servers for the given provider images if they +// are not running yet. It is called outside the XR's deadline. +type EnsureServers func(ctx context.Context, resources []*unstructured.Unstructured) error + +// XRPlanner plans each composite resource's composed resources with their +// 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. +type XRPlanner struct { + planner *Planner + keep func(*unstructured.Unstructured) bool + ensure EnsureServers + + mu sync.Mutex + plans map[string]*plan.ResourcePlan +} + +// NewXRPlanner returns an XRPlanner that plans with p. keep and ensure may be nil. +func NewXRPlanner(p *Planner, keep func(*unstructured.Unstructured) bool, ensure EnsureServers) *XRPlanner { + return &XRPlanner{planner: p, keep: keep, ensure: ensure, plans: map[string]*plan.ResourcePlan{}} +} + +// AfterXRDiff is a diffprocessor.WithAfterXRDiff hook. It fails the XR if any +// of its resources' plans errors, and merges none of them then. +func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.ResourceDiff) error { + if x.ensure != nil { + var resources []*unstructured.Unstructured + for _, d := range diffs { + if d.DiffType != dt.DiffTypeRemoved && d.Desired.Raw != nil { + resources = append(resources, d.Desired.Raw) + } + } + // Server start and image pulls are run-wide work, not the XR's. + if err := x.ensure(diffprocessor.WithoutXRDeadline(ctx), resources); err != nil { + return errors.Wrap(err, "cannot start provider diff servers") + } + } + + plans := PlanDiffs(ctx, x.planner, x.keep, diffs) + + var failed []string + for _, p := range plans { + if p.Error != "" { + failed = append(failed, fmt.Sprintf("%s/%s: %s", p.Kind, p.Name, p.Error)) + } + } + if len(failed) > 0 { + sort.Strings(failed) + return errors.Errorf("cannot plan %d resources with their diff servers: %s", len(failed), strings.Join(failed, "; ")) + } + + x.mu.Lock() + defer x.mu.Unlock() + maps.Copy(x.plans, plans) + + return nil +} + +// Plans returns the plans collected so far, never nil. +func (x *XRPlanner) Plans() map[string]*plan.ResourcePlan { + x.mu.Lock() + defer x.mu.Unlock() + + return x.plans +} diff --git a/internal/simulate/xrplan_test.go b/internal/simulate/xrplan_test.go new file mode 100644 index 00000000..5a3fbff3 --- /dev/null +++ b/internal/simulate/xrplan_test.go @@ -0,0 +1,160 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" +) + +// funcPlanClient answers Plan per resource name. +type funcPlanClient struct { + fakePlanClient + fail map[string]bool +} + +func (f *funcPlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + if f.fail[in.GetDesiredResource().GetFields()["metadata"].GetStructValue().GetFields()["name"].GetStringValue()] { + return nil, status.Error(codes.Internal, "boom") + } + return f.rsp, nil +} + +func added(r *unstructured.Unstructured) *dt.ResourceDiff { + d := &dt.ResourceDiff{Gvk: vpcGVK, DiffType: dt.DiffTypeAdded} + d.Desired.Raw = r + return d +} + +func TestXRPlanner(t *testing.T) { + s := runtime.NewScheme() + s.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) + rm.Add(vpcGVK, meta.RESTScopeRoot) + + pc := &funcPlanClient{ + fakePlanClient: fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}}, + fail: map[string]bool{"bad-a": true, "bad-b": true}, + } + planner := NewPlanner(fake.NewClientBuilder().WithScheme(s).Build(), rm, "default", &fakeRouter{client: pc}, time.Second) + + removed := added(vpc("gone", "10.0.0.0/16")) + removed.DiffType = dt.DiffTypeRemoved + nilDesired := &dt.ResourceDiff{Gvk: vpcGVK, DiffType: dt.DiffTypeAdded} + + cases := map[string]struct { + diffs map[string]*dt.ResourceDiff + ensureErr error + wantErr string + wantPlans []string + wantEnsured []string + }{ + "AllOK": { + diffs: map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16")), "b": added(vpc("b", "10.0.0.0/16"))}, + wantPlans: []string{"a", "b"}, + wantEnsured: []string{"a", "b"}, + }, + "OnePlanErrors": { + diffs: map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16")), "bad-a": added(vpc("bad-a", "10.0.0.0/16"))}, + wantErr: "cannot plan 1 resources with their diff servers: VPC/bad-a: ", + wantEnsured: []string{"a", "bad-a"}, + }, + "ErrorsAreSorted": { + diffs: map[string]*dt.ResourceDiff{"bad-b": added(vpc("bad-b", "10.0.0.0/16")), "bad-a": added(vpc("bad-a", "10.0.0.0/16"))}, + wantErr: "cannot plan 2 resources", + wantEnsured: []string{"bad-a", "bad-b"}, + }, + "EnsureError": { + diffs: map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16"))}, + ensureErr: errors.New("pull failed"), + wantErr: "cannot start provider diff servers: pull failed", + wantEnsured: []string{"a"}, + }, + "SkipsRemovedAndNilDesired": { + diffs: map[string]*dt.ResourceDiff{"r": removed, "n": nilDesired, "a": added(vpc("a", "10.0.0.0/16"))}, + wantPlans: []string{"a"}, + wantEnsured: []string{"a"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + var ensured []string + var ensureCtx context.Context + ensure := func(ctx context.Context, rs []*unstructured.Unstructured) error { + ensureCtx := ctx + for _, r := range rs { + ensured = append(ensured, r.GetName()) + } + return tc.ensureErr + } + // An earlier XR's plans stay whatever this XR does. + x := NewXRPlanner(planner, nil, ensure) + x.plans["earlier"] = &plan.ResourcePlan{Name: "earlier"} + + err := x.AfterXRDiff(context.Background(), tc.diffs) + + if tc.wantErr == "" && err != nil { + t.Fatalf("AfterXRDiff(...): unexpected error %v", err) + } + if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { + t.Fatalf("AfterXRDiff(...): want error containing %q, got %v", tc.wantErr, err) + } + if name == "ErrorsAreSorted" && !strings.Contains(err.Error(), "VPC/bad-a: ") { + t.Errorf("AfterXRDiff(...): want bad-a before bad-b, got %v", err) + } + if len(ensured) != len(tc.wantEnsured) { + t.Errorf("ensure got %v, want %v", ensured, tc.wantEnsured) + } + if ensureCtx.Err() != nil { + t.Errorf("ensure ctx has ended: %v", ensureCtx.Err()) + } + got := x.Plans() + if len(got) != len(tc.wantPlans)+1 || got["earlier"] == nil { + t.Errorf("Plans() has %d entries, want %v plus the earlier XR's", len(got), tc.wantPlans) + } + for _, k := range tc.wantPlans { + if got[k] == nil { + t.Errorf("Plans()[%q] missing", k) + } + } + }) + } + + t.Run("EmptyPlansNonNil", func(t *testing.T) { + if NewXRPlanner(planner, nil, nil).Plans() == nil { + t.Errorf("Plans() = nil, want an empty non-nil map") + } + }) +} From 350c715bcd0aa378b67d499ac89b422ece18f603 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 20:47:01 -0400 Subject: [PATCH 068/126] feat(simulate): start provider diff servers incrementally Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/servers.go | 100 +++++++++++++++++++++++------- internal/simulate/servers_test.go | 44 +++++++++++++ 2 files changed, 123 insertions(+), 21 deletions(-) diff --git a/internal/simulate/servers.go b/internal/simulate/servers.go index 5f2ecd6b..0fb5d8bd 100644 --- a/internal/simulate/servers.go +++ b/internal/simulate/servers.go @@ -24,11 +24,14 @@ import ( "os" "path/filepath" "strings" + "sync" + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" "github.com/spf13/afero" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/kubernetes/scheme" @@ -91,37 +94,92 @@ const ( defaultDevelopmentTarget = "localhost:9099" ) +// ServerSet runs provider diff servers for a simulation, starting each +// provider's server the first time it is asked for. It routes resources to +// the servers it runs. +type ServerSet struct { + sp terminal.SpinnerPrinter + stderr io.Writer + mgr *diffserver.Manager + handled map[string]bool + mu sync.Mutex +} -// StartDiffServers starts a diff server for each input and returns the -// manager to plan through and a func that stops the servers. It prints -// noProviders to stderr when there are no inputs, and the servers it skipped. -func StartDiffServers(ctx context.Context, sp terminal.SpinnerPrinter, log logging.Logger, stderr io.Writer, - pkgs runtimexpkg.Client, inputs []diffserver.Input, session, noProviders string, -) (*diffserver.Manager, func(), error) { - if len(inputs) == 0 { - _, _ = fmt.Fprintln(stderr, noProviders) +// NewServerSet returns a ServerSet that starts no servers until Ensure. +func NewServerSet(sp terminal.SpinnerPrinter, log logging.Logger, stderr io.Writer, pkgs runtimexpkg.Client, session string) *ServerSet { + return &ServerSet{ + sp: sp, + stderr: stderr, + mgr: diffserver.NewManager(pkgs, log, diffserver.WithSessionID(session)), + handled: map[string]bool{}, + } +} + +// Ensure starts a server for each input whose Image has not been asked for +// before, and prints the ones it skipped. Inputs it has already handled, +// whether they started or were skipped, are not retried. +func (s *ServerSet) Ensure(ctx context.Context, inputs []diffserver.Input) error { + s.mu.Lock() + defer s.mu.Unlock() + + var fresh []diffserver.Input + for _, in := range inputs { + if s.handled[in.Image] { + continue + } + s.handled[in.Image] = true + fresh = append(fresh, in) + } + if len(fresh) == 0 { + return nil } - mgr := diffserver.NewManager(pkgs, log, diffserver.WithSessionID(session)) var skipped []diffserver.Skipped - if err := sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { + if err := s.sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { var err error - skipped, err = mgr.Start(ctx, inputs, ch) + skipped, err = s.mgr.Start(ctx, fresh, ch) return err }); err != nil { - return nil, nil, errors.Wrap(err, "cannot start diff servers") + return errors.Wrap(err, "cannot start diff servers") } - // Cleanup runs even when ctx was cancelled by a signal, so an interrupted - // run still tears its containers down. - stop := func() { - if err := mgr.Stop(context.WithoutCancel(ctx)); err != nil { - _, _ = fmt.Fprintf(stderr, "Warning: cannot clean up diff servers: %s\n", err) - } + for _, sk := range skipped { + _, _ = fmt.Fprintf(s.stderr, "Not running a diff server for %s: %s\n", sk.Image, sk.Reason) + } + return nil +} + +// Route implements Router. +func (s *ServerSet) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + return s.mgr.Route(gk) +} + +// Ran reports whether any server is running. +func (s *ServerSet) Ran() bool { + return len(s.mgr.Servers()) > 0 +} + +// Stop stops every server; it runs even when ctx was cancelled, so an +// interrupted run still tears its containers down. +func (s *ServerSet) Stop(ctx context.Context) { + if err := s.mgr.Stop(context.WithoutCancel(ctx)); err != nil { + _, _ = fmt.Fprintf(s.stderr, "Warning: cannot clean up diff servers: %s\n", err) + } +} + +// StartDiffServers starts a diff server for each input and returns the set to +// plan through and a func that stops the servers. It prints noProviders to +// stderr when there are no inputs, and the servers it skipped. +func StartDiffServers(ctx context.Context, sp terminal.SpinnerPrinter, log logging.Logger, stderr io.Writer, + pkgs runtimexpkg.Client, inputs []diffserver.Input, session, noProviders string, +) (*ServerSet, func(), error) { + if len(inputs) == 0 { + _, _ = fmt.Fprintln(stderr, noProviders) } - for _, s := range skipped { - _, _ = fmt.Fprintf(stderr, "Not running a diff server for %s: %s\n", s.Image, s.Reason) + set := NewServerSet(sp, log, stderr, pkgs, session) + if err := set.Ensure(ctx, inputs); err != nil { + return nil, nil, err } - return mgr, stop, nil + return set, func() { set.Stop(ctx) }, nil } // ImageConfigs returns the ImageConfigs to rewrite provider image paths with: diff --git a/internal/simulate/servers_test.go b/internal/simulate/servers_test.go index 82e17a1d..7fa7f923 100644 --- a/internal/simulate/servers_test.go +++ b/internal/simulate/servers_test.go @@ -17,6 +17,9 @@ limitations under the License. package simulate import ( + "bytes" + "context" + "errors" "net/netip" "os" "path/filepath" @@ -25,7 +28,11 @@ import ( "github.com/google/go-cmp/cmp" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/cli/v2/internal/diffserver" + "github.com/crossplane/cli/v2/internal/terminal" ) func TestLoadProviderInputs(t *testing.T) { @@ -227,3 +234,40 @@ func TestNeedsDocker(t *testing.T) { }) } } + +// failingPkgs fails every Get, so each image is skipped. +type failingPkgs struct { + runtimexpkg.Client + + gets int +} + +func (f *failingPkgs) Get(context.Context, string, ...runtimexpkg.GetOption) (*runtimexpkg.Package, error) { + f.gets++ + return nil, errors.New("boom") +} + +func TestServerSetEnsure(t *testing.T) { + pkgs := &failingPkgs{} + var out bytes.Buffer + s := NewServerSet(terminal.NewNopSpinnerPrinter(), logging.NewNopLogger(), &out, pkgs, "s") + + if err := s.Ensure(context.Background(), nil); err != nil || out.Len() != 0 || pkgs.gets != 0 { + t.Fatalf("empty Ensure: err=%v out=%q gets=%d", err, out.String(), pkgs.gets) + } + in := []diffserver.Input{{Image: "xpkg.example/p:v1"}} + for range 2 { + if err := s.Ensure(context.Background(), in); err != nil { + t.Fatal(err) + } + } + if pkgs.gets != 1 { + t.Errorf("Get called %d times, want 1", pkgs.gets) + } + if got := strings.Count(out.String(), "Not running a diff server for xpkg.example/p:v1: "); got != 1 { + t.Errorf("skip message printed %d times, want 1: %q", got, out.String()) + } + if s.Ran() { + t.Error("Ran() = true, want false") + } +} From 081dec94d823ce896032abd90f3e464906ac2e47 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 20:49:19 -0400 Subject: [PATCH 069/126] feat(project): add provider plan flags to project simulate Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 17 +++++++- cmd/crossplane/project/simulate.go | 19 ++++++++- cmd/crossplane/project/simulate_test.go | 55 ++++++++++++++++++++++--- 3 files changed, 83 insertions(+), 8 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 7c8c0275..0e1264b6 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -76,8 +76,21 @@ Plan: 0 to add, 2 to change, 0 to destroy. Note: Cloud-layer plan not run (provider diff servers not yet wired). ``` -The `--timeout` flag bounds the whole simulation, not each composite resource. -It doesn't include the time spent building functions. +The `--timeout` flag, 2m by default, bounds the time to simulate one composite +resource: its render and its resources' provider plans. Function and provider +image pulls don't count. + +Provider plans are controlled by these flags: + +- `--skip-plan` doesn't run provider diff servers and shows only the + cluster-layer diff. It can't be combined with the three flags below. +- `--providers` takes a YAML file or directory of Provider manifests to run as + diff servers, instead of discovering providers from the cluster. +- `--image-configs` takes a YAML file of ImageConfig manifests that rewrite + provider image paths for local pulls. It defaults to the project's + imageConfigs when `-f` names a project file. +- `--session-id` sets the `simulate.crossplane.io/session` label on diff server + containers. It defaults to a random per-process ID. The command accepts the same diff-tuning flags as `crossplane-diff`: diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index d352910a..a50c758e 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -82,7 +82,7 @@ type simulateCmd struct { Name string `help:"Only simulate the composite resource with this name. Use with --namespace to name a namespaced resource."` IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` - Timeout time.Duration `default:"1m" help:"Max time for the simulation, not counting function builds."` + Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count."` Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` NoColor bool `help:"Disable colorized output."` @@ -97,6 +97,11 @@ type simulateCmd struct { AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` + SkipPlan bool `help:"Don't run provider diff servers; show only the cluster-layer diff."` + Providers string `help:"YAML file or directory with Provider manifests to run as diff servers, overriding discovery from the cluster. Supports per-provider runtime annotations." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` + ImageConfigs string `help:"YAML file with ImageConfig manifests that rewrite provider image paths for local pulls. Defaults to the project's imageConfigs when -f names a project file." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` + SessionID string `help:"Session ID to apply as the simulate.crossplane.io/session label on diff server containers. Defaults to a random per-process ID." placeholder:"ID"` + // CrossplaneVersion and CrossplaneImage select the crossplane render // backend, as they do for crossplane-diff. When neither is set the render // engine pulls xpkg.crossplane.io/crossplane/crossplane:stable. @@ -123,6 +128,10 @@ type SimulateHooks struct { // Terminal colorizes diffs as if stdout were a terminal, unless // --no-color is set. Terminal bool + + // Router, when set, routes provider plans through it instead of starting + // diff servers. + Router simulate.Router } // FunctionCredentials holds Secret credentials loaded from a file path. It @@ -194,6 +203,14 @@ func (c *simulateCmd) Validate() error { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } + if c.SkipPlan { + for _, f := range []struct{ name, val string }{{"providers", c.Providers}, {"image-configs", c.ImageConfigs}, {"session-id", c.SessionID}} { + if f.val != "" { + return errors.Errorf("--%s cannot be used with --skip-plan", f.name) + } + } + } + // The two flags are mutually exclusive (kong xor), so at most one of // these has anything to check. if c.CrossplaneImage != "" { diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index b201db2f..61bf72ed 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -93,7 +93,7 @@ func TestSimulateFlags(t *testing.T) { Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, - Timeout: time.Minute, + Timeout: 2 * time.Minute, Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", @@ -133,7 +133,7 @@ func TestSimulateFlags(t *testing.T) { Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, - Timeout: time.Minute, + Timeout: 2 * time.Minute, Output: "plan", NoColor: true, Compact: true, @@ -154,7 +154,7 @@ func TestSimulateFlags(t *testing.T) { Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, - Timeout: time.Minute, + Timeout: 2 * time.Minute, Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", @@ -191,12 +191,57 @@ func TestSimulateFlags(t *testing.T) { Input: []string{"a.yaml", "b,c.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, - Timeout: time.Minute, + Timeout: 2 * time.Minute, Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", }}, }, + "PlanFlags": { + reason: "The provider plan flags should land on their fields.", + args: []string{"--providers", dir, "--image-configs", creds, "--session-id", "s1"}, + want: want{cmd: &simulateCmd{ + Input: []string{"crossplane-project.yaml"}, + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: 2 * time.Minute, + Output: "plan", + MaxNestedDepth: 10, + DryRunOn: "all", + Providers: dir, + ImageConfigs: creds, + SessionID: "s1", + }}, + }, + "SkipPlan": { + reason: "--skip-plan alone should be accepted.", + args: []string{"--skip-plan"}, + want: want{cmd: &simulateCmd{ + Input: []string{"crossplane-project.yaml"}, + MaxConcurrency: 8, + MaxIterations: dp.DefaultMaxRenderIterations, + Timeout: 2 * time.Minute, + Output: "plan", + MaxNestedDepth: 10, + DryRunOn: "all", + SkipPlan: true, + }}, + }, + "SkipPlanWithProviders": { + reason: "--skip-plan conflicts with --providers.", + args: []string{"--skip-plan", "--providers", dir}, + want: want{err: true}, + }, + "SkipPlanWithImageConfigs": { + reason: "--skip-plan conflicts with --image-configs.", + args: []string{"--skip-plan", "--image-configs", creds}, + want: want{err: true}, + }, + "SkipPlanWithSessionID": { + reason: "--skip-plan conflicts with --session-id.", + args: []string{"--skip-plan", "--session-id", "s1"}, + want: want{err: true}, + }, "UnknownOutput": { reason: "Output formats other than plan, diff, json and yaml should be rejected.", args: []string{"-o", "xml"}, @@ -224,7 +269,7 @@ func TestSimulateFlags(t *testing.T) { Input: []string{"crossplane-project.yaml"}, MaxConcurrency: 8, MaxIterations: dp.DefaultMaxRenderIterations, - Timeout: time.Minute, + Timeout: 2 * time.Minute, Output: "plan", MaxNestedDepth: 10, DryRunOn: "all", From 19809b88a0b1bcced8de54ff3a476b351f469706 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 20:52:23 -0400 Subject: [PATCH 070/126] test(simulate): fix and tighten XRPlanner tests Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../diff/diffprocessor/processor_config.go | 8 ++++- internal/simulate/xrplan_test.go | 30 ++++++++++++++++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 111e9040..ee414b5c 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -461,7 +461,13 @@ func (c *ProcessorConfig) xrContext(ctx context.Context) (context.Context, conte return ctx, func() {} } - return context.WithTimeout(context.WithValue(ctx, runContextKey{}, ctx), c.XRTimeout) + return ContextWithXRDeadline(ctx, c.XRTimeout) +} + +// ContextWithXRDeadline returns the context a composite resource's diff runs under: ctx bounded by +// d, remembering ctx as the run context WithoutXRDeadline returns. +func ContextWithXRDeadline(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithValue(ctx, runContextKey{}, ctx), d) } // runContextKey keys the context a composite resource's XRTimeout deadline was added to. diff --git a/internal/simulate/xrplan_test.go b/internal/simulate/xrplan_test.go index 5a3fbff3..5541cfbd 100644 --- a/internal/simulate/xrplan_test.go +++ b/internal/simulate/xrplan_test.go @@ -33,6 +33,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "github.com/crossplane/cli/v2/internal/diff/diffprocessor" dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/plan" ) @@ -131,14 +132,17 @@ func TestXRPlanner(t *testing.T) { if tc.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tc.wantErr)) { t.Fatalf("AfterXRDiff(...): want error containing %q, got %v", tc.wantErr, err) } - if name == "ErrorsAreSorted" && !strings.Contains(err.Error(), "VPC/bad-a: ") { - t.Errorf("AfterXRDiff(...): want bad-a before bad-b, got %v", err) + if name == "ErrorsAreSorted" { + a, b := strings.Index(err.Error(), "VPC/bad-a: "), strings.Index(err.Error(), "VPC/bad-b: ") + if a < 0 || b < 0 || a >= b { + t.Errorf("AfterXRDiff(...): want bad-a before bad-b, got %v", err) + } } if len(ensured) != len(tc.wantEnsured) { t.Errorf("ensure got %v, want %v", ensured, tc.wantEnsured) } - if ensureCtx.Err() != nil { - t.Errorf("ensure ctx has ended: %v", ensureCtx.Err()) + if len(tc.wantEnsured) > 0 && (ensureCtx == nil || ensureCtx.Err() != nil) { + t.Errorf("ensure ctx missing or ended: %v", ensureCtx) } got := x.Plans() if len(got) != len(tc.wantPlans)+1 || got["earlier"] == nil { @@ -152,6 +156,24 @@ func TestXRPlanner(t *testing.T) { }) } + t.Run("EnsureRunsOutsideXRDeadline", func(t *testing.T) { + var ensureCtx context.Context + ensure := func(ctx context.Context, _ []*unstructured.Unstructured) error { + ensureCtx := ctx + return errors.New("stop before planning") + } + xrCtx, cancel := diffprocessor.ContextWithXRDeadline(context.Background(), time.Nanosecond) + defer cancel() + <-xrCtx.Done() + + x := NewXRPlanner(planner, nil, ensure) + _ = x.AfterXRDiff(xrCtx, map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16"))}) + + if ensureCtx == nil || ensureCtx.Err() != nil { + t.Errorf("ensure ctx missing or ended: %v", ensureCtx) + } + }) + t.Run("EmptyPlansNonNil", func(t *testing.T) { if NewXRPlanner(planner, nil, nil).Plans() == nil { t.Errorf("Plans() = nil, want an empty non-nil map") From b7ab0f1f77567773134ffb97f8a9e0d6278232f8 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 20:57:08 -0400 Subject: [PATCH 071/126] test(simulate): stop shadowing ensureCtx in XRPlanner tests Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/xrplan_test.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/simulate/xrplan_test.go b/internal/simulate/xrplan_test.go index 5541cfbd..309e21e9 100644 --- a/internal/simulate/xrplan_test.go +++ b/internal/simulate/xrplan_test.go @@ -112,9 +112,9 @@ func TestXRPlanner(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { var ensured []string - var ensureCtx context.Context + var ensureCtxs []context.Context ensure := func(ctx context.Context, rs []*unstructured.Unstructured) error { - ensureCtx := ctx + ensureCtxs = append(ensureCtxs, ctx) for _, r := range rs { ensured = append(ensured, r.GetName()) } @@ -141,8 +141,8 @@ func TestXRPlanner(t *testing.T) { if len(ensured) != len(tc.wantEnsured) { t.Errorf("ensure got %v, want %v", ensured, tc.wantEnsured) } - if len(tc.wantEnsured) > 0 && (ensureCtx == nil || ensureCtx.Err() != nil) { - t.Errorf("ensure ctx missing or ended: %v", ensureCtx) + if len(tc.wantEnsured) > 0 && (len(ensureCtxs) == 0 || ensureCtxs[0].Err() != nil) { + t.Errorf("ensure ctx missing or ended: %v", ensureCtxs) } got := x.Plans() if len(got) != len(tc.wantPlans)+1 || got["earlier"] == nil { @@ -157,9 +157,9 @@ func TestXRPlanner(t *testing.T) { } t.Run("EnsureRunsOutsideXRDeadline", func(t *testing.T) { - var ensureCtx context.Context + var ensureCtxs []context.Context ensure := func(ctx context.Context, _ []*unstructured.Unstructured) error { - ensureCtx := ctx + ensureCtxs = append(ensureCtxs, ctx) return errors.New("stop before planning") } xrCtx, cancel := diffprocessor.ContextWithXRDeadline(context.Background(), time.Nanosecond) @@ -169,8 +169,8 @@ func TestXRPlanner(t *testing.T) { x := NewXRPlanner(planner, nil, ensure) _ = x.AfterXRDiff(xrCtx, map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16"))}) - if ensureCtx == nil || ensureCtx.Err() != nil { - t.Errorf("ensure ctx missing or ended: %v", ensureCtx) + if len(ensureCtxs) == 0 || ensureCtxs[0].Err() != nil { + t.Errorf("ensure ctx missing or ended: %v", ensureCtxs) } }) From 9fdf779919a3a958e077a667a8d0641d75bb5eab Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 20:57:08 -0400 Subject: [PATCH 072/126] style(project): align simulate flag tags Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index a50c758e..328eeeb7 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -77,13 +77,13 @@ type simulateCmd struct { MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` - Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` - Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` + Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` + Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` Name string `help:"Only simulate the composite resource with this name. Use with --namespace to name a namespaced resource."` IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count."` - Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` + Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` @@ -98,9 +98,9 @@ type simulateCmd struct { DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` SkipPlan bool `help:"Don't run provider diff servers; show only the cluster-layer diff."` - Providers string `help:"YAML file or directory with Provider manifests to run as diff servers, overriding discovery from the cluster. Supports per-provider runtime annotations." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` - ImageConfigs string `help:"YAML file with ImageConfig manifests that rewrite provider image paths for local pulls. Defaults to the project's imageConfigs when -f names a project file." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` - SessionID string `help:"Session ID to apply as the simulate.crossplane.io/session label on diff server containers. Defaults to a random per-process ID." placeholder:"ID"` + Providers string `help:"YAML file or directory with Provider manifests to run as diff servers, overriding discovery from the cluster. Supports per-provider runtime annotations." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` + ImageConfigs string `help:"YAML file with ImageConfig manifests that rewrite provider image paths for local pulls. Defaults to the project's imageConfigs when -f names a project file." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` + SessionID string `help:"Session ID to apply as the simulate.crossplane.io/session label on diff server containers. Defaults to a random per-process ID." placeholder:"ID"` // CrossplaneVersion and CrossplaneImage select the crossplane render // backend, as they do for crossplane-diff. When neither is set the render From de3f7b3ac8b69b9d0ecbcc3996330bf3df7a4de3 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 21:12:21 -0400 Subject: [PATCH 073/126] feat(project): plan composed resources with provider diff servers Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 125 +++++++++++++++++++++--- cmd/crossplane/project/simulate_test.go | 2 +- 2 files changed, 112 insertions(+), 15 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 328eeeb7..d4ea855b 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -43,7 +43,9 @@ import ( apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" "github.com/crossplane/cli/v2/cmd/crossplane/common" ld "github.com/crossplane/cli/v2/cmd/crossplane/common/load" "github.com/crossplane/cli/v2/internal/config" @@ -54,6 +56,7 @@ import ( dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/diffserver" "github.com/crossplane/cli/v2/internal/docker" "github.com/crossplane/cli/v2/internal/kube" "github.com/crossplane/cli/v2/internal/plan" @@ -73,6 +76,10 @@ var simulateHelp string // or composite resource files, would change the composite resources in a // cluster. type simulateCmd struct { + // Run sets these; nil with --skip-plan (servers also when Hooks.Router is set). + xrPlanner *simulate.XRPlanner + servers *simulate.ServerSet + Input []string `default:"${project_file}" help:"Project file (default), or changed Composition or composite resource files. Repeatable; - reads standard input." placeholder:"PATH" sep:"none" short:"f"` MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` @@ -248,9 +255,10 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin var fns []pkgv1.Function var digests map[string]project.EmbeddedDigest + var proj *v1alpha1.Project comps := in.resources if in.mode == projectMode { - comps, fns, digests, err = c.buildProject(sigCtx, logger, sp, cliCfg, in.projectFile) + comps, fns, digests, proj, err = c.buildProject(sigCtx, logger, sp, cliCfg, in.projectFile) if err != nil { return err } @@ -266,14 +274,24 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin return err } - opts := append(c.processorOptions(k, logger, fns), dp.WithChangedFunctions(changed...)) + opts := append(c.processorOptions(k, logger, fns), dp.WithChangedFunctions(changed...), dp.WithXRTimeout(c.Timeout)) + + // xrPlanner and servers are for rendering the plans; they stay nil with + // --skip-plan, and servers also when Hooks.Router supplies the router. + if !c.SkipPlan { + planOpts, err := c.planOptions(sigCtx, k, logger, sp, restCfg, in, proj) + // Stop even on error: upfront Ensure may have started servers. + if c.servers != nil { + defer c.servers.Stop(context.WithoutCancel(sigCtx)) + } + if err != nil { + return err + } + opts = append(opts, planOpts...) + } xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) - // ponytail: the timeout bounds the whole simulation because the diff - // engine has no per-XR deadline. Make it per composite resource once the - // engine supports one. - ctx, cancel := context.WithTimeout(sigCtx, c.Timeout) - defer cancel() + ctx := sigCtx if in.mode == xrMode { // Compositions and functions come from the cluster, as in @@ -303,6 +321,85 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin return c.simulate(ctx, logger, status, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, in.mode)) } +// planOptions sets up provider diff servers and the XRPlanner, and returns +// the processor options that plan each composite resource's composed +// resources. Run stops c.servers when it is set. +func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, restCfg *rest.Config, in *inputs, proj *v1alpha1.Project) ([]dp.ProcessorOption, error) { + kubeClient, rmapper, err := simulate.NewKubeClient(restCfg) + if err != nil { + return nil, err + } + // ponytail: the kubeconfig's namespace isn't plumbed through + // restConfig, so namespaced resources without one plan in "default". + const ns = "default" + + var ics []pkgv1beta1.ImageConfig + var router simulate.Router + var depMgr *dependency.Manager + if c.Hooks != nil && c.Hooks.Router != nil { + router = c.Hooks.Router + } else { + projectFile := "" + if in.mode == projectMode { + projectFile = in.projectFile + } + if ics, err = simulate.ImageConfigs(c.ImageConfigs, projectFile); err != nil { + return nil, err + } + var pkgs runtimexpkg.Client + var resolver *clixpkg.Resolver + if pkgs, resolver, err = c.newClientAndResolver(clixpkg.WithImageConfigs(ics)); err != nil { + return nil, errors.Wrap(err, "cannot create package client") + } + if in.mode == projectMode { + projFilePath, err := filepath.Abs(in.projectFile) + if err != nil { + return nil, err + } + depMgr = dependency.NewManager(proj, afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)), + dependency.WithProjectFile(filepath.Base(projFilePath)), + dependency.WithXpkgClient(pkgs), + dependency.WithResolver(resolver), + ) + } + c.servers = simulate.NewServerSet(sp, logger, k.Stderr, pkgs, c.SessionID) + router = c.servers + } + + var ensure simulate.EnsureServers + switch { + case c.servers == nil: + case c.Providers != "": + inputs, err := simulate.LoadProviderInputs(c.Providers) + if err != nil { + return nil, errors.Wrapf(err, "cannot load provider manifests from %q", c.Providers) + } + if err := c.servers.Ensure(sigCtx, inputs); err != nil { + return nil, err + } + case in.mode == projectMode: + images, err := project.LoadProviderDependencies(depMgr, proj) + if err != nil { + return nil, errors.Wrap(err, "cannot load the project's provider dependencies") + } + if err := c.servers.Ensure(sigCtx, diffserver.Images(images)); err != nil { + return nil, err + } + default: + ensure = func(ctx context.Context, rs []*un.Unstructured) error { + imgs, err := simulate.RelevantProviderImages(ctx, kubeClient, rmapper, rs, len(ics) > 0) + if err != nil { + return err + } + return c.servers.Ensure(ctx, diffserver.Images(imgs)) + } + } + + keep := simulate.ProviderManaged(sigCtx, kubeClient, rmapper) + c.xrPlanner = simulate.NewXRPlanner(simulate.NewPlanner(kubeClient, rmapper, ns, router, c.Timeout), keep, ensure) + return []dp.ProcessorOption{dp.WithAfterXRDiff(c.xrPlanner.AfterXRDiff), dp.WithKeepEqualDiffs(keep)}, nil +} + // restConfig returns the cluster to simulate against: the hooks' when set, // otherwise the kubeconfig's. func (c *simulateCmd) restConfig() (*rest.Config, error) { @@ -315,32 +412,32 @@ func (c *simulateCmd) restConfig() (*rest.Config, error) { // buildProject returns the project's compositions and its locally built // functions. -func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config, projectFile string) ([]*un.Unstructured, []pkgv1.Function, map[string]project.EmbeddedDigest, error) { +func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, sp terminal.SpinnerPrinter, cliCfg *config.Config, projectFile string) ([]*un.Unstructured, []pkgv1.Function, map[string]project.EmbeddedDigest, *v1alpha1.Project, error) { projFilePath, err := filepath.Abs(projectFile) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } projFS := afero.NewBasePathFs(afero.NewOsFs(), filepath.Dir(projFilePath)) projFileName := filepath.Base(projFilePath) proj, err := projectfile.Parse(projFS, projFileName) if err != nil { - return nil, nil, nil, errors.Wrapf(err, "failed to parse project file %q", projectFile) + return nil, nil, nil, nil, errors.Wrapf(err, "failed to parse project file %q", projectFile) } comps, err := project.LoadCompositions(proj, projFS) if err != nil { - return nil, nil, nil, errors.Wrap(err, "cannot load compositions from project") + return nil, nil, nil, nil, errors.Wrap(err, "cannot load compositions from project") } if len(comps) == 0 { - return nil, nil, nil, errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) + return nil, nil, nil, nil, errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) } fns, digests, err := project.LoadFunctionsWithDigests(ctx, logger, sp, cliCfg, projFS, projFilePath, projFileName, c.MaxConcurrency, c.newClientAndResolver) if err != nil { - return nil, nil, nil, err + return nil, nil, nil, nil, err } - return comps, fns, digests, nil + return comps, fns, digests, proj, nil } // changedFunctions returns the cluster names of the embedded functions whose diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 61bf72ed..fb04a477 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -292,7 +292,7 @@ func TestSimulateFlags(t *testing.T) { if tc.want.err { return } - if diff := cmp.Diff(tc.want.cmd, got, cmpopts.IgnoreFields(simulateCmd{}, "Impersonation")); diff != "" { + if diff := cmp.Diff(tc.want.cmd, got, cmpopts.IgnoreFields(simulateCmd{}, "Impersonation"), cmpopts.IgnoreUnexported(simulateCmd{})); diff != "" { t.Errorf("\n%s\nParse(%v): -want, +got:\n%s", tc.reason, tc.args, diff) } }) From c1758ee35a08512d884a4228129cdf54e73075b5 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 21:33:50 -0400 Subject: [PATCH 074/126] feat(project): show provider plans in project simulate output Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 76 ++++++++++++++++--- cmd/crossplane/project/simulate_test.go | 33 +++++++- internal/diff/integration/plan_output_test.go | 6 +- internal/simulate/report.go | 4 +- 4 files changed, 101 insertions(+), 18 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index d4ea855b..b2fcf86b 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -307,13 +307,18 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin // Render the report even when there are errors, so successful XRs // are shown and structured output stays valid. The renderer // includes the errors in structured output. + changed := report != nil && report.HasChanges() if report != nil { - if renderErr := c.renderXRReport(k, logger, report); renderErr != nil { + rendered, renderErr := c.renderXRReport(k, logger, report) + if renderErr == nil { + changed = rendered + } + if renderErr != nil { logger.Debug("Failed to render diffs", "error", renderErr) err = withRenderError(err, errors.Wrap(renderErr, "failed to render diffs")) } } - return report != nil && report.HasChanges(), errors.Wrap(err, "cannot simulate composite resources") + return changed, errors.Wrap(err, "cannot simulate composite resources") }) } @@ -480,12 +485,17 @@ func (c *simulateCmd) diffCompositions(k *kong.Context, logger logging.Logger, p // Render the report even when there are errors (e.g. every // composition failed), so structured output stays valid. A render // failure takes precedence over the processing error. + changed := output != nil && output.HasChanges() if output != nil { - if renderErr := c.renderCompReport(k, logger, output); renderErr != nil { + rendered, renderErr := c.renderCompReport(k, logger, output) + if renderErr == nil { + changed = rendered + } + if renderErr != nil { err = errors.Wrap(renderErr, "failed to render composition diff") } } - return output != nil && output.HasChanges(), errors.Wrap(err, msg) + return changed, errors.Wrap(err, msg) } } @@ -796,25 +806,69 @@ func (c *simulateCmd) diffOptions(k *kong.Context) renderer.DiffOptions { return opts } +// providerPlans returns the provider plans the run collected, or nil when +// planning was skipped. +func (c *simulateCmd) providerPlans() map[string]*plan.ResourcePlan { + if c.xrPlanner == nil { + return nil + } + return c.xrPlanner.Plans() +} + +// planNotes returns the notes that replace the result's own, or nil to keep +// them. noServers is true when diff servers were wanted but none ran. +func planNotes(skipPlan bool, plans map[string]*plan.ResourcePlan, noServers bool) []string { + switch { + case skipPlan: + return []string{simulate.NoteProviderPlanSkipped} + case plans != nil && len(plans) == 0 && noServers: + return []string{simulate.NoteNoDiffServers} + } + return nil +} + +func (c *simulateCmd) notes() []string { + return planNotes(c.SkipPlan, c.providerPlans(), c.servers != nil && !c.servers.Ran()) +} + // renderXRReport writes an xr or project run's report in the format -o -// selects: the engine's human diff for diff, and plan.Result otherwise. -func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport) error { +// selects: the engine's human diff for diff, and plan.Result otherwise. It +// returns whether the run changed anything, provider plans included. +func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport) (bool, error) { opts := c.diffOptions(k) + plans := c.providerPlans() + res := simulate.FromXRReport(r, plans) + if n := c.notes(); n != nil { + res.Notes = n + } + changed := r.HasChanges() || (plans != nil && simulate.Changed(res)) if c.Output == string(renderer.OutputFormatDiff) { - return renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) + if plans != nil { + r = simulate.AnnotateXRReport(r, plans) + } + return changed, renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) } - return c.renderPlan(k, simulate.FromXRReport(r, nil), dt.XRDryRunWarnings(r.Groups)) + return changed, c.renderPlan(k, res, dt.XRDryRunWarnings(r.Groups)) } // renderCompReport writes a composition run's report in the format -o // selects, as renderXRReport does. -func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput) error { +func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput) (bool, error) { opts := c.diffOptions(k) opts.MinimizeComposition = c.MinimizeComposition + plans := c.providerPlans() + res := simulate.FromCompReport(o, plans) + if n := c.notes(); n != nil { + res.Notes = n + } + changed := o.HasChanges() || (plans != nil && simulate.Changed(res)) if c.Output == string(renderer.OutputFormatDiff) { - return renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts).RenderCompDiff(o) + if plans != nil { + o = simulate.AnnotateCompReport(o, plans) + } + return changed, renderer.NewDefaultCompDiffRenderer(logger, renderer.NewDiffRenderer(logger, opts), opts).RenderCompDiff(o) } - return c.renderPlan(k, simulate.FromCompReport(o, nil), dt.CompDryRunWarnings(o)) + return changed, c.renderPlan(k, res, dt.CompDryRunWarnings(o)) } // renderPlan writes res to stdout in the plan, json or yaml format, then diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index fb04a477..c05596fb 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -49,6 +49,7 @@ import ( dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/plan" + "github.com/crossplane/cli/v2/internal/simulate" ) // parseSimulate parses args as `simulate` flags, running kong's validation. @@ -584,8 +585,14 @@ func TestSimulateRenderReports(t *testing.T) { render func(*kong.Context) error want func(*testing.T, string) }{ - "xr": {func(k *kong.Context) error { return tc.cmd.renderXRReport(k, logging.NewNopLogger(), xrReport) }, tc.wantXR}, - "comp": {func(k *kong.Context) error { return tc.cmd.renderCompReport(k, logging.NewNopLogger(), compReport) }, tc.wantComp}, + "xr": {func(k *kong.Context) error { + _, err := tc.cmd.renderXRReport(k, logging.NewNopLogger(), xrReport) + return err + }, tc.wantXR}, + "comp": {func(k *kong.Context) error { + _, err := tc.cmd.renderCompReport(k, logging.NewNopLogger(), compReport) + return err + }, tc.wantComp}, } { stdout := &bytes.Buffer{} k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: &bytes.Buffer{}}} @@ -1085,3 +1092,25 @@ func TestCheckComposites(t *testing.T) { }) } } + +func TestPlanNotes(t *testing.T) { + cases := map[string]struct { + skipPlan bool + plans map[string]*plan.ResourcePlan + noServers bool + want []string + }{ + "SkipPlan": {skipPlan: true, want: []string{simulate.NoteProviderPlanSkipped}}, + "NilPlans": {}, + "EmptyServersNotRun": {plans: map[string]*plan.ResourcePlan{}, noServers: true, want: []string{simulate.NoteNoDiffServers}}, + "EmptyServersRan": {plans: map[string]*plan.ResourcePlan{}}, + "NonEmptyPlans": {plans: map[string]*plan.ResourcePlan{"k": {}}, noServers: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if diff := cmp.Diff(tc.want, planNotes(tc.skipPlan, tc.plans, tc.noServers)); diff != "" { + t.Errorf("planNotes(...): -want, +got:\n%s", diff) + } + }) + } +} diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index cc7987e9..88c7bd7d 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -95,7 +95,7 @@ func TestSimulatePlanOutput(t *testing.T) { Plan: 0 to add, 2 to change, 0 to destroy. -Note: Cloud-layer plan not run (provider diff servers not yet wired). +Note: Cloud-layer plan not run: no provider diff servers serve these resources. `, }, "XRCreatePlanText": { @@ -120,7 +120,7 @@ Note: Cloud-layer plan not run (provider diff servers not yet wired). Plan: 2 to add, 0 to change, 0 to destroy. -Note: Cloud-layer plan not run (provider diff servers not yet wired). +Note: Cloud-layer plan not run: no provider diff servers serve these resources. `, }, "XRPlanJSON": { @@ -166,7 +166,7 @@ XR: XNopResource.ns.diff.example.org default/test-resource Plan: 0 to add, 3 to change, 0 to destroy. -Note: Cloud-layer plan not run (provider diff servers not yet wired). +Note: Cloud-layer plan not run: no provider diff servers serve these resources. `, }, "CompPlanJSON": { diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 16da93ea..bbc2362a 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -34,8 +34,8 @@ import ( ) // noteProviderPlanNotRun says the cluster-layer result was not refined by -// provider diff servers, so its plans say nothing about the cloud resources. -const noteProviderPlanNotRun = "Cloud-layer plan not run (provider diff servers not yet wired)." +// provider plans, so its plans say nothing about the cloud resources. +const noteProviderPlanNotRun = "Cloud-layer plan not run." // Notes a caller sets on Result.Notes when no provider plans refined the // cluster-layer result, saying why. A result built from provider plans From 65975d8921fb2eb778e31fff944521e6dd806498 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 21:56:10 -0400 Subject: [PATCH 075/126] test(diff): run project simulate against a fake provider diff server Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../diff/integration/provider_plan_test.go | 299 ++++++++++++++++++ .../diff/integration/simulate_harness_test.go | 10 + 2 files changed, 309 insertions(+) create mode 100644 internal/diff/integration/provider_plan_test.go diff --git a/internal/diff/integration/provider_plan_test.go b/internal/diff/integration/provider_plan_test.go new file mode 100644 index 00000000..ffdeb3ed --- /dev/null +++ b/internal/diff/integration/provider_plan_test.go @@ -0,0 +1,299 @@ +//go:build integration + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/plan" + "github.com/crossplane/cli/v2/internal/simulate" +) + +// fakeProviderRouter routes the composed XDownstreamResource kind, and only +// it, to a fake diff server that answers with fn. +type fakeProviderRouter struct { + fn func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) +} + +func (f fakeProviderRouter) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + if gk.Kind != "XDownstreamResource" { + return nil, false + } + return f, true +} + +func (f fakeProviderRouter) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + return f.fn(in) +} + +func strValue(s string) *diffv1alpha1.FieldValue { + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Value{Value: structpb.NewStringValue(s)}} +} + +func unknownValue() *diffv1alpha1.FieldValue { + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Absence{Absence: diffv1alpha1.Absence_ABSENCE_UNKNOWN}} +} + +func TestSimulateProviderPlan(t *testing.T) { + t.Parallel() + + tu.SetupKubeTestLogger(t) + + xrCase := IntegrationTestCase{ + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/diff/resources/existing-xr.yaml", "testdata/diff/resources/existing-downstream-resource.yaml"), + }, + } + + update := func(changes ...*diffv1alpha1.FieldChange) func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UPDATE, Changes: changes}, nil + } + } + region := &diffv1alpha1.FieldChange{Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1")} + + cases := map[string]struct { + reason string + args []string + fn func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) + skip string + // ownCRD makes the composed kind's CRD provider-installed. + ownCRD bool + xrFile string + wantCode int + // wantOut are substrings of stdout; wantErr of stderr. + wantOut, wantNotOut []string + wantErr []string + // check inspects the -o json result, when args ask for it. + check func(t *testing.T, res plan.Result) + }{ + "ProviderUpdate": { + reason: "A provider's extra field change joins the composed resource's plan, and the cloud-layer note is gone.", + args: []string{"-o", "json"}, + fn: update(region), + wantCode: dp.ExitCodeDiffDetected, + wantNotOut: []string{"Cloud-layer plan not run"}, + check: func(t *testing.T, res plan.Result) { + t.Helper() + for _, p := range res.Plans { + if p.Kind != "XDownstreamResource" { + continue + } + for _, c := range p.Changes { + if c.Path == "spec.forProvider.region" { + return + } + } + } + t.Errorf("no XDownstreamResource plan carries the provider's spec.forProvider.region change: %+v", res.Plans) + }, + }, + "Replace": { + reason: "A field requiring replacement shows [-/+] and # forces replacement in -o plan.", + args: []string{"-o", "plan"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1"), RequiresReplace: true}, + }}, nil + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"[-/+]", "# forces replacement"}, + }, + "ReplaceDiff": { + reason: "-o diff says requires replacement in the header and marks the changed line.", + args: []string{"-o", "diff"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.configData", Actual: strValue("existing-value"), Planned: strValue("modified-value"), RequiresReplace: true}, + }}, nil + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"# forces replacement"}, + }, + "ReplaceDiffHeader": { + reason: "-o diff says requires replacement in the resource header.", + skip: "bug: -o diff prints a plain ~~~ header for a replace; no code under internal/simulate/annotate.go or internal/diff renders 'requires replacement'", + args: []string{"-o", "diff"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.configData", Actual: strValue("existing-value"), Planned: strValue("modified-value"), RequiresReplace: true}, + }}, nil + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"requires replacement"}, + }, + "KnownAfterApply": { + reason: "Computed paths show (known after apply) in -o diff.", + args: []string{"-o", "diff"}, + fn: update(&diffv1alpha1.FieldChange{Field: "spec.forProvider.configData", Actual: strValue("existing-value"), Planned: unknownValue()}), + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"(known after apply)"}, + }, + "PlanError": { + reason: "A plan error makes the XR errored, drops its plans, names the resource and exits 1.", + args: []string{"-o", "json"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return nil, status.Error(codes.Internal, "boom") + }, + wantCode: dp.ExitCodeToolError, + check: func(t *testing.T, res plan.Result) { + t.Helper() + if !res.Errored { + t.Errorf("want errored result, got %+v", res) + } + if !strings.Contains(planJSON(t, res), "test-resource") || !strings.Contains(planJSON(t, res), "boom") { + t.Errorf("result does not name the resource and error: %s", planJSON(t, res)) + } + }, + }, + "NotSupported": { + reason: "A provider that cannot diff leaves the cluster diff, marked approximate, with no error.", + args: []string{"-o", "json"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + st, err := status.New(codes.FailedPrecondition, "cannot compute diff").WithDetails(&errdetails.PreconditionFailure{Violations: []*errdetails.PreconditionFailure_Violation{{Type: "DIFF_COMPUTATION_NOT_SUPPORTED"}}}) + if err != nil { + return nil, err + } + return nil, st.Err() + }, + wantCode: dp.ExitCodeDiffDetected, + check: func(t *testing.T, res plan.Result) { + t.Helper() + if res.Errored || res.Summary.Approximate == 0 { + t.Errorf("want non-errored result with approximate plans, got errored=%t summary=%+v", res.Errored, res.Summary) + } + }, + }, + "SkipPlan": { + reason: "--skip-plan never calls the provider: the run succeeds although every call would fail.", + args: []string{"-o", "plan", "--skip-plan"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return nil, status.Error(codes.Internal, "must not be called") + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{simulate.NoteProviderPlanSkipped}, + }, + "Drift": { + reason: "A provider-managed resource the cluster sees unchanged still shows the provider's change.", + xrFile: "testdata/diff/unmodified-xr.yaml", + args: []string{"-o", "plan"}, + ownCRD: true, + fn: update(&diffv1alpha1.FieldChange{Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1")}), + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"XDownstreamResource", "region"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.skip != "" { + t.Skip(tc.skip) + } + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + cfg, kube, ctx, _ := startIntegrationCluster(t, XRDiffTest, xrCase, defaultTimeout) + if tc.ownCRD { + ownByProviderRevision(ctx, t, kube, "xdownstreamresources.ns.nop.example.org") + } + + xr := tc.xrFile + if xr == "" { + xr = "testdata/diff/modified-xr.yaml" + } + argv := append([]string{"project", "simulate", "--timeout=" + defaultTimeout.String(), "-f", xr}, tc.args...) + + var stdout, stderr bytes.Buffer + code, err := runSimulateArgvRouted(t, cfg, argv, localCrossplaneBinary(t), fakeProviderRouter{fn: tc.fn}, &stdout, &stderr) + if code != tc.wantCode { + t.Errorf("\n%s\nwant exit code %d, got %d (err %v)\nstdout:\n%s\nstderr:\n%s", tc.reason, tc.wantCode, code, err, stdout.String(), stderr.String()) + } + for _, w := range tc.wantOut { + if !strings.Contains(stdout.String(), w) { + t.Errorf("\n%s\nstdout lacks %q:\n%s", tc.reason, w, stdout.String()) + } + } + for _, w := range tc.wantNotOut { + if strings.Contains(stdout.String(), w) { + t.Errorf("\n%s\nstdout has %q:\n%s", tc.reason, w, stdout.String()) + } + } + if tc.check != nil { + var res plan.Result + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("\n%s\nstdout is not a plan.Result: %v\n%s\nstderr:\n%s", tc.reason, err, stdout.String(), stderr.String()) + } + tc.check(t, res) + } + }) + } +} + +func planJSON(t *testing.T, res plan.Result) string { + t.Helper() + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// ownByProviderRevision makes the named CRD look provider-installed, as +// simulate.ProviderManaged reads it: controlled by a ProviderRevision. +func ownByProviderRevision(ctx context.Context, t *testing.T, kube client.Client, crdName string) { + t.Helper() + + crd := &unstructured.Unstructured{} + crd.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) + if err := kube.Get(ctx, client.ObjectKey{Name: crdName}, crd); err != nil { + t.Fatalf("cannot get CRD %s: %v", crdName, err) + } + crd.SetOwnerReferences([]metav1.OwnerReference{{ + APIVersion: "pkg.crossplane.io/v1", Kind: "ProviderRevision", Name: "provider-fake-1", UID: "00000000-0000-0000-0000-000000000001", + Controller: new(true), BlockOwnerDeletion: new(true), + }}) + if err := kube.Update(ctx, crd); err != nil { + t.Fatalf("cannot own CRD %s: %v", crdName, err) + } +} diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index c18c359b..e191a4c7 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -59,6 +59,7 @@ import ( "github.com/crossplane/cli/v2/internal/diff/ref" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/terminal" clixpkg "github.com/crossplane/cli/v2/internal/xpkg" ) @@ -194,6 +195,14 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s func runSimulateArgv(t *testing.T, cfg *rest.Config, argv []string, renderBinary string, stdout, stderr io.Writer) (int, error) { t.Helper() + return runSimulateArgvRouted(t, cfg, argv, renderBinary, nil, stdout, stderr) +} + +// runSimulateArgvRouted is runSimulateArgv with provider plans routed through +// router, when it is non-nil, instead of started diff servers. +func runSimulateArgvRouted(t *testing.T, cfg *rest.Config, argv []string, renderBinary string, router simulate.Router, stdout, stderr io.Writer) (int, error) { + t.Helper() + var cli struct { Project project.Cmd `cmd:""` } @@ -225,6 +234,7 @@ func runSimulateArgv(t *testing.T, cfg *rest.Config, argv []string, renderBinary RESTConfig: cfg, RenderBinary: renderBinary, Terminal: true, + Router: router, } err = kctx.Run() From 15355f585fa04665b411f361ac45e970474f36ba Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 22:22:04 -0400 Subject: [PATCH 076/126] fix(project): mark replacements in project simulate -o diff Also asserts a failed XR keeps no composed-resource plans. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 6 ++++++ internal/diff/integration/provider_plan_test.go | 10 ++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index b2fcf86b..fd5451b1 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -837,6 +837,9 @@ func (c *simulateCmd) notes() []string { func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport) (bool, error) { opts := c.diffOptions(k) plans := c.providerPlans() + if plans != nil { + opts.Replaced = simulate.ReplacedBy(plans) + } res := simulate.FromXRReport(r, plans) if n := c.notes(); n != nil { res.Notes = n @@ -857,6 +860,9 @@ func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o opts := c.diffOptions(k) opts.MinimizeComposition = c.MinimizeComposition plans := c.providerPlans() + if plans != nil { + opts.Replaced = simulate.ReplacedBy(plans) + } res := simulate.FromCompReport(o, plans) if n := c.notes(); n != nil { res.Notes = n diff --git a/internal/diff/integration/provider_plan_test.go b/internal/diff/integration/provider_plan_test.go index ffdeb3ed..af50c6db 100644 --- a/internal/diff/integration/provider_plan_test.go +++ b/internal/diff/integration/provider_plan_test.go @@ -150,7 +150,6 @@ func TestSimulateProviderPlan(t *testing.T) { }, "ReplaceDiffHeader": { reason: "-o diff says requires replacement in the resource header.", - skip: "bug: -o diff prints a plain ~~~ header for a replace; no code under internal/simulate/annotate.go or internal/diff renders 'requires replacement'", args: []string{"-o", "diff"}, fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ @@ -158,7 +157,7 @@ func TestSimulateProviderPlan(t *testing.T) { }}, nil }, wantCode: dp.ExitCodeDiffDetected, - wantOut: []string{"requires replacement"}, + wantOut: []string{"~~~ XDownstreamResource/test-resource (requires replacement)", "(1 requires replacement)"}, }, "KnownAfterApply": { reason: "Computed paths show (known after apply) in -o diff.", @@ -182,6 +181,13 @@ func TestSimulateProviderPlan(t *testing.T) { if !strings.Contains(planJSON(t, res), "test-resource") || !strings.Contains(planJSON(t, res), "boom") { t.Errorf("result does not name the resource and error: %s", planJSON(t, res)) } + // The failed XR is all-or-nothing: none of its composed + // resources keep a plan. + for _, p := range res.Plans { + if p.Kind == "XDownstreamResource" { + t.Errorf("failed XR still carries a plan for its composed resource: %+v", p) + } + } }, }, "NotSupported": { From 59ade08e2583b0f700b96aa87cc02e0865570f75 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 22:22:35 -0400 Subject: [PATCH 077/126] docs(project): describe provider plans and exit codes in simulate help Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 0e1264b6..8003e931 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -80,6 +80,17 @@ The `--timeout` flag, 2m by default, bounds the time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count. +By default `simulate` also runs provider plans: it asks each provider's diff +server to plan the managed resources, so the plan shows changes the cluster +can't see. The diff servers come from `--providers` when set, otherwise from +the project's provider dependencies in project mode, otherwise from the +providers installed on the cluster that own the rendered resources, started on +demand. A resource no diff server serves keeps its cluster-layer diff. In `json` +and `yaml` output, `approximate: true` on a plan means it was computed +client-side, without a provider diff, because the provider couldn't diff that +resource. The result's `notes` say when provider plans were skipped or no diff +server served the resources. + Provider plans are controlled by these flags: - `--skip-plan` doesn't run provider diff servers and shows only the @@ -112,9 +123,11 @@ The command exits with one of these statuses, so a CI pipeline can gate on the result: - 0: the simulation succeeded and found no changes. -- 1: the simulation failed. +- 1: the simulation failed. A provider plan that fails fails its whole + composite resource, which then reports no plans. - 2: only schema validation failed. -- 3: the simulation succeeded and found changes. +- 3: the simulation succeeded and found changes, including drift a provider + plan found that the cluster diff didn't. - 130: the simulation was interrupted. When more than one applies, the lowest of 1, 2 and 3 wins. From 2f9d79cecaa90c352cd21875c0d0a407499e9f5f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 22:36:11 -0400 Subject: [PATCH 078/126] perf(diff): build cluster lookups lazily instead of listing at startup Ported from crossplane-contrib/crossplane-diff#598 (d93eb87). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/client/client.go | 2 +- internal/diff/client/core/core.go | 12 +- .../client/crossplane/composition_client.go | 213 ++++-- .../crossplane/composition_client_test.go | 344 ++++++--- .../client/crossplane/definition_client.go | 270 ++++--- .../crossplane/definition_client_test.go | 678 ++++++++++++------ .../client/crossplane/environment_client.go | 16 +- .../crossplane/environment_client_test.go | 49 +- .../diff/client/crossplane/function_client.go | 91 ++- .../client/crossplane/function_client_test.go | 175 ++--- .../diff/client/kubernetes/schema_client.go | 282 ++------ .../client/kubernetes/schema_client_test.go | 465 +++--------- internal/diff/diffprocessor/defaulter.go | 6 +- internal/diff/diffprocessor/diff_processor.go | 29 +- .../diff/diffprocessor/diff_processor_test.go | 193 +++-- .../diff/diffprocessor/function_provider.go | 18 +- .../diffprocessor/function_provider_test.go | 37 +- .../project_function_provider.go | 8 +- .../project_function_provider_test.go | 19 +- .../diffprocessor/requirements_provider.go | 21 +- .../requirements_provider_test.go | 69 ++ .../diff/diffprocessor/schema_validator.go | 25 +- .../diffprocessor/schema_validator_test.go | 102 --- .../diff/first-xr-with-composition-ref.yaml | 11 + .../composition-with-missing-function.yaml | 17 + .../diff/resources/unestablished-xrd.yaml | 25 + internal/diff/testutils/mock_builder.go | 176 +++-- internal/diff/testutils/mocks.go | 38 +- 28 files changed, 1765 insertions(+), 1626 deletions(-) create mode 100644 internal/diff/integration/testdata/diff/first-xr-with-composition-ref.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/composition-with-missing-function.yaml create mode 100644 internal/diff/integration/testdata/diff/resources/unestablished-xrd.yaml diff --git a/internal/diff/client/client.go b/internal/diff/client/client.go index 3a118fd9..40edc553 100644 --- a/internal/diff/client/client.go +++ b/internal/diff/client/client.go @@ -55,7 +55,7 @@ func New(config *rest.Config, logger logging.Logger) (*Clients, error) { Schema: k8.NewSchemaClient(coreClients, tc, logger), } - defClient := xp.NewDefinitionClient(k8c.Resource, logger) + defClient := xp.NewDefinitionClient(k8c.Resource, tc, logger) xpc := xp.Clients{ Composition: xp.NewCompositionClient(k8c.Resource, defClient, logger), diff --git a/internal/diff/client/core/core.go b/internal/diff/client/core/core.go index 21df1369..8c656071 100644 --- a/internal/diff/client/core/core.go +++ b/internal/diff/client/core/core.go @@ -7,6 +7,7 @@ import ( "reflect" "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" "k8s.io/client-go/dynamic" kubeclient "k8s.io/client-go/kubernetes" "k8s.io/client-go/kubernetes/scheme" @@ -107,8 +108,17 @@ func makeXrmClient(config *rest.Config) (*xrm.Client, error) { return xrmClient, nil } +// makeDiscoveryClient returns a discovery client that fetches the server's groups and resources once and +// serves every later lookup from memory. Every client's Initialize and most lookups consult discovery, so +// without the cache a run repeats the same discovery requests many times over. The cache lives for the run: +// a type the server starts serving mid-run is not seen, which a diff of a fixed cluster state never needs. func makeDiscoveryClient(config *rest.Config) (discovery.DiscoveryInterface, error) { - return discovery.NewDiscoveryClientForConfig(config) + d, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + return nil, err + } + + return memory.NewMemCacheClient(d), nil } // InitializeClients initializes a list of clients, collecting all errors. diff --git a/internal/diff/client/crossplane/composition_client.go b/internal/diff/client/crossplane/composition_client.go index 35fc17bc..7c2b0b26 100644 --- a/internal/diff/client/crossplane/composition_client.go +++ b/internal/diff/client/crossplane/composition_client.go @@ -9,7 +9,9 @@ import ( "sync" apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" @@ -79,10 +81,25 @@ type DefaultCompositionClient struct { revisionClient CompositionRevisionClient logger logging.Logger - // Cache of compositions, guarded by compositionsMu. + // Caches guarded by compositionsMu, which is never held across a cluster request: compositions by + // name, the error a GET of each name the cluster has no composition for returned, and the + // compositions each label selector (formatted) matches. compositions map[string]*apiextensionsv1.Composition + missing map[string]error + selected map[string][]*apiextensionsv1.Composition compositionsMu sync.RWMutex - gvks []schema.GroupVersionKind + + // listedAll records that compositions holds every composition in the cluster. listMu guards it and + // is held across the one full LIST, so concurrent lookups by type share it. + listedAll bool + listMu sync.Mutex + + gvks []schema.GroupVersionKind +} + +// compositionGVK returns the one Composition version this client reads, as typed Compositions. +func compositionGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{Group: CrossplaneAPIExtGroup, Version: "v1", Kind: CompositionKind} } // NewCompositionClient creates a new DefaultCompositionClient. @@ -93,10 +110,13 @@ func NewCompositionClient(resourceClient kubernetes.ResourceClient, definitionCl revisionClient: NewCompositionRevisionClient(resourceClient, logger), logger: logger, compositions: make(map[string]*apiextensionsv1.Composition), + missing: make(map[string]error), + selected: make(map[string][]*apiextensionsv1.Composition), } } -// Initialize loads compositions into the cache. +// Initialize discovers the served Composition versions. Compositions are fetched on demand: by name, +// by label selector, or all of them, once, for a composite that names neither. func (c *DefaultCompositionClient) Initialize(ctx context.Context) error { c.logger.Debug("Initializing composition client") @@ -112,20 +132,7 @@ func (c *DefaultCompositionClient) Initialize(ctx context.Context) error { return errors.Wrap(err, "cannot initialize composition revision client") } - // List compositions to populate the cache - comps, err := c.ListCompositions(ctx) - if err != nil { - return errors.Wrap(err, "cannot list compositions") - } - - // Store in cache - c.compositionsMu.Lock() - for _, comp := range comps { - c.compositions[comp.GetName()] = comp - } - c.compositionsMu.Unlock() - - c.logger.Debug("Composition client initialized", "compositionsCount", len(comps)) + c.logger.Debug("Composition client initialized") return nil } @@ -134,24 +141,28 @@ func (c *DefaultCompositionClient) Initialize(ctx context.Context) error { func (c *DefaultCompositionClient) ListCompositions(ctx context.Context) ([]*apiextensionsv1.Composition, error) { c.logger.Debug("Listing compositions from cluster") - // Define the composition GVK - gvk := schema.GroupVersionKind{ - Group: CrossplaneAPIExtGroup, - Version: "v1", - Kind: CompositionKind, - } - // TODO: we don't actually use our cached GVKs here -- but there's only one version of Composition // and this part is strongly typed which will make a second version hard to handle // Get all compositions using the resource client - unComps, err := c.resourceClient.ListResources(ctx, gvk, "") + unComps, err := c.resourceClient.ListResources(ctx, compositionGVK(), "") if err != nil { c.logger.Debug("Failed to list compositions", "error", err) return nil, errors.Wrap(err, "cannot list compositions from cluster") } - // Convert unstructured to typed + compositions, err := c.toCompositions(unComps) + if err != nil { + return nil, err + } + + c.logger.Debug("Successfully retrieved compositions", "count", len(compositions)) + + return compositions, nil +} + +// toCompositions converts listed unstructured Compositions to typed ones. +func (c *DefaultCompositionClient) toCompositions(unComps []*un.Unstructured) ([]*apiextensionsv1.Composition, error) { compositions := make([]*apiextensionsv1.Composition, 0, len(unComps)) for _, obj := range unComps { comp := &apiextensionsv1.Composition{} @@ -168,32 +179,37 @@ func (c *DefaultCompositionClient) ListCompositions(ctx context.Context) ([]*api compositions = append(compositions, comp) } - c.logger.Debug("Successfully retrieved compositions", "count", len(compositions)) - return compositions, nil } // GetComposition gets a composition by name. func (c *DefaultCompositionClient) GetComposition(ctx context.Context, name string) (*apiextensionsv1.Composition, error) { - // Check cache first + // Check cache first, for the composition or the cluster not having it c.compositionsMu.RLock() comp, ok := c.compositions[name] + missingErr := c.missing[name] c.compositionsMu.RUnlock() if ok { return comp, nil } - // Not in cache, fetch from cluster - gvk := schema.GroupVersionKind{ - Group: CrossplaneAPIExtGroup, - Version: "v1", - Kind: CompositionKind, + if missingErr != nil { + return nil, missingErr } - unComp, err := c.resourceClient.GetResource(ctx, gvk, "" /* Compositions are cluster scoped */, name) + // Not in cache, fetch from cluster + unComp, err := c.resourceClient.GetResource(ctx, compositionGVK(), "" /* Compositions are cluster scoped */, name) if err != nil { - return nil, errors.Wrapf(err, "cannot get composition %s", name) + err = errors.Wrapf(err, "cannot get composition %s", name) + + if apierrors.IsNotFound(err) { + c.compositionsMu.Lock() + c.missing[name] = err + c.compositionsMu.Unlock() + } + + return nil, err } // Convert to typed # TODO: troublesome because typed has a version @@ -219,6 +235,101 @@ func (c *DefaultCompositionClient) cachedCompositions() []*apiextensionsv1.Compo return slices.Collect(maps.Values(c.compositions)) } +// allCompositions returns every composition in the cluster. The first call lists them; later calls, +// including concurrent ones, are served from the cache. +func (c *DefaultCompositionClient) allCompositions(ctx context.Context) ([]*apiextensionsv1.Composition, error) { + c.listMu.Lock() + defer c.listMu.Unlock() + + if !c.listedAll { + comps, err := c.ListCompositions(ctx) + if err != nil { + return nil, err + } + + c.compositionsMu.Lock() + for _, comp := range comps { + c.compositions[comp.GetName()] = comp + delete(c.missing, comp.GetName()) + } + c.compositionsMu.Unlock() + + c.listedAll = true + } + + return c.cachedCompositions(), nil +} + +// compositionsMatching returns the compositions labelsMatch says match matchLabels. The first lookup +// of a set of labels lists the compositions carrying them from the cluster, with them as the selector. +func (c *DefaultCompositionClient) compositionsMatching(ctx context.Context, matchLabels map[string]string) ([]*apiextensionsv1.Composition, error) { + // labelsMatch lets a composition without a label match an empty value for it, which a selector + // requiring the label would not, so only non-empty values go into the selector. + required := make(map[string]string, len(matchLabels)) + + for k, v := range matchLabels { + if v != "" { + required[k] = v + } + } + + // The apiserver only accepts valid labels on a composition, so a non-empty label that is not + // valid matches none. Listing with it would instead fail the request. + if _, err := labels.ValidatedSelectorFromSet(required); err != nil { + c.logger.Debug("Composition selector labels are not valid labels, so no composition matches", "labels", matchLabels, "reason", err) + return nil, nil + } + + selected, err := c.compositionsSelectedBy(ctx, metav1.LabelSelector{MatchLabels: required}) + if err != nil { + return nil, err + } + + var matching []*apiextensionsv1.Composition + + for _, comp := range selected { + if c.labelsMatch(comp.GetLabels(), matchLabels) { + matching = append(matching, comp) + } + } + + return matching, nil +} + +// compositionsSelectedBy returns the compositions sel selects, listing them from the cluster the +// first time sel is asked for. +func (c *DefaultCompositionClient) compositionsSelectedBy(ctx context.Context, sel metav1.LabelSelector) ([]*apiextensionsv1.Composition, error) { + key := metav1.FormatLabelSelector(&sel) + + c.compositionsMu.RLock() + comps, ok := c.selected[key] + c.compositionsMu.RUnlock() + + if ok { + return comps, nil + } + + unComps, err := c.resourceClient.GetResourcesByLabel(ctx, compositionGVK(), "" /* Compositions are cluster scoped */, sel) + if err != nil { + return nil, errors.Wrap(err, "cannot list compositions from cluster") + } + + comps, err = c.toCompositions(unComps) + if err != nil { + return nil, err + } + + c.compositionsMu.Lock() + c.selected[key] = comps + for _, comp := range comps { + c.compositions[comp.GetName()] = comp + delete(c.missing, comp.GetName()) + } + c.compositionsMu.Unlock() + + return comps, nil +} + // getCompositionRevisionRef reads the compositionRevisionRef from an XR/Claim spec. // Returns the revision name and whether it was found. A non-nil error means the field is malformed // (present but not a string/object), which is treated as a hard failure by the caller. @@ -688,22 +799,15 @@ func (c *DefaultCompositionClient) findByLabelSelector(ctx context.Context, xrd, // Find compositions matching the labels var matchingCompositions []*apiextensionsv1.Composition - // Get all compositions if we haven't loaded them yet - cached := c.cachedCompositions() - if len(cached) == 0 { - if _, err := c.ListCompositions(ctx); err != nil { - return nil, errors.Wrap(err, "cannot list compositions to match selector") - } + selected, err := c.compositionsMatching(ctx, stringLabels) + if err != nil { + return nil, errors.Wrap(err, "cannot list compositions to match selector") } - // Search through all compositions looking for compatible ones with matching labels - for _, comp := range cached { - // Check if this composition is for the right XR type + // Keep the compositions with matching labels that are for the right XR type + for _, comp := range selected { if c.isCompositionCompatible(comp, targetGVK) { - // Check if labels match - if c.labelsMatch(comp.GetLabels(), stringLabels) { - matchingCompositions = append(matchingCompositions, comp) - } + matchingCompositions = append(matchingCompositions, comp) } } @@ -734,18 +838,15 @@ func (c *DefaultCompositionClient) findByLabelSelector(ctx context.Context, xrd, // findByTypeReference attempts to find a composition by matching the type reference. func (c *DefaultCompositionClient) findByTypeReference(ctx context.Context, _ *un.Unstructured, targetGVK schema.GroupVersionKind, resourceID string) (*apiextensionsv1.Composition, error) { - // Get all compositions if we haven't loaded them yet - cached := c.cachedCompositions() - if len(cached) == 0 { - if _, err := c.ListCompositions(ctx); err != nil { - return nil, errors.Wrap(err, "cannot list compositions to match type") - } + all, err := c.allCompositions(ctx) + if err != nil { + return nil, errors.Wrap(err, "cannot list compositions to match type") } // Find all compositions that match this target type var compatibleCompositions []*apiextensionsv1.Composition - for _, comp := range cached { + for _, comp := range all { if c.isCompositionCompatible(comp, targetGVK) { compatibleCompositions = append(compatibleCompositions, comp) } diff --git a/internal/diff/client/crossplane/composition_client_test.go b/internal/diff/client/crossplane/composition_client_test.go index 141911e1..55b6286b 100644 --- a/internal/diff/client/crossplane/composition_client_test.go +++ b/internal/diff/client/crossplane/composition_client_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "sync" + "sync/atomic" "testing" "github.com/google/go-cmp/cmp" @@ -45,9 +46,9 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { WithCompositeTypeRef("example.org/v1", "XR1"). Build() - nonMatchingComp := tu.NewComposition("non-matching-comp"). - WithCompositeTypeRef("example.org/v1", "OtherXR"). - Build() + nonMatchingCompBuilder := tu.NewComposition("non-matching-comp"). + WithCompositeTypeRef("example.org/v1", "OtherXR") + nonMatchingComp := nonMatchingCompBuilder.Build() referencedComp := tu.NewComposition("referenced-comp"). WithCompositeTypeRef("example.org/v1", "XR1"). @@ -57,39 +58,20 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { WithCompositeTypeRef("example.org/v1", "OtherXR"). Build() - labeledComp := func() *apiextensionsv1.Composition { - comp := tu.NewComposition("labeled-comp"). - WithCompositeTypeRef("example.org/v1", "XR1"). - Build() - comp.SetLabels(map[string]string{ + labeledComp := tu.NewComposition("labeled-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{ "environment": "production", "tier": "standard", }) - return comp - }() - - aComp := func() *apiextensionsv1.Composition { - comp := tu.NewComposition("a-comp"). - WithCompositeTypeRef("example.org/v1", "XR1"). - Build() - comp.SetLabels(map[string]string{ - "environment": "production", - }) - - return comp - }() - - bComp := func() *apiextensionsv1.Composition { - comp := tu.NewComposition("b-comp"). - WithCompositeTypeRef("example.org/v1", "XR1"). - Build() - comp.SetLabels(map[string]string{ - "environment": "production", - }) + aComp := tu.NewComposition("a-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{"environment": "production"}) - return comp - }() + bComp := tu.NewComposition("b-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{"environment": "production"}) versionMismatchComp := tu.NewComposition("version-mismatch-comp"). WithCompositeTypeRef("example.org/v2", "XR1"). @@ -111,7 +93,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -135,7 +116,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -160,7 +140,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -196,7 +175,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -223,7 +201,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -253,7 +230,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -278,18 +254,14 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { reason: "Should return composition matching the selector labels", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithEmptyListResources(). + WithListableResources(labeledComp.BuildAsUnstructured(), nonMatchingCompBuilder.BuildAsUnstructured()). Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ - compositions: map[string]*apiextensionsv1.Composition{ - "labeled-comp": labeledComp, - "non-matching-comp": nonMatchingComp, - }, + compositions: map[string]*apiextensionsv1.Composition{}, }, args: args{ ctx: t.Context(), @@ -303,33 +275,24 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { }(), }, want: want{ - composition: labeledComp, + composition: labeledComp.Build(), }, }, "CompositionSelectorNoMatch": { reason: "Should return error when no composition matches the selector", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithEmptyListResources(). + WithListableResources(tu.NewComposition("labeled-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{"environment": "staging"}). + BuildAsUnstructured()). Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ - compositions: map[string]*apiextensionsv1.Composition{ - "labeled-comp": func() *apiextensionsv1.Composition { - comp := tu.NewComposition("labeled-comp"). - WithCompositeTypeRef("example.org/v1", "XR1"). - Build() - comp.SetLabels(map[string]string{ - "environment": "staging", - }) - - return comp - }(), - }, + compositions: map[string]*apiextensionsv1.Composition{}, }, args: args{ ctx: t.Context(), @@ -350,18 +313,14 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { reason: "Should return an error when multiple compositions match the selector", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithEmptyListResources(). + WithListableResources(aComp.BuildAsUnstructured(), bComp.BuildAsUnstructured()). Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ - compositions: map[string]*apiextensionsv1.Composition{ - "a-comp": aComp, - "b-comp": bComp, - }, + compositions: map[string]*apiextensionsv1.Composition{}, }, args: args{ ctx: t.Context(), @@ -386,7 +345,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -408,7 +366,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -435,7 +392,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -464,7 +420,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -489,7 +444,6 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV1XRDForXR(). Build(), fields: fields{ @@ -794,6 +748,8 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { revisionClient: NewCompositionRevisionClient(&tt.mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: tt.fields.compositions, + missing: make(map[string]error), + selected: make(map[string][]*apiextensionsv1.Composition), } // Test the FindMatchingComposition method @@ -835,6 +791,223 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { } } +// TestFindByTypeReferenceWhenCacheEmpty covers an XR that names no composition, so it is matched +// against the compositions the cluster lists because none are cached yet. The selector case lists +// those carrying its labels. +func TestFindByTypeReferenceWhenCacheEmpty(t *testing.T) { + matching := tu.NewComposition("matching-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{"environment": "production"}) + + // Same labels, different composite type: only the type separates it from the match. + other := tu.NewComposition("other-comp"). + WithCompositeTypeRef("example.org/v1", "OtherXR"). + WithLabels(map[string]string{"environment": "production"}) + + listed := []*un.Unstructured{matching.BuildAsUnstructured(), other.BuildAsUnstructured()} + + tests := map[string]struct { + reason string + res *un.Unstructured + }{ + "NoRefNoSelector": { + reason: "An XR with neither a compositionRef nor a selector should resolve to the one listed composition of its type", + res: tu.NewResource("example.org/v1", "XR1", "my-xr").Build(), + }, + "LabelSelector": { + reason: "An XR with a compositionSelector should resolve to the listed composition of its type matching the labels", + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionSelector", map[string]any{ + "matchLabels": map[string]any{"environment": "production"}, + }). + Build(), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockResource := tu.NewMockResourceClient(). + WithListableResources(listed...). + Build() + + c := &DefaultCompositionClient{ + resourceClient: mockResource, + definitionClient: tu.NewMockDefinitionClient().WithV1XRDForXR().Build(), + revisionClient: NewCompositionRevisionClient(mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: make(map[string]*apiextensionsv1.Composition), + missing: make(map[string]error), + selected: make(map[string][]*apiextensionsv1.Composition), + } + + got, err := c.FindMatchingComposition(t.Context(), tt.res) + if err != nil { + t.Fatalf("\n%s\nFindMatchingComposition(...): unexpected error: %v", tt.reason, err) + } + + if diff := cmp.Diff(matching.Build(), got.Composition); diff != "" { + t.Errorf("\n%s\nFindMatchingComposition(...): -want composition, +got composition:\n%s", tt.reason, diff) + } + }) + } +} + +// TestFindByLabelSelectorWithoutFullCache resolves composites that select their composition by label +// on credentials that cannot list every composition, so the compositions carrying the labels are all +// the client can see. The composition chosen, and the failure when several match, are as when the +// selector is evaluated against every composition. +func TestFindByLabelSelectorWithoutFullCache(t *testing.T) { + production := map[string]string{"environment": "production"} + + prodXR1 := tu.NewComposition("prod-xr1").WithCompositeTypeRef("example.org/v1", "XR1").WithLabels(production) + // Same labels, different composite type: only the type separates it from prodXR1. + prodOther := tu.NewComposition("prod-other").WithCompositeTypeRef("example.org/v1", "OtherXR").WithLabels(production) + // Same type, different labels: only the labels separate it from prodXR1. + stagingXR1 := tu.NewComposition("staging-xr1").WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{"environment": "staging"}) + // More labels than the selector names; a selector matches a subset of a composition's labels. + prodStandardXR1 := tu.NewComposition("prod-standard-xr1").WithCompositeTypeRef("example.org/v1", "XR1"). + WithLabels(map[string]string{"environment": "production", "tier": "standard"}) + secondProdXR1 := tu.NewComposition("second-prod-xr1").WithCompositeTypeRef("example.org/v1", "XR1").WithLabels(production) + + selecting := func(matchLabels map[string]any) *un.Unstructured { + return tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionSelector", map[string]any{"matchLabels": matchLabels}). + Build() + } + + type want struct { + composition *apiextensionsv1.Composition + err string + } + + tests := map[string]struct { + reason string + cluster []*un.Unstructured + res *un.Unstructured + want want + }{ + "OnlyCompositionOfTheTypeWithTheLabels": { + reason: "The composition of the composite's type carrying the selected labels is chosen over those differing in type or labels", + cluster: []*un.Unstructured{prodOther.BuildAsUnstructured(), stagingXR1.BuildAsUnstructured(), prodXR1.BuildAsUnstructured()}, + res: selecting(map[string]any{"environment": "production"}), + want: want{composition: prodXR1.Build()}, + }, + "CompositionWithMoreLabelsThanSelected": { + reason: "A composition carrying labels beyond those selected still matches", + cluster: []*un.Unstructured{prodStandardXR1.BuildAsUnstructured(), stagingXR1.BuildAsUnstructured()}, + res: selecting(map[string]any{"environment": "production"}), + want: want{composition: prodStandardXR1.Build()}, + }, + "EverySelectedLabelMustMatch": { + reason: "Of compositions sharing one selected label, only the one carrying all of them matches", + cluster: []*un.Unstructured{prodXR1.BuildAsUnstructured(), prodStandardXR1.BuildAsUnstructured()}, + res: selecting(map[string]any{"environment": "production", "tier": "standard"}), + want: want{composition: prodStandardXR1.Build()}, + }, + "EmptySelectedValueMatchesAbsentLabel": { + reason: "A selected label with an empty value matches a composition without that label, as it always has", + cluster: []*un.Unstructured{prodXR1.BuildAsUnstructured(), stagingXR1.BuildAsUnstructured()}, + res: selecting(map[string]any{"environment": "production", "tier": ""}), + want: want{composition: prodXR1.Build()}, + }, + "SeveralMatchIsAmbiguous": { + reason: "Two compositions of the type carrying the labels fail as ambiguous rather than either being chosen", + cluster: []*un.Unstructured{prodXR1.BuildAsUnstructured(), prodOther.BuildAsUnstructured(), secondProdXR1.BuildAsUnstructured()}, + res: selecting(map[string]any{"environment": "production"}), + want: want{err: "ambiguous composition selection: multiple compositions match"}, + }, + "InvalidLabelValueMatchesNothing": { + reason: "A label value no composition can carry matches none, rather than failing the lookup", + cluster: []*un.Unstructured{prodXR1.BuildAsUnstructured()}, + res: selecting(map[string]any{"environment": "not a valid label value"}), + want: want{err: "no compatible composition found matching labels map[environment:not a valid label value] for example.org/v1, Kind=XR1/my-xr"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + mockResource := tu.NewMockResourceClient(). + WithListableResources(tt.cluster...). + WithErrorOnListOf(compositionGVK()). + Build() + + c := NewCompositionClient(mockResource, tu.NewMockDefinitionClient().WithV1XRDForXR().Build(), tu.TestLogger(t, false)) + + got, err := c.FindMatchingComposition(t.Context(), tt.res) + + if tt.want.err != "" { + if err == nil || !strings.Contains(err.Error(), tt.want.err) { + t.Errorf("\n%s\nFindMatchingComposition(...): want error containing %q, got composition %v and error %v", + tt.reason, tt.want.err, got.Composition, err) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nFindMatchingComposition(...): unexpected error: %v", tt.reason, err) + } + + if diff := cmp.Diff(tt.want.composition, got.Composition); diff != "" { + t.Errorf("\n%s\nFindMatchingComposition(...): -want composition, +got composition:\n%s", tt.reason, diff) + } + }) + } +} + +// TestFindByTypeReferenceListsOnce resolves composites that name no composition, sequentially and +// concurrently, on a cluster that serves the list of compositions only once. Each resolves to the +// one composition of its type, so later lookups are served from what the first one listed. Run it +// with -race. +func TestFindByTypeReferenceListsOnce(t *testing.T) { + xr1 := tu.NewComposition("xr1-comp").WithCompositeTypeRef("example.org/v1", "XR1") + xr2 := tu.NewComposition("xr2-comp").WithCompositeTypeRef("example.org/v1", "XR2") + + var listed atomic.Bool + + mockResource := tu.NewMockResourceClient(). + WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { + if gvk != compositionGVK() { + return nil, errors.Errorf("unexpected list of %s", gvk) + } + + if listed.Swap(true) { + return nil, errors.New("compositions were already listed") + } + + return []*un.Unstructured{xr1.BuildAsUnstructured(), xr2.BuildAsUnstructured()}, nil + }). + Build() + + c := NewCompositionClient(mockResource, tu.NewMockDefinitionClient().WithV1XRDForXR().Build(), tu.TestLogger(t, false)) + + want := map[string]*apiextensionsv1.Composition{"XR1": xr1.Build(), "XR2": xr2.Build()} + + resolve := func(kind string) { + got, err := c.FindMatchingComposition(t.Context(), tu.NewResource("example.org/v1", kind, "my-xr").Build()) + if err != nil { + t.Errorf("FindMatchingComposition(%s): unexpected error: %v", kind, err) + return + } + + if diff := cmp.Diff(want[kind], got.Composition); diff != "" { + t.Errorf("FindMatchingComposition(%s): -want composition, +got composition:\n%s", kind, diff) + } + } + + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { resolve("XR1") }) + wg.Go(func() { resolve("XR2") }) + } + + wg.Wait() + + resolve("XR1") + resolve("XR2") +} + func TestDefaultCompositionClient_GetComposition(t *testing.T) { ctx := t.Context() @@ -905,6 +1078,7 @@ func TestDefaultCompositionClient_GetComposition(t *testing.T) { revisionClient: NewCompositionRevisionClient(mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: tt.cache, + missing: make(map[string]error), } comp, err := c.GetComposition(ctx, tt.name) @@ -1097,13 +1271,14 @@ func TestDefaultCompositionClient_Initialize(t *testing.T) { Build(), expectError: true, }, - "ListCompositionsFailed": { - reason: "Should return error when listing compositions fails", + "ListCompositionsForbidden": { + reason: "Should initialize when the credentials cannot list compositions, since compositions are fetched on demand", mockResource: tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). + WithFoundGVKs([]schema.GroupVersionKind{{Group: CrossplaneAPIExtGroup, Kind: "Composition"}}). + WithErrorOnListOf(compositionGVK()). Build(), - expectError: true, + expectError: false, }, } @@ -1561,7 +1736,6 @@ func TestDefaultCompositionClient_V2XRDWithV1StylePaths(t *testing.T) { Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV2XRDForXR(). Build(), compositions: map[string]*apiextensionsv1.Composition{ @@ -1579,25 +1753,16 @@ func TestDefaultCompositionClient_V2XRDWithV1StylePaths(t *testing.T) { reason: "Should find composition when v2 XRD uses v1-style spec.compositionSelector path", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithEmptyListResources(). + WithListableResources(tu.NewComposition("matching-comp"). + WithCompositeTypeRef("example.org/v1", "XExampleResource"). + WithLabels(map[string]string{"environment": "production"}). + BuildAsUnstructured()). Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). - WithEmptyXRDsFetch(). WithV2XRDForXR(). Build(), - compositions: map[string]*apiextensionsv1.Composition{ - "matching-comp": func() *apiextensionsv1.Composition { - comp := tu.NewComposition("matching-comp"). - WithCompositeTypeRef("example.org/v1", "XExampleResource"). - Build() - comp.SetLabels(map[string]string{ - "environment": "production", - }) - - return comp - }(), - }, + compositions: map[string]*apiextensionsv1.Composition{}, res: tu.NewResource("example.org/v1", "XExampleResource", "my-xr"). // v1-style path: spec.compositionSelector (not spec.crossplane.compositionSelector) WithSpecField("compositionSelector", map[string]any{ @@ -1627,6 +1792,8 @@ func TestDefaultCompositionClient_V2XRDWithV1StylePaths(t *testing.T) { revisionClient: NewCompositionRevisionClient(&tt.mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: tt.compositions, + missing: make(map[string]error), + selected: make(map[string][]*apiextensionsv1.Composition), } got, err := c.FindMatchingComposition(t.Context(), tt.res) @@ -2245,6 +2412,7 @@ func TestDefaultCompositionClient_ConcurrentUse(t *testing.T) { resourceClient: tu.NewMockResourceClient().WithResourcesExist(cluster...).Build(), logger: tu.TestLogger(t, false), compositions: map[string]*apiextensionsv1.Composition{}, + missing: map[string]error{}, } var wg sync.WaitGroup diff --git a/internal/diff/client/crossplane/definition_client.go b/internal/diff/client/crossplane/definition_client.go index 631e8d75..9a663b21 100644 --- a/internal/diff/client/crossplane/definition_client.go +++ b/internal/diff/client/crossplane/definition_client.go @@ -4,6 +4,8 @@ import ( "context" "sync" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" @@ -19,14 +21,9 @@ import ( const CompositeResourceDefinitionKind = "CompositeResourceDefinition" // DefinitionClient handles Crossplane definitions (XRDs). -// -//nolint:interfacebloat // The 6 methods are cohesively about XRD lookup; splitting just to satisfy the linter would create surface without value. type DefinitionClient interface { core.Initializable - // GetXRDs gets all XRDs in the cluster - GetXRDs(ctx context.Context) ([]*un.Unstructured, error) - // GetXRDForClaim finds the XRD that defines the given claim type GetXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) @@ -50,25 +47,41 @@ type DefinitionClient interface { // DefaultDefinitionClient implements DefinitionClient. type DefaultDefinitionClient struct { resourceClient kubernetes.ResourceClient + typeConverter kubernetes.TypeConverter logger logging.Logger - // XRDs cache + // XRDs cache: every XRD in the cluster, listed by listXRDs at most once. gvks []schema.GroupVersionKind xrds []*un.Unstructured xrdsMutex sync.RWMutex xrdsLoaded bool + + // The XRD that defines each XR and claim GVK looked up so far, or nil where none does. + // byGVKMu guards both, and is never held across a cluster request. + xrdsForXR map[schema.GroupVersionKind]*un.Unstructured + xrdsForClaim map[schema.GroupVersionKind]*un.Unstructured + byGVKMu sync.Mutex +} + +// crdGVK is the GVK of the CustomResourceDefinitions Crossplane generates for each XRD's XR and claim. +func crdGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} } -// NewDefinitionClient creates a new DefaultDefinitionClient. -func NewDefinitionClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) DefinitionClient { +// NewDefinitionClient creates a new DefaultDefinitionClient. The type converter supplies the plural +// resource names XRDs and their CRDs are named by. +func NewDefinitionClient(resourceClient kubernetes.ResourceClient, typeConverter kubernetes.TypeConverter, logger logging.Logger) DefinitionClient { return &DefaultDefinitionClient{ resourceClient: resourceClient, + typeConverter: typeConverter, logger: logger, xrds: []*un.Unstructured{}, + xrdsForXR: make(map[schema.GroupVersionKind]*un.Unstructured), + xrdsForClaim: make(map[schema.GroupVersionKind]*un.Unstructured), } } -// Initialize loads XRDs into the cache. +// Initialize discovers the served XRD versions. XRDs are fetched on demand. func (c *DefaultDefinitionClient) Initialize(ctx context.Context) error { c.logger.Debug("Initializing definition client") @@ -79,19 +92,13 @@ func (c *DefaultDefinitionClient) Initialize(ctx context.Context) error { c.gvks = gvks - // Load XRDs - _, err = c.GetXRDs(ctx) - if err != nil { - return errors.Wrap(err, "cannot load XRDs") - } - - c.logger.Debug("Definition client initialized", "xrdsCount", len(c.xrds)) + c.logger.Debug("Definition client initialized") return nil } -// GetXRDs gets all XRDs in the cluster. -func (c *DefaultDefinitionClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, error) { +// listXRDs lists every XRD in the cluster, at most once per client. +func (c *DefaultDefinitionClient) listXRDs(ctx context.Context) ([]*un.Unstructured, error) { // Check if XRDs are already loaded c.xrdsMutex.RLock() @@ -135,104 +142,197 @@ func (c *DefaultDefinitionClient) GetXRDs(ctx context.Context) ([]*un.Unstructur // GetXRDForClaim finds the XRD that defines the given claim type. func (c *DefaultDefinitionClient) GetXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - c.logger.Debug("Looking for XRD that defines claim", - "gvk", gvk.String()) + xrd, err := c.xrdForClaim(ctx, gvk) + if err != nil { + return nil, err + } - // Get all XRDs - xrds, err := c.GetXRDs(ctx) + // A copy, so a caller that modifies what it gets does not modify this cache, which concurrent callers share. + return xrd.DeepCopy(), nil +} + +// GetXRDForXR finds the XRD that defines the given XR type. +func (c *DefaultDefinitionClient) GetXRDForXR(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + c.logger.Debug("Looking for XRD that defines XR", "gvk", gvk.String()) + + xrd, err := c.cachedLookup(ctx, c.xrdsForXR, gvk, c.fetchXRDForXR) if err != nil { - return nil, errors.Wrap(err, "cannot get XRDs") + return nil, err } - // Loop through XRDs to find one that defines this GVK as a claim - for _, xrd := range xrds { - claimGroup, found, _ := un.NestedString(xrd.Object, "spec", "group") + if xrd == nil { + return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + } - // Skip if group doesn't match - if !found || claimGroup != gvk.Group { - continue - } + c.logger.Debug("Found matching XRD for XR type", "gvk", gvk.String(), "xrd", xrd.GetName()) - // Check claim kind - claimNames, found, _ := un.NestedMap(xrd.Object, "spec", "claimNames") - if !found || claimNames == nil { - continue - } + // A copy, so a caller that modifies what it gets does not modify this cache, which concurrent callers share. + return xrd.DeepCopy(), nil +} - claimKind, found, _ := un.NestedString(claimNames, "kind") - if !found || claimKind != gvk.Kind { - continue - } +// xrdForClaim is GetXRDForClaim without the copy, for callers that only read the XRD. +func (c *DefaultDefinitionClient) xrdForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + c.logger.Debug("Looking for XRD that defines claim", "gvk", gvk.String()) + + xrd, err := c.cachedLookup(ctx, c.xrdsForClaim, gvk, c.fetchXRDForClaim) + if err != nil { + return nil, err + } + + if xrd == nil { + return nil, errors.Errorf("no XRD found that defines claim type %s", gvk.String()) + } + + c.logger.Debug("Found matching XRD for claim type", "gvk", gvk.String(), "xrd", xrd.GetName()) + + return xrd, nil +} - c.logger.Debug("Found matching XRD for claim type", - "gvk", gvk.String(), - "xrd", xrd.GetName()) +// cachedLookup returns cache's entry for gvk, nil if gvk is cached as defined by no XRD, and otherwise +// fetches it and caches the result, a miss included. A failed fetch is not cached. +func (c *DefaultDefinitionClient) cachedLookup(ctx context.Context, cache map[schema.GroupVersionKind]*un.Unstructured, gvk schema.GroupVersionKind, + fetch func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error), +) (*un.Unstructured, error) { + c.byGVKMu.Lock() + xrd, ok := cache[gvk] + c.byGVKMu.Unlock() + if ok { return xrd, nil } - return nil, errors.Errorf("no XRD found that defines claim type %s", gvk.String()) + if gvk.Group == "" { + // A core type, such as a ConfigMap. No XRD defines one: an XRD's group is required. + return nil, nil + } + + xrd, err := fetch(ctx, gvk) + if err != nil { + return nil, err + } + + c.byGVKMu.Lock() + cache[gvk] = xrd + c.byGVKMu.Unlock() + + return xrd, nil } -// GetXRDForXR finds the XRD that defines the given XR type. -func (c *DefaultDefinitionClient) GetXRDForXR(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { - c.logger.Debug("Looking for XRD that defines XR", - "gvk", gvk.String()) +// fetchXRDForXR gets the XRD that defines gvk as an XR, or nil if none does. An XRD is named +// . of the XR type it defines, and discovery knows the XR type's plural. +func (c *DefaultDefinitionClient) fetchXRDForXR(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + plural, err := c.typeConverter.GetResourceNameForGVK(ctx, gvk) + if err != nil { + // Not served, so no XRD defines it at this version. Not cached: discovery already caches it. + return nil, errors.Wrapf(err, "no XRD found that defines XR type %s", gvk.String()) + } + + xrd, err := c.getXRD(ctx, plural+"."+gvk.Group) + if err != nil || xrd == nil || !definesXR(xrd, gvk) { + return nil, err + } + + return xrd, nil +} - // Get all XRDs - xrds, err := c.GetXRDs(ctx) +// fetchXRDForClaim gets the XRD that defines gvk as a claim, or nil if none does. Crossplane names a +// claim's CRD . and makes the XRD its controller. A CRD without a controller reference +// (applied by hand rather than generated by Crossplane) is matched against every XRD, listed once. +func (c *DefaultDefinitionClient) fetchXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + plural, err := c.typeConverter.GetResourceNameForGVK(ctx, gvk) if err != nil { - return nil, errors.Wrap(err, "cannot get XRDs") + // Not served, so no XRD defines it at this version. Not cached: discovery already caches it. + return nil, errors.Wrapf(err, "no XRD found that defines claim type %s", gvk.String()) } - // Loop through XRDs to find one that defines this GVK as an XR - for _, xrd := range xrds { - xrGroup, found, _ := un.NestedString(xrd.Object, "spec", "group") + crdName := plural + "." + gvk.Group - // Skip if group doesn't match - if !found || xrGroup != gvk.Group { - continue - } + crd, err := c.resourceClient.GetResource(ctx, crdGVK(), "", crdName) + if apierrors.IsNotFound(err) { + return nil, nil + } - // Check XR kind - xrKind, found, _ := un.NestedString(xrd.Object, "spec", "names", "kind") - if !found || xrKind != gvk.Kind { - continue + if err != nil { + return nil, errors.Wrapf(err, "cannot get CRD %s", crdName) + } + + owner := metav1.GetControllerOf(crd) + if owner == nil { + c.logger.Debug("CRD has no controller; looking for its claim type among all XRDs", "crd", crdName) + + xrds, err := c.listXRDs(ctx) + if err != nil { + return nil, errors.Wrap(err, "cannot get XRDs") } - // Check version matches - versions, found, _ := un.NestedSlice(xrd.Object, "spec", "versions") - if !found { - continue + for _, xrd := range xrds { + if definesClaim(xrd, gvk) { + return xrd, nil + } } - versionMatches := false + return nil, nil + } - for _, v := range versions { - version, ok := v.(map[string]any) - if !ok { - continue - } + // A CRD controlled by something other than an XRD, such as a provider's, names no XRD that + // defines the claim, and definesClaim rejects any XRD that shares its owner's name. + xrd, err := c.getXRD(ctx, owner.Name) + if err != nil || xrd == nil || !definesClaim(xrd, gvk) { + return nil, err + } - name, found, _ := un.NestedString(version, "name") - if found && name == gvk.Version { - versionMatches = true - break - } - } + return xrd, nil +} + +// getXRD gets the named XRD at the first served XRD version (the version listing them would have +// returned it at), or nil if there is none. +func (c *DefaultDefinitionClient) getXRD(ctx context.Context, name string) (*un.Unstructured, error) { + if len(c.gvks) == 0 { + return nil, nil + } + + xrd, err := c.resourceClient.GetResource(ctx, c.gvks[0], "", name) + if apierrors.IsNotFound(err) { + return nil, nil + } + + if err != nil { + return nil, errors.Wrapf(err, "cannot get XRD %s", name) + } + + return xrd, nil +} - if !versionMatches { +// definesXR reports whether xrd defines gvk as an XR: its group and kind, at one of its versions. +func definesXR(xrd *un.Unstructured, gvk schema.GroupVersionKind) bool { + xrGroup, _, _ := un.NestedString(xrd.Object, "spec", "group") + xrKind, _, _ := un.NestedString(xrd.Object, "spec", "names", "kind") + + if xrGroup != gvk.Group || xrKind != gvk.Kind { + return false + } + + versions, _, _ := un.NestedSlice(xrd.Object, "spec", "versions") + for _, v := range versions { + version, ok := v.(map[string]any) + if !ok { continue } - c.logger.Debug("Found matching XRD for XR type", - "gvk", gvk.String(), - "xrd", xrd.GetName()) - - return xrd, nil + if name, found, _ := un.NestedString(version, "name"); found && name == gvk.Version { + return true + } } - return nil, errors.Errorf("no XRD found that defines XR type %s", gvk.String()) + return false +} + +// definesClaim reports whether xrd defines gvk as a claim: its group and claim kind, at any version. +func definesClaim(xrd *un.Unstructured, gvk schema.GroupVersionKind) bool { + group, _, _ := un.NestedString(xrd.Object, "spec", "group") + claimKind, found, _ := un.NestedString(xrd.Object, "spec", "claimNames", "kind") + + return found && group == gvk.Group && claimKind == gvk.Kind } // IsClaimResource checks if the given resource is a claim type by attempting @@ -241,7 +341,7 @@ func (c *DefaultDefinitionClient) IsClaimResource(ctx context.Context, resource gvk := resource.GroupVersionKind() // Try to find an XRD that defines this resource type as a claim - _, err := c.GetXRDForClaim(ctx, gvk) + _, err := c.xrdForClaim(ctx, gvk) if err != nil { c.logger.Debug("Resource is not a claim type", "gvk", gvk.String(), "error", err) return false diff --git a/internal/diff/client/crossplane/definition_client_test.go b/internal/diff/client/crossplane/definition_client_test.go index 88195b2a..709fe074 100644 --- a/internal/diff/client/crossplane/definition_client_test.go +++ b/internal/diff/client/crossplane/definition_client_test.go @@ -22,7 +22,7 @@ var ( XRDv2GVK = schema.GroupVersionKind{Group: CrossplaneAPIExtGroup, Version: "v2", Kind: CompositeResourceDefinitionKind} ) -func TestDefaultDefinitionClient_GetXRDs(t *testing.T) { +func TestDefaultDefinitionClient_listXRDs(t *testing.T) { ctx := t.Context() // Create test XRDs @@ -142,34 +142,34 @@ func TestDefaultDefinitionClient_GetXRDs(t *testing.T) { gvks: tt.fields.gvks, } - got, err := c.GetXRDs(ctx) + got, err := c.listXRDs(ctx) if tt.wantErr { if err == nil { - t.Errorf("\n%s\nGetXRDs(): expected error but got none", tt.reason) + t.Errorf("\n%s\nlistXRDs(): expected error but got none", tt.reason) return } if tt.errSubstring != "" && !strings.Contains(err.Error(), tt.errSubstring) { - t.Errorf("\n%s\nGetXRDs(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) + t.Errorf("\n%s\nlistXRDs(): expected error containing %q, got %q", tt.reason, tt.errSubstring, err.Error()) } return } if err != nil { - t.Errorf("\n%s\nGetXRDs(): unexpected error: %v", tt.reason, err) + t.Errorf("\n%s\nlistXRDs(): unexpected error: %v", tt.reason, err) return } // Verify cache state if !c.xrdsLoaded { - t.Errorf("\n%s\nGetXRDs(): cache not marked as loaded after call", tt.reason) + t.Errorf("\n%s\nlistXRDs(): cache not marked as loaded after call", tt.reason) } // Compare XRD count if diff := cmp.Diff(len(tt.want), len(got)); diff != "" { - t.Errorf("\n%s\nGetXRDs(): -want count, +got count:\n%s", tt.reason, diff) + t.Errorf("\n%s\nlistXRDs(): -want count, +got count:\n%s", tt.reason, diff) } // Check if all expected XRDs are present by name @@ -187,14 +187,14 @@ func TestDefaultDefinitionClient_GetXRDs(t *testing.T) { // Check missing XRDs for name := range wantNames { if !gotNames[name] { - t.Errorf("\n%s\nGetXRDs(): missing expected XRD with name %s", tt.reason, name) + t.Errorf("\n%s\nlistXRDs(): missing expected XRD with name %s", tt.reason, name) } } // Check unexpected XRDs for name := range gotNames { if !wantNames[name] { - t.Errorf("\n%s\nGetXRDs(): unexpected XRD with name %s", tt.reason, name) + t.Errorf("\n%s\nlistXRDs(): unexpected XRD with name %s", tt.reason, name) } } }) @@ -202,10 +202,8 @@ func TestDefaultDefinitionClient_GetXRDs(t *testing.T) { } func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { - ctx := t.Context() - // Create test XRDs - xrdWithClaimKind := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-with-claim"). + xrdWithClaimKind := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xexampleresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XExampleResource", @@ -217,9 +215,10 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { "plural": "exampleclaims", "singular": "exampleclaim", }). + WithSpecField("versions", []any{map[string]any{"name": "v1"}}). Build() - xrdWithoutClaimKind := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-without-claim"). + xrdWithoutClaimKind := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xotherresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XOtherResource", @@ -234,22 +233,16 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { } tests := map[string]struct { - reason string - mockResource tu.MockResourceClient - cachedXRDs []*un.Unstructured - discoveredXRDGVKs []schema.GroupVersionKind - args args - want *un.Unstructured - wantErr bool - errSubstring string + reason string + cluster xrdCluster + args args + want *un.Unstructured + wantErr bool + errSubstring string }{ "MatchingClaimFound": { - reason: "Should return the XRD that defines the claim kind", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdWithClaimKind, xrdWithoutClaimKind}, - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + reason: "Should return the XRD that defines the claim kind", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdWithClaimKind, xrdWithoutClaimKind}, xrdGVKs: []schema.GroupVersionKind{XRDv1GVK}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -261,11 +254,8 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { wantErr: false, }, "NoMatchingClaim": { - reason: "Should return error when no XRD defines the claim kind", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdWithoutClaimKind}, + reason: "Should return error when no XRD defines the claim kind", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdWithoutClaimKind}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -277,14 +267,9 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { wantErr: true, errSubstring: "no XRD found that defines claim type", }, - "GetXRDsError": { - reason: "Should propagate error from GetXRDs", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). - Build(), - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, - cachedXRDs: nil, // Force GetXRDs to be called + "GetError": { + reason: "Should propagate an error getting the definition from the cluster", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdWithClaimKind}, getErr: errors.New("get error")}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -294,14 +279,11 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { }, want: nil, wantErr: true, - errSubstring: "cannot get XRDs", + errSubstring: "get error", }, "DifferentGroup": { - reason: "Should not match XRD with different group", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdWithClaimKind}, + reason: "Should not match XRD with different group", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdWithClaimKind}}, args: args{ gvk: schema.GroupVersionKind{ Group: "different.org", // Different group @@ -314,11 +296,8 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { errSubstring: "no XRD found that defines claim type", }, "DifferentKind": { - reason: "Should not match XRD with different claim kind", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdWithClaimKind}, + reason: "Should not match XRD with different claim kind", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdWithClaimKind}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", // Same group @@ -334,13 +313,8 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - c := &DefaultDefinitionClient{ - resourceClient: &tt.mockResource, - logger: tu.TestLogger(t, false), - xrds: tt.cachedXRDs, - xrdsLoaded: tt.cachedXRDs != nil, // Only mark as loaded if we have cached XRDs - gvks: tt.discoveredXRDGVKs, - } + ctx := t.Context() + c := tt.cluster.client(ctx, t) got, err := c.GetXRDForClaim(ctx, tt.args.gvk) @@ -383,10 +357,8 @@ func TestDefaultDefinitionClient_GetXRDForClaim(t *testing.T) { } func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { - ctx := t.Context() - // Create test XRDs - xrdForXR1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-for-xr1"). + xrdForXR1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xr1s.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XR1", @@ -407,7 +379,7 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { }). Build() - xrdForXR2 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-for-xr2"). + xrdForXR2 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xr2s.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XR2", @@ -428,21 +400,16 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { } tests := map[string]struct { - reason string - mockResource tu.MockResourceClient - cachedXRDs []*un.Unstructured - discoveredXRDGVKs []schema.GroupVersionKind - args args - want *un.Unstructured - wantErr bool - errSubstring string + reason string + cluster xrdCluster + args args + want *un.Unstructured + wantErr bool + errSubstring string }{ "MatchingXRFound": { - reason: "Should return the XRD that defines the XR kind", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdForXR1, xrdForXR2}, + reason: "Should return the XRD that defines the XR kind", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdForXR1, xrdForXR2}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -454,11 +421,8 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { wantErr: false, }, "MatchingXRWithDifferentVersion": { - reason: "Should return the XRD that defines the XR kind with matching version", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdForXR1, xrdForXR2}, + reason: "Should return the XRD that defines the XR kind with matching version", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdForXR1, xrdForXR2}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -470,11 +434,8 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { wantErr: false, }, "NoMatchingXR": { - reason: "Should return error when no XRD defines the XR kind", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdForXR1, xrdForXR2}, + reason: "Should return error when no XRD defines the XR kind", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdForXR1, xrdForXR2}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -486,14 +447,9 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { wantErr: true, errSubstring: "no XRD found that defines XR type", }, - "GetXRDsError": { - reason: "Should propagate error from GetXRDs", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). - Build(), - cachedXRDs: nil, // Force GetXRDs to be called - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + "GetError": { + reason: "Should propagate an error getting the definition from the cluster", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdForXR1}, getErr: errors.New("get error")}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -503,14 +459,11 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { }, want: nil, wantErr: true, - errSubstring: "cannot get XRDs", + errSubstring: "get error", }, "DifferentGroup": { - reason: "Should not match XRD with different group", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdForXR1}, + reason: "Should not match XRD with different group", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdForXR1}}, args: args{ gvk: schema.GroupVersionKind{ Group: "different.org", // Different group @@ -523,11 +476,8 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { errSubstring: "no XRD found that defines XR type", }, "VersionNotFound": { - reason: "Should not match XRD if version doesn't exist", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdForXR1}, + reason: "Should not match XRD if version doesn't exist", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdForXR1}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -543,13 +493,8 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - c := &DefaultDefinitionClient{ - resourceClient: &tt.mockResource, - logger: tu.TestLogger(t, false), - xrds: tt.cachedXRDs, - xrdsLoaded: tt.cachedXRDs != nil, // Only mark as loaded if we have cached XRDs - gvks: tt.discoveredXRDGVKs, - } + ctx := t.Context() + c := tt.cluster.client(ctx, t) got, err := c.GetXRDForXR(ctx, tt.args.gvk) @@ -586,75 +531,55 @@ func TestDefaultDefinitionClient_GetXRDForXR(t *testing.T) { } func TestDefaultDefinitionClient_Initialize(t *testing.T) { - ctx := t.Context() - tests := map[string]struct { reason string - mockResource tu.MockResourceClient - wantErr bool + mockResource *tu.MockResourceClient + wantErr string }{ - "SuccessfulInitialization": { - reason: "Should successfully initialize the client", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - WithFoundGVKs([]schema.GroupVersionKind{XRDv1GVK}). - WithEmptyListResources(). + "XRDListForbidden": { + reason: "Should initialize though the credentials cannot list XRDs, since XRDs are fetched on demand", + mockResource: tu.NewMockResourceClient(). + WithFoundGVKs([]schema.GroupVersionKind{XRDv1GVK, XRDv2GVK}). + WithErrorOnListOf(XRDv1GVK, XRDv2GVK). Build(), - wantErr: false, }, - "GetXRDsError": { - reason: "Should return error when getting XRDs fails", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). + "DiscoveryError": { + reason: "Should return an error when the served XRD versions cannot be discovered", + mockResource: tu.NewMockResourceClient(). + WithoutFoundGVKs("discovery error"). Build(), - wantErr: true, + wantErr: "cannot get XRD GVKs: discovery error", }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { - c := &DefaultDefinitionClient{ - resourceClient: &tt.mockResource, - logger: tu.TestLogger(t, false), - } - - err := c.Initialize(ctx) + c := NewDefinitionClient(tt.mockResource, tu.NewMockTypeConverter().Build(), tu.TestLogger(t, false)) - if tt.wantErr && err == nil { - t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) - } else if !tt.wantErr && err != nil { - t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + gotErr := "" + if err := c.Initialize(t.Context()); err != nil { + gotErr = err.Error() } - // If we succeeded, verify that XRDs are now loaded - if !tt.wantErr { - if !c.xrdsLoaded { - t.Errorf("\n%s\nInitialize(): XRDs not marked as loaded after successful initialization", tt.reason) - } + if diff := cmp.Diff(tt.wantErr, gotErr); diff != "" { + t.Errorf("\n%s\nInitialize(): -want error, +got error:\n%s", tt.reason, diff) } }) } } func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { - ctx := t.Context() - tests := map[string]struct { - reason string - mockResource tu.MockResourceClient - cachedXRDs []*un.Unstructured - resource *un.Unstructured - expected bool + reason string + cluster xrdCluster + resource *un.Unstructured + expected bool }{ "ResourceIsClaim": { reason: "Should return true when resource is a claim type", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{ + cluster: xrdCluster{xrds: []*un.Unstructured{ // Create a mock XRD that defines this as a claim - tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "testclaims.example.org"). + tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xtestresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XTestResource", @@ -666,8 +591,9 @@ func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { "plural": "testclaims", "singular": "testclaim", }). + WithSpecField("versions", []any{map[string]any{"name": "v1"}}). Build(), - }, + }}, resource: tu.NewResource("example.org/v1", "TestClaim", "test-claim"). InNamespace("default"). Build(), @@ -675,10 +601,7 @@ func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { }, "ResourceIsNotClaim": { reason: "Should return false when resource is not a claim type", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{ + cluster: xrdCluster{xrds: []*un.Unstructured{ // Create a mock XRD that defines this as an XR (no claimNames) tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "testxrs.example.org"). WithSpecField("group", "example.org"). @@ -689,31 +612,24 @@ func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { }). // No claimNames field Build(), - }, + }}, resource: tu.NewResource("example.org/v1", "TestXR", "test-xr"). InNamespace("default"). Build(), expected: false, }, - "GetXRDForClaimError": { - reason: "Should return false when GetXRDForClaim fails", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). - Build(), - cachedXRDs: nil, // Force GetXRDs to fail + "GetError": { + reason: "Should return false when getting the definition from the cluster fails", + cluster: xrdCluster{getErr: errors.New("get error")}, resource: tu.NewResource("example.org/v1", "TestResource", "test-resource"). Build(), expected: false, // Should return false on error }, "NoMatchingXRD": { reason: "Should return false when no matching XRD exists", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{ + cluster: xrdCluster{xrds: []*un.Unstructured{ // Create an XRD that doesn't match our resource - tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "otherclaims.example.org"). + tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xotherresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XOtherResource", @@ -726,7 +642,7 @@ func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { "singular": "otherclaim", }). Build(), - }, + }}, resource: tu.NewResource("example.org/v1", "TestClaim", "test-claim"). Build(), expected: false, @@ -735,12 +651,8 @@ func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - c := &DefaultDefinitionClient{ - resourceClient: &tt.mockResource, - logger: tu.TestLogger(t, false), - xrds: tt.cachedXRDs, - xrdsLoaded: tt.cachedXRDs != nil, // Only mark as loaded if we have cached XRDs - } + ctx := t.Context() + c := tt.cluster.client(ctx, t) // Call the function under test result := c.IsClaimResource(ctx, tt.resource) @@ -754,10 +666,8 @@ func TestDefaultDefinitionClient_IsClaimResource(t *testing.T) { } func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { - ctx := t.Context() - // v1 XRD: defines an XR (no claimNames here for simplicity). - xrdV1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-v1"). + xrdV1 := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xlegacyresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XLegacyResource", @@ -773,7 +683,7 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { // returns SchemaModern. (A v2 XRD without scope, or with // scope=LegacyCluster, would correctly resolve as SchemaLegacy — see // the V2XRD_LegacyScope_LegacySchema case below.) - xrdV2 := tu.NewResource("apiextensions.crossplane.io/v2", CompositeResourceDefinitionKind, "xrd-v2"). + xrdV2 := tu.NewResource("apiextensions.crossplane.io/v2", CompositeResourceDefinitionKind, "xmodernresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XModernResource", @@ -791,7 +701,7 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { // conversion to v2 storage form: the converted v2 object preserves // scope="LegacyCluster" verbatim. Our scope-based detector should treat // this as Legacy regardless of the apiVersion stamp. - xrdV2LegacyScope := tu.NewResource("apiextensions.crossplane.io/v2", CompositeResourceDefinitionKind, "xrd-v2-legacy-scope"). + xrdV2LegacyScope := tu.NewResource("apiextensions.crossplane.io/v2", CompositeResourceDefinitionKind, "xlegacyscopedresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XLegacyScopedResource", @@ -808,7 +718,7 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { // via the claim path when given a claim GVK (the XR/claim distinction is // a Crossplane CompositeResourceDefinition feature, not a Go type — the // claim kind here is just the user-supplied "ClaimedResource" below). - xrdV1WithClaim := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xrd-v1-with-claim"). + xrdV1WithClaim := tu.NewResource("apiextensions.crossplane.io/v1", CompositeResourceDefinitionKind, "xclaimedresources.example.org"). WithSpecField("group", "example.org"). WithSpecField("names", map[string]any{ "kind": "XClaimedResource", @@ -830,22 +740,16 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { } tests := map[string]struct { - reason string - mockResource tu.MockResourceClient - cachedXRDs []*un.Unstructured - discoveredXRDGVKs []schema.GroupVersionKind - args args - want ucomposite.Schema - wantErr bool - errSubstring string + reason string + cluster xrdCluster + args args + want ucomposite.Schema + wantErr bool + errSubstring string }{ "V1XRD_LegacySchema": { - reason: "Should return SchemaLegacy for an XR defined by a v1 XRD", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdV1}, - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + reason: "Should return SchemaLegacy for an XR defined by a v1 XRD", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdV1}, xrdGVKs: []schema.GroupVersionKind{XRDv1GVK}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -856,12 +760,8 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { want: ucomposite.SchemaLegacy, }, "V2XRD_ModernSchema": { - reason: "Should return SchemaModern for a v2 XRD with non-legacy scope", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdV2}, - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv2GVK}, + reason: "Should return SchemaModern for a v2 XRD with non-legacy scope", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdV2}, xrdGVKs: []schema.GroupVersionKind{XRDv2GVK}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -872,12 +772,8 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { want: ucomposite.SchemaModern, }, "V2XRD_LegacyScope_LegacySchema": { - reason: "Should return SchemaLegacy when a v2-form XRD's scope is LegacyCluster (e.g. v1 XRD round-tripped through conversion)", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdV2LegacyScope}, - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv2GVK}, + reason: "Should return SchemaLegacy when a v2-form XRD's scope is LegacyCluster (e.g. v1 XRD round-tripped through conversion)", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdV2LegacyScope}, xrdGVKs: []schema.GroupVersionKind{XRDv2GVK}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -888,12 +784,8 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { want: ucomposite.SchemaLegacy, }, "ClaimGVK_ResolvesViaClaimPath": { - reason: "Should return the schema of the XRD that publishes a claim, given the claim GVK", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdV1WithClaim}, - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv1GVK}, + reason: "Should return the schema of the XRD that publishes a claim, given the claim GVK", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdV1WithClaim}, xrdGVKs: []schema.GroupVersionKind{XRDv1GVK}}, args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -904,12 +796,8 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { want: ucomposite.SchemaLegacy, }, "NoXRDFound_Error": { - reason: "Should return an error when no XRD defines the GVK", - mockResource: *tu.NewMockResourceClient(). - WithSuccessfulInitialize(). - Build(), - cachedXRDs: []*un.Unstructured{xrdV2}, // only v2 XRD known - discoveredXRDGVKs: []schema.GroupVersionKind{XRDv2GVK}, + reason: "Should return an error when no XRD defines the GVK", + cluster: xrdCluster{xrds: []*un.Unstructured{xrdV2}, xrdGVKs: []schema.GroupVersionKind{XRDv2GVK}}, // only v2 XRD known args: args{ gvk: schema.GroupVersionKind{ Group: "example.org", @@ -924,13 +812,8 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { for name, tt := range tests { t.Run(name, func(t *testing.T) { - c := &DefaultDefinitionClient{ - resourceClient: &tt.mockResource, - logger: tu.TestLogger(t, false), - xrds: tt.cachedXRDs, - xrdsLoaded: tt.cachedXRDs != nil, - gvks: tt.discoveredXRDGVKs, - } + ctx := t.Context() + c := tt.cluster.client(ctx, t) got, err := c.GetCompositeSchema(ctx, tt.args.gvk) @@ -958,3 +841,336 @@ func TestDefaultDefinitionClient_GetCompositeSchema(t *testing.T) { }) } } + +// xrdCluster is a cluster holding XRDs and the CRDs Crossplane generates for them, with discovery +// serving each XR and claim type at every version its XRD lists. +type xrdCluster struct { + xrds []*un.Unstructured + // xrdGVKs are the XRD versions discovery serves, each serving every XRD. Default: v1 only. + xrdGVKs []schema.GroupVersionKind + // unestablished are XRDs whose types are not served, so they have no CRD of their own. + unestablished []*un.Unstructured + // managed are served types no XRD defines, such as managed resources. + managed []schema.GroupVersionKind + // unownedClaimCRDs drops the controller reference from claim CRDs, as on a CRD applied by hand. + unownedClaimCRDs bool + // listForbidden denies listing XRDs, as for credentials without that permission. + listForbidden bool + // getErr, if set, is what getting any XRD or CRD fails with. + getErr error +} + +// client returns an initialized DefaultDefinitionClient over the cluster. +func (x xrdCluster) client(ctx context.Context, t *testing.T) *DefaultDefinitionClient { + t.Helper() + + xrdGVKs := x.xrdGVKs + if len(xrdGVKs) == 0 { + xrdGVKs = []schema.GroupVersionKind{XRDv1GVK} + } + + served := make([]*un.Unstructured, 0, len(xrdGVKs)*(len(x.xrds)+len(x.unestablished))) + + resources := make([]*un.Unstructured, 0, 3*len(x.xrds)) + plurals := map[schema.GroupVersionKind]string{} + + for _, gvk := range x.managed { + plurals[gvk] = strings.ToLower(gvk.Kind) + "s" + // A provider's CRD, which its ProviderRevision controls. + resources = append(resources, tu.NewResource("apiextensions.k8s.io/v1", "CustomResourceDefinition", plurals[gvk]+"."+gvk.Group). + WithControllerReference("ProviderRevision", "provider-nop-abc123", "pkg.crossplane.io/v1", "rev-uid"). + Build()) + } + + for _, xrd := range append(append([]*un.Unstructured{}, x.xrds...), x.unestablished...) { + // The apiserver serves an XRD at every XRD version, converting it. + for _, gvk := range xrdGVKs { + v := xrd.DeepCopy() + v.SetAPIVersion(gvk.GroupVersion().String()) + served = append(served, v) + } + } + + for _, xrd := range x.xrds { + group, _, _ := un.NestedString(xrd.Object, "spec", "group") + versions, _, _ := un.NestedSlice(xrd.Object, "spec", "versions") + + serve := func(kind, plural string, owned bool) { + crd := tu.NewResource("apiextensions.k8s.io/v1", "CustomResourceDefinition", plural+"."+group) + if owned { + crd = crd.WithControllerReference(CompositeResourceDefinitionKind, xrd.GetName(), xrd.GetAPIVersion(), "xrd-uid") + } + + resources = append(resources, crd.Build()) + + for _, v := range versions { + name, _, _ := un.NestedString(v.(map[string]any), "name") + plurals[schema.GroupVersionKind{Group: group, Version: name, Kind: kind}] = plural + } + } + + kind, _, _ := un.NestedString(xrd.Object, "spec", "names", "kind") + plural, _, _ := un.NestedString(xrd.Object, "spec", "names", "plural") + serve(kind, plural, true) + + if claimKind, ok, _ := un.NestedString(xrd.Object, "spec", "claimNames", "kind"); ok { + claimPlural, _, _ := un.NestedString(xrd.Object, "spec", "claimNames", "plural") + serve(claimKind, claimPlural, !x.unownedClaimCRDs) + } + } + + rb := tu.NewMockResourceClient(). + WithFoundGVKs(xrdGVKs). + WithResourcesExist(append(resources, served...)...). + WithListableResources(served...) + if x.getErr != nil { + rb = rb.WithGetResourceError(x.getErr) + } + + if x.listForbidden { + rb = rb.WithErrorOnListOf(xrdGVKs...) + } + + tc := tu.NewMockTypeConverter(). + WithGetResourceNameForGVK(func(_ context.Context, gvk schema.GroupVersionKind) (string, error) { + if p, ok := plurals[gvk]; ok { + return p, nil + } + + return "", errors.Errorf("no resource found for kind %s in group version %s", gvk.Kind, gvk.GroupVersion()) + }). + Build() + + c, ok := NewDefinitionClient(rb.Build(), tc, tu.TestLogger(t, false)).(*DefaultDefinitionClient) + if !ok { + t.Fatal("NewDefinitionClient did not return a *DefaultDefinitionClient") + } + + if err := c.Initialize(ctx); err != nil { + t.Fatalf("Initialize(): unexpected error: %v", err) + } + + return c +} + +// lookupXRDTwice calls lookup twice, as concurrent or repeated diffs do, and returns the name of the +// XRD each call found ("" for none) and its error. It renames what the first call returns, so the +// second shows whether a caller's change to an XRD it was given reaches later callers. +func lookupXRDTwice(lookup func() (*un.Unstructured, error)) (names, errs []string) { + for range 2 { + xrd, err := lookup() + + name, msg := "", "" + if xrd != nil { + name = xrd.GetName() + xrd.SetName("modified-by-caller") + } + + if err != nil { + msg = err.Error() + } + + names = append(names, name) + errs = append(errs, msg) + } + + return names, errs +} + +func TestGetXRDForXRWithoutListPermission(t *testing.T) { + // A v1 XRD serving its XR at two versions. + v1XRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xbuckets.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{"kind": "XBucket", "plural": "xbuckets"}). + WithSpecField("versions", []any{map[string]any{"name": "v1alpha1"}, map[string]any{"name": "v1"}}). + Build() + + // A v2 XRD of a namespaced XR, in the same group as the v1 XRD. + v2XRD := tu.NewResource(CrossplaneAPIExtGroupV2, CompositeResourceDefinitionKind, "databases.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("scope", "Namespaced"). + WithSpecField("names", map[string]any{"kind": "Database", "plural": "databases"}). + WithSpecField("versions", []any{map[string]any{"name": "v1"}}). + Build() + + // An XRD that never established: a provider's CRD already has its name, so discovery + // serves the provider's kind under its plural, not the XRD's kind. + collidingXRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "buckets.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{"kind": "XStorage", "plural": "buckets"}). + WithSpecField("versions", []any{map[string]any{"name": "v1"}}). + Build() + + bothXRDVersions := []schema.GroupVersionKind{XRDv1GVK, XRDv2GVK} + cluster := xrdCluster{ + xrds: []*un.Unstructured{v1XRD, v2XRD}, + xrdGVKs: bothXRDVersions, + unestablished: []*un.Unstructured{collidingXRD}, + managed: []schema.GroupVersionKind{{Group: "example.org", Version: "v1", Kind: "Bucket"}}, + listForbidden: true, + } + + type want struct { + names []string + errs []string + } + + tests := map[string]struct { + reason string + gvk schema.GroupVersionKind + want want + }{ + "V1XRDFirstVersion": { + reason: "Should get the v1 XRD for its XR at the first version it serves", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1alpha1", Kind: "XBucket"}, + want: want{names: []string{"xbuckets.example.org", "xbuckets.example.org"}, errs: []string{"", ""}}, + }, + "V1XRDSecondVersion": { + reason: "Should get the v1 XRD for its XR at another version it serves", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"}, + want: want{names: []string{"xbuckets.example.org", "xbuckets.example.org"}, errs: []string{"", ""}}, + }, + "V2XRD": { + reason: "Should get the v2 XRD for its XR", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Database"}, + want: want{names: []string{"databases.example.org", "databases.example.org"}, errs: []string{"", ""}}, + }, + "VersionNotServed": { + reason: "Should find no XRD for an XR kind at a version its XRD does not serve", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v2", Kind: "XBucket"}, + want: want{names: []string{"", ""}, errs: []string{ + "no XRD found that defines XR type example.org/v2, Kind=XBucket: no resource found for kind XBucket in group version example.org/v2", + "no XRD found that defines XR type example.org/v2, Kind=XBucket: no resource found for kind XBucket in group version example.org/v2", + }}, + }, + "CoreType": { + reason: "Should find no XRD for a core type", + gvk: schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, + want: want{names: []string{"", ""}, errs: []string{ + "no XRD found that defines XR type /v1, Kind=ConfigMap", + "no XRD found that defines XR type /v1, Kind=ConfigMap", + }}, + }, + "ManagedResource": { + reason: "Should find no XRD for a provider's type, though an XRD that never established has the name its plural gives", + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + want: want{names: []string{"", ""}, errs: []string{ + "no XRD found that defines XR type example.org/v1, Kind=Bucket", + "no XRD found that defines XR type example.org/v1, Kind=Bucket", + }}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + c := cluster.client(ctx, t) + + names, errs := lookupXRDTwice(func() (*un.Unstructured, error) { return c.GetXRDForXR(t.Context(), tt.gvk) }) + + if diff := cmp.Diff(tt.want, want{names: names, errs: errs}, cmp.AllowUnexported(want{})); diff != "" { + t.Errorf("\n%s\nGetXRDForXR(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestGetXRDForClaim(t *testing.T) { + // A v1 XRD offering a claim at two versions. + v1XRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xbuckets.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{"kind": "XBucket", "plural": "xbuckets"}). + WithSpecField("claimNames", map[string]any{"kind": "Bucket", "plural": "buckets"}). + WithSpecField("versions", []any{map[string]any{"name": "v1alpha1"}, map[string]any{"name": "v1"}}). + Build() + + // A v1 XRD offering no claim, in the same group. + noClaimXRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xqueues.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]any{"kind": "XQueue", "plural": "xqueues"}). + WithSpecField("versions", []any{map[string]any{"name": "v1"}}). + Build() + + xrds := []*un.Unstructured{v1XRD, noClaimXRD} + bothXRDVersions := []schema.GroupVersionKind{XRDv1GVK, XRDv2GVK} + managed := []schema.GroupVersionKind{{Group: "example.org", Version: "v1", Kind: "Instance"}} + + bucketV1 := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"} + found := []string{"xbuckets.example.org", "xbuckets.example.org"} + noErrs := []string{"", ""} + notAClaim := func(gvk schema.GroupVersionKind) []string { + msg := "no XRD found that defines claim type " + gvk.String() + return []string{msg, msg} + } + + type want struct { + names []string + errs []string + } + + tests := map[string]struct { + reason string + cluster xrdCluster + gvk schema.GroupVersionKind + want want + }{ + "ClaimFoundThroughCRDOwner": { + reason: "Should get the XRD that controls the claim's CRD, though the credentials cannot list XRDs", + cluster: xrdCluster{xrds: xrds, xrdGVKs: bothXRDVersions, listForbidden: true}, + gvk: bucketV1, + want: want{names: found, errs: noErrs}, + }, + "ClaimAtAnotherVersion": { + reason: "Should get the XRD for its claim at another version it serves", + cluster: xrdCluster{xrds: xrds, xrdGVKs: bothXRDVersions, listForbidden: true}, + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1alpha1", Kind: "Bucket"}, + want: want{names: found, errs: noErrs}, + }, + "XRKindIsNotAClaim": { + reason: "Should find no XRD defining an XR kind as a claim, though an XRD controls its CRD", + cluster: xrdCluster{xrds: xrds, listForbidden: true}, + gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"}, + want: want{names: []string{"", ""}, errs: notAClaim(schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"})}, + }, + "ManagedResourceIsNotAClaim": { + reason: "Should find no XRD for a type whose CRD a provider controls", + cluster: xrdCluster{xrds: xrds, managed: managed, listForbidden: true}, + gvk: managed[0], + want: want{names: []string{"", ""}, errs: notAClaim(managed[0])}, + }, + "CRDWithoutOwnerFallsBackToList": { + reason: "Should find the XRD among all XRDs when the claim's CRD has no controller", + cluster: xrdCluster{xrds: xrds, xrdGVKs: bothXRDVersions, unownedClaimCRDs: true}, + gvk: bucketV1, + want: want{names: found, errs: noErrs}, + }, + "CRDWithoutOwnerAndNoMatchingXRD": { + reason: "Should find no XRD when the CRD has no controller and no XRD offers the claim", + cluster: xrdCluster{xrds: []*un.Unstructured{noClaimXRD}, managed: managed, unownedClaimCRDs: true}, + gvk: managed[0], + want: want{names: []string{"", ""}, errs: notAClaim(managed[0])}, + }, + "CRDWithoutOwnerAndListForbidden": { + reason: "Should report that the XRDs cannot be listed when the fallback needs them", + cluster: xrdCluster{xrds: xrds, unownedClaimCRDs: true, listForbidden: true}, + gvk: bucketV1, + want: want{names: []string{"", ""}, errs: []string{ + `cannot get XRDs: cannot list XRDs: cannot list items for GVK apiextensions.crossplane.io/v1, Kind=CompositeResourceDefinition: compositeresourcedefinitions.apiextensions.crossplane.io is forbidden: list is not permitted`, + `cannot get XRDs: cannot list XRDs: cannot list items for GVK apiextensions.crossplane.io/v1, Kind=CompositeResourceDefinition: compositeresourcedefinitions.apiextensions.crossplane.io is forbidden: list is not permitted`, + }}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + c := tt.cluster.client(ctx, t) + + names, errs := lookupXRDTwice(func() (*un.Unstructured, error) { return c.GetXRDForClaim(t.Context(), tt.gvk) }) + + if diff := cmp.Diff(tt.want, want{names: names, errs: errs}, cmp.AllowUnexported(want{})); diff != "" { + t.Errorf("\n%s\nGetXRDForClaim(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} diff --git a/internal/diff/client/crossplane/environment_client.go b/internal/diff/client/crossplane/environment_client.go index 98abfc23..0aa5d03b 100644 --- a/internal/diff/client/crossplane/environment_client.go +++ b/internal/diff/client/crossplane/environment_client.go @@ -43,7 +43,8 @@ func NewEnvironmentClient(resourceClient kubernetes.ResourceClient, logger loggi } } -// Initialize loads environment configs into the cache. +// Initialize discovers the served EnvironmentConfig versions. EnvironmentConfigs are fetched on +// demand by GetEnvironmentConfig. func (c *DefaultEnvironmentClient) Initialize(ctx context.Context) error { c.logger.Debug("Initializing environment client") @@ -54,18 +55,7 @@ func (c *DefaultEnvironmentClient) Initialize(ctx context.Context) error { c.gvks = gvks - // List environment configs to populate the cache - configs, err := c.GetEnvironmentConfigs(ctx) - if err != nil { - return errors.Wrap(err, "cannot list environment configs") - } - - // Store in cache - for _, config := range configs { - c.envConfigs[cacheKey("", config.GetName())] = config - } - - c.logger.Debug("Environment client initialized", "envConfigsCount", len(c.envConfigs)) + c.logger.Debug("Environment client initialized") return nil } diff --git a/internal/diff/client/crossplane/environment_client_test.go b/internal/diff/client/crossplane/environment_client_test.go index 89940251..ad9a1db6 100644 --- a/internal/diff/client/crossplane/environment_client_test.go +++ b/internal/diff/client/crossplane/environment_client_test.go @@ -303,52 +303,33 @@ func TestDefaultEnvironmentClient_GetEnvironmentConfig(t *testing.T) { func TestDefaultEnvironmentClient_Initialize(t *testing.T) { ctx := t.Context() - // Create test environment configurations - envConfig1 := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "env-config-1").Build() - envConfig2 := tu.NewResource("apiextensions.crossplane.io/v1alpha1", "EnvironmentConfig", "env-config-2").Build() - tests := map[string]struct { reason string mockResource tu.MockResourceClient wantErr bool - wantCached map[string]bool }{ "SuccessfulInitialization": { - reason: "Should successfully initialize and cache environment configs", + reason: "Should discover the EnvironmentConfig versions and cache no configs, which are fetched on demand", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). - WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { - if gvk.Group == "apiextensions.crossplane.io" && - gvk.Version == "v1alpha1" && - gvk.Kind == "EnvironmentConfig" { - return []*un.Unstructured{envConfig1, envConfig2}, nil - } - - return nil, errors.New("unexpected GVK") - }). Build(), wantErr: false, - wantCached: map[string]bool{ - "env-config-1": true, - "env-config-2": true, - }, }, - "NoConfigs": { - reason: "Should successfully initialize with empty cache when no configs exist", + "NoListPermission": { + reason: "Should initialize when the credentials cannot list environment configs", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). - WithEmptyListResources(). + WithErrorOnListOf(EnvConfigV1alpha1GVK). Build(), - wantErr: false, - wantCached: map[string]bool{}, + wantErr: false, }, - "ListError": { - reason: "Should return error when listing environment configs fails", + "DiscoveryFailed": { + reason: "Should return error when the EnvironmentConfig versions cannot be discovered", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). + WithoutFoundGVKs("discovery error"). Build(), wantErr: true, }, @@ -372,18 +353,8 @@ func TestDefaultEnvironmentClient_Initialize(t *testing.T) { return } - // If no error expected, check the cache state - if !tt.wantErr { - for name := range tt.wantCached { - if _, ok := c.envConfigs["/"+name]; !ok { - t.Errorf("\n%s\nInitialize(): expected config %s to be cached, but it's not", tt.reason, name) - } - } - - // Check we don't have extra configs - if len(c.envConfigs) != len(tt.wantCached) { - t.Errorf("\n%s\nInitialize(): expected %d cached configs, got %d", tt.reason, len(tt.wantCached), len(c.envConfigs)) - } + if len(c.envConfigs) != 0 { + t.Errorf("\n%s\nInitialize(): expected no cached configs, got %d", tt.reason, len(c.envConfigs)) } }) } diff --git a/internal/diff/client/crossplane/function_client.go b/internal/diff/client/crossplane/function_client.go index e26beee5..92ef6475 100644 --- a/internal/diff/client/crossplane/function_client.go +++ b/internal/diff/client/crossplane/function_client.go @@ -3,7 +3,9 @@ package crossplane import ( "context" "fmt" + "sync" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -21,8 +23,9 @@ import ( type FunctionClient interface { core.Initializable - // GetFunctionsFromPipeline gets functions used in a composition pipeline - GetFunctionsFromPipeline(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) + // GetFunctionsFromPipeline gets functions used in a composition pipeline, fetching each one the + // pipeline names from the cluster the first time it is asked for. + GetFunctionsFromPipeline(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) // ListFunctions lists all functions in the cluster ListFunctions(ctx context.Context) ([]pkgv1.Function, error) @@ -33,9 +36,17 @@ type DefaultFunctionClient struct { resourceClient kubernetes.ResourceClient logger logging.Logger - // Cache of functions - functions map[string]pkgv1.Function - gvks []schema.GroupVersionKind + // Cache of functions by name: those found, and the names the cluster has no Function for. + // functionsMu guards both, and is never held across a cluster request. + functions map[string]pkgv1.Function + missing map[string]bool + functionsMu sync.Mutex + gvks []schema.GroupVersionKind +} + +// functionGVK returns the one Function version this client reads, as typed pkgv1.Functions. +func functionGVK() schema.GroupVersionKind { + return schema.GroupVersionKind{Group: CrossplanePkgGroup, Version: "v1", Kind: FunctionKind} } // NewFunctionClient creates a new DefaultFunctionClient. @@ -44,10 +55,12 @@ func NewFunctionClient(resourceClient kubernetes.ResourceClient, logger logging. resourceClient: resourceClient, logger: logger, functions: make(map[string]pkgv1.Function), + missing: make(map[string]bool), } } -// Initialize loads functions into the cache. +// Initialize discovers the served Function versions. Functions are fetched on demand by +// GetFunctionsFromPipeline. func (c *DefaultFunctionClient) Initialize(ctx context.Context) error { c.logger.Debug("Initializing function client") @@ -58,18 +71,7 @@ func (c *DefaultFunctionClient) Initialize(ctx context.Context) error { c.gvks = gvks - // List functions to populate the cache - fns, err := c.ListFunctions(ctx) - if err != nil { - return errors.Wrap(err, "cannot list functions") - } - - // Store in cache - for _, fn := range fns { - c.functions[fn.GetName()] = fn - } - - c.logger.Debug("Function client initialized", "functionsCount", len(c.functions)) + c.logger.Debug("Function client initialized") return nil } @@ -81,15 +83,8 @@ func (c *DefaultFunctionClient) ListFunctions(ctx context.Context) ([]pkgv1.Func // TODO: we don't actually use our cached GVKs here -- but there's only one version of Function // and this part is strongly typed which will make a second version hard to handle - // Define the function GVK - gvk := schema.GroupVersionKind{ - Group: CrossplanePkgGroup, - Version: "v1", - Kind: FunctionKind, - } - // Get all functions using the resource client - unFns, err := c.resourceClient.ListResources(ctx, gvk, "") + unFns, err := c.resourceClient.ListResources(ctx, functionGVK(), "") if err != nil { c.logger.Debug("Failed to list functions", "error", err) return nil, errors.Wrap(err, "cannot list functions from cluster") @@ -118,7 +113,7 @@ func (c *DefaultFunctionClient) ListFunctions(ctx context.Context) ([]pkgv1.Func } // GetFunctionsFromPipeline gets functions used in a composition pipeline. -func (c *DefaultFunctionClient) GetFunctionsFromPipeline(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { +func (c *DefaultFunctionClient) GetFunctionsFromPipeline(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { c.logger.Debug("Getting functions from pipeline", "composition_name", comp.GetName()) if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { @@ -137,7 +132,11 @@ func (c *DefaultFunctionClient) GetFunctionsFromPipeline(comp *apiextensionsv1.C c.logger.Debug("Processing pipeline steps", "steps_count", len(comp.Spec.Pipeline)) for _, step := range comp.Spec.Pipeline { - fn, ok := c.functions[step.FunctionRef.Name] + fn, ok, err := c.getFunction(ctx, step.FunctionRef.Name) + if err != nil { + return nil, err + } + if !ok { c.logger.Debug("Function not found", "step", step.Step, @@ -160,3 +159,39 @@ func (c *DefaultFunctionClient) GetFunctionsFromPipeline(comp *apiextensionsv1.C return functions, nil } + +// getFunction returns the named Function, from the cache or else from the cluster, and whether it +// exists. Both outcomes are cached, so each name is fetched at most once. +func (c *DefaultFunctionClient) getFunction(ctx context.Context, name string) (pkgv1.Function, bool, error) { + c.functionsMu.Lock() + fn, found := c.functions[name] + missing := c.missing[name] + c.functionsMu.Unlock() + + if found || missing { + return fn, found, nil + } + + u, err := c.resourceClient.GetResource(ctx, functionGVK(), "", name) + if apierrors.IsNotFound(err) { + c.functionsMu.Lock() + c.missing[name] = true + c.functionsMu.Unlock() + + return pkgv1.Function{}, false, nil + } + + if err != nil { + return pkgv1.Function{}, false, errors.Wrapf(err, "cannot get function %q", name) + } + + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &fn); err != nil { + return pkgv1.Function{}, false, errors.Wrap(err, "cannot convert unstructured to Function") + } + + c.functionsMu.Lock() + c.functions[name] = fn + c.functionsMu.Unlock() + + return fn, true, nil +} diff --git a/internal/diff/client/crossplane/function_client_test.go b/internal/diff/client/crossplane/function_client_test.go index ce612206..fec8400b 100644 --- a/internal/diff/client/crossplane/function_client_test.go +++ b/internal/diff/client/crossplane/function_client_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -121,6 +122,7 @@ func TestDefaultFunctionClient_GetFunctionsFromPipeline(t *testing.T) { }, mockResource: tu.NewMockResourceClient(). WithSuccessfulInitialize(). + WithResourceNotFound(). Build(), args: args{ comp: &apiextensionsv1.Composition{ @@ -257,10 +259,11 @@ func TestDefaultFunctionClient_GetFunctionsFromPipeline(t *testing.T) { c := &DefaultFunctionClient{ resourceClient: tt.mockResource, functions: tt.fields.functions, + missing: make(map[string]bool), logger: tu.TestLogger(t, false), } - got, err := c.GetFunctionsFromPipeline(tt.args.comp) + got, err := c.GetFunctionsFromPipeline(t.Context(), tt.args.comp) if tt.want.err != nil { if err == nil { @@ -306,6 +309,80 @@ func TestDefaultFunctionClient_GetFunctionsFromPipeline(t *testing.T) { } } +func TestGetFunctionsFromPipelineWithoutListPermission(t *testing.T) { + fnA := tu.NewResource("pkg.crossplane.io/v1", "Function", "function-a").Build() + fnB := tu.NewResource("pkg.crossplane.io/v1", "Function", "function-b").Build() + + comp := tu.NewComposition("test-comp"). + WithPipelineMode(). + WithPipelineStep("step-a", "function-a", nil). + WithPipelineStep("step-b", "function-b", nil). + WithPipelineStep("step-c", "function-a", nil). + Build() + + type want struct { + names []string + err string + } + + tests := map[string]struct { + reason string + functions []*un.Unstructured + want want + }{ + "AllFunctionsExist": { + reason: "Should get each function the pipeline names, in step order, though the credentials cannot list functions", + functions: []*un.Unstructured{fnA, fnB}, + want: want{names: []string{"function-a", "function-b", "function-a"}}, + }, + "FunctionMissing": { + reason: "Should report a function the cluster does not have as missing, as when functions were listed", + functions: []*un.Unstructured{fnA}, + want: want{err: `function "function-b" referenced in pipeline step "step-b" not found`}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + + c := NewFunctionClient(tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{functionGVK()}). + WithResourcesExist(tt.functions...). + WithErrorOnListOf(functionGVK()). + Build(), tu.TestLogger(t, false)) + + if err := c.Initialize(ctx); err != nil { + t.Fatalf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + } + + // Ask twice: the second call is answered from what the first one fetched. + for range 2 { + fns, err := c.GetFunctionsFromPipeline(ctx, comp) + + gotErr := "" + if err != nil { + gotErr = err.Error() + } + + if diff := cmp.Diff(tt.want.err, gotErr); diff != "" { + t.Fatalf("\n%s\nGetFunctionsFromPipeline(...): -want error, +got error:\n%s", tt.reason, diff) + } + + names := make([]string, 0, len(fns)) + for _, fn := range fns { + names = append(names, fn.GetName()) + } + + if diff := cmp.Diff(tt.want.names, names, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("\n%s\nGetFunctionsFromPipeline(...): -want function names, +got function names:\n%s", tt.reason, diff) + } + } + }) + } +} + func TestDefaultFunctionClient_ListFunctions(t *testing.T) { ctx := t.Context() @@ -427,6 +504,7 @@ func TestDefaultFunctionClient_ListFunctions(t *testing.T) { resourceClient: &tt.mockResource, logger: tu.TestLogger(t, false), functions: make(map[string]pkgv1.Function), + missing: make(map[string]bool), } got, err := c.ListFunctions(ctx) @@ -488,95 +566,33 @@ func TestDefaultFunctionClient_ListFunctions(t *testing.T) { func TestDefaultFunctionClient_Initialize(t *testing.T) { ctx := t.Context() - // Create test functions - fn1 := pkgv1.Function{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "pkg.crossplane.io/v1", - Kind: "Function", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "function-1", - }, - } - - fn2 := pkgv1.Function{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "pkg.crossplane.io/v1", - Kind: "Function", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: "function-2", - }, - } - - // Convert to unstructured for testing - u1 := &un.Unstructured{} - obj1, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&fn1) - u1.SetUnstructuredContent(obj1) - u1.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "pkg.crossplane.io", - Version: "v1", - Kind: "Function", - }) - - u2 := &un.Unstructured{} - obj2, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(&fn2) - u2.SetUnstructuredContent(obj2) - u2.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "pkg.crossplane.io", - Version: "v1", - Kind: "Function", - }) - tests := map[string]struct { reason string mockResource tu.MockResourceClient wantErr bool - wantCached map[string]bool }{ "SuccessfulInitialization": { - reason: "Should successfully initialize and cache functions", + reason: "Should discover the Function versions and cache no functions, which are fetched on demand", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). - WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, _ string) ([]*un.Unstructured, error) { - if gvk.Group == "pkg.crossplane.io" && gvk.Kind == "Function" { - return []*un.Unstructured{u1, u2}, nil - } - - return nil, errors.New("unexpected GVK") - }). + WithFoundGVKs([]schema.GroupVersionKind{functionGVK()}). Build(), wantErr: false, - wantCached: map[string]bool{ - "function-1": true, - "function-2": true, - }, }, - "NoFunctions": { - reason: "Should successfully initialize with empty cache when no functions exist", + "NoListPermission": { + reason: "Should initialize when the credentials cannot list functions", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithFoundGVKs([]schema.GroupVersionKind{EnvConfigV1alpha1GVK}). - WithEmptyListResources(). - Build(), - wantErr: false, - wantCached: map[string]bool{}, - }, - "ResourceClientInitFailed": { - reason: "Should return error when resource client initialization fails", - mockResource: *tu.NewMockResourceClient(). - WithInitialize(func(_ context.Context) error { - return errors.New("init error") - }). + WithFoundGVKs([]schema.GroupVersionKind{functionGVK()}). + WithErrorOnListOf(functionGVK()). Build(), - wantErr: true, + wantErr: false, }, - "ListFunctionsFailed": { - reason: "Should return error when listing functions fails", + "DiscoveryFailed": { + reason: "Should return error when the Function versions cannot be discovered", mockResource: *tu.NewMockResourceClient(). WithSuccessfulInitialize(). - WithListResourcesFailure("list error"). + WithoutFoundGVKs("discovery error"). Build(), wantErr: true, }, @@ -588,6 +604,7 @@ func TestDefaultFunctionClient_Initialize(t *testing.T) { resourceClient: &tt.mockResource, logger: tu.TestLogger(t, false), functions: make(map[string]pkgv1.Function), + missing: make(map[string]bool), } err := c.Initialize(ctx) @@ -605,16 +622,8 @@ func TestDefaultFunctionClient_Initialize(t *testing.T) { return } - // If successful, check the cache state - for name := range tt.wantCached { - if _, ok := c.functions[name]; !ok { - t.Errorf("\n%s\nInitialize(): expected function %s to be cached, but it's not", tt.reason, name) - } - } - - // Check we don't have extra functions - if len(c.functions) != len(tt.wantCached) { - t.Errorf("\n%s\nInitialize(): expected %d cached functions, got %d", tt.reason, len(tt.wantCached), len(c.functions)) + if len(c.functions) != 0 { + t.Errorf("\n%s\nInitialize(): expected no cached functions, got %d", tt.reason, len(c.functions)) } }) } diff --git a/internal/diff/client/kubernetes/schema_client.go b/internal/diff/client/kubernetes/schema_client.go index 791f6218..9be86e29 100644 --- a/internal/diff/client/kubernetes/schema_client.go +++ b/internal/diff/client/kubernetes/schema_client.go @@ -3,14 +3,13 @@ package kubernetes import ( "context" "fmt" - "maps" "slices" "strings" "sync" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/dynamic" @@ -18,35 +17,21 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - xpextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" - xpextv2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" - "github.com/crossplane/cli/v2/internal/diff/client/core" ) -// XRD apiVersion strings recognized by the schema client. These mirror the -// runtime SchemeGroupVersion values from the imported xpextv1/xpextv2 packages, -// but as constants they can be used in switch cases. -const ( - xrdAPIVersionV1 = "apiextensions.crossplane.io/v1" - xrdAPIVersionV2 = "apiextensions.crossplane.io/v2" -) - // SchemaClient handles operations related to Kubernetes schemas and CRDs. type SchemaClient interface { // GetCRD gets the CustomResourceDefinition for a given GVK GetCRD(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) // GetCRDByName gets the CustomResourceDefinition by its name - GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) + GetCRDByName(ctx context.Context, name string) (*extv1.CustomResourceDefinition, error) // IsCRDRequired checks if a GVK requires a CRD IsCRDRequired(ctx context.Context, gvk schema.GroupVersionKind) bool - // LoadCRDsFromXRDs converts XRDs to CRDs and caches them - LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error - - // GetAllCRDs returns all cached CRDs (needed for external validation library) + // GetAllCRDs returns every CRD fetched so far (needed for external validation library) GetAllCRDs() []*extv1.CustomResourceDefinition } @@ -60,11 +45,12 @@ type DefaultSchemaClient struct { resourceTypeMap map[schema.GroupVersionKind]bool resourceMapMu sync.RWMutex - // CRD caching - consolidated from SchemaValidator - crds []*extv1.CustomResourceDefinition - crdsMu sync.RWMutex - crdByName map[string]*extv1.CustomResourceDefinition // for fast lookup by name - xrdToCRDName map[string]string // maps XRD name to CRD name + // CRDs fetched so far, and the NotFound error for each name found not to exist. crdsMu guards all + // three, and is never held across a cluster request. + crds []*extv1.CustomResourceDefinition + crdsMu sync.RWMutex + crdByName map[string]*extv1.CustomResourceDefinition // for fast lookup by name + missingCRDs map[string]error } // NewSchemaClient creates a new DefaultSchemaClient. @@ -76,7 +62,7 @@ func NewSchemaClient(clients *core.Clients, typeConverter TypeConverter, logger resourceTypeMap: make(map[schema.GroupVersionKind]bool), crds: []*extv1.CustomResourceDefinition{}, crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + missingCRDs: make(map[string]error), } } @@ -88,50 +74,14 @@ func (c *DefaultSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersio return nil, errors.Wrapf(err, "cannot determine CRD name for %s", gvk.String()) } - // Construct the full CRD name crdName := fmt.Sprintf("%s.%s", resourceName, gvk.Group) - // Check cache first - c.crdsMu.RLock() - - if cached, ok := c.crdByName[crdName]; ok { - c.crdsMu.RUnlock() - c.logger.Debug("Using cached CRD", "gvk", gvk.String(), "crdName", crdName) - - return cached, nil - } - - c.crdsMu.RUnlock() - - c.logger.Debug("Looking up CRD", "gvk", gvk.String(), "crdName", resourceName) - - // Define the CRD GVR directly to avoid recursion - crdGVR := schema.GroupVersionResource{ - Group: "apiextensions.k8s.io", - Version: "v1", - Resource: "customresourcedefinitions", - } - - // Fetch the CRD from cluster - crdObj, err := c.dynamicClient.Resource(crdGVR).Get(ctx, crdName, metav1.GetOptions{}) + crd, err := c.getCRD(ctx, crdName) if err != nil { - c.logger.Debug("Failed to get CRD", "gvk", gvk.String(), "crdName", crdName, "error", err) return nil, errors.Wrapf(err, "cannot get CRD %s for %s", crdName, gvk.String()) } - c.logger.Debug("Successfully retrieved CRD", "gvk", gvk.String(), "crdName", resourceName) - - // Convert to typed CRD - crdTyped := &extv1.CustomResourceDefinition{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crdObj.Object, crdTyped); err != nil { - c.logger.Debug("Error converting CRD", "gvk", gvk.String(), "crdName", crdName, "error", err) - return nil, errors.Wrapf(err, "cannot convert CRD %s to typed", crdName) - } - - // Add to cache - c.addCRD(crdTyped) - - return crdTyped, nil + return crd, nil } // IsCRDRequired checks if a GVK requires a CRD. @@ -194,185 +144,66 @@ func (c *DefaultSchemaClient) cacheResourceType(gvk schema.GroupVersionKind, req c.resourceTypeMap[gvk] = requiresCRD } -// extractGVKsFromXRDs extracts GVKs from multiple XRDs. This is a pure function with no side effects. -func extractGVKsFromXRDs(xrds []*un.Unstructured) ([]schema.GroupVersionKind, error) { - var allGVKs []schema.GroupVersionKind - - for _, xrd := range xrds { - gvks, err := extractGVKsFromXRD(xrd) - if err != nil { - return nil, errors.Wrapf(err, "failed to extract GVKs from XRD %s", xrd.GetName()) - } - - allGVKs = append(allGVKs, gvks...) - } - - return allGVKs, nil -} - -// extractGVKsFromXRD extracts all GroupVersionKinds from an XRD using strongly-typed conversion. -// This method handles both v1 and v2 XRDs and leverages Kubernetes runtime conversion. -func extractGVKsFromXRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { - apiVersion := xrd.GetAPIVersion() - - switch apiVersion { - case xrdAPIVersionV1: - return extractGVKsFromV1XRD(xrd) - case xrdAPIVersionV2: - return extractGVKsFromV2XRD(xrd) - default: - return nil, errors.Errorf("unsupported XRD apiVersion %s in XRD %s", apiVersion, xrd.GetName()) - } -} - -// extractGVKsFromV1XRD extracts GVKs from a v1 XRD using strongly-typed conversion. -func extractGVKsFromV1XRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { - typedXRD := &xpextv1.CompositeResourceDefinition{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrd.Object, typedXRD); err != nil { - return nil, errors.Wrapf(err, "cannot convert XRD %s to v1 typed object", xrd.GetName()) - } - - // Extract GVKs for each version - no validation needed since XRDs from server are guaranteed valid - gvks := make([]schema.GroupVersionKind, 0, len(typedXRD.Spec.Versions)) - for _, version := range typedXRD.Spec.Versions { - gvks = append(gvks, schema.GroupVersionKind{ - Group: typedXRD.Spec.Group, - Version: version.Name, - Kind: typedXRD.Spec.Names.Kind, - }) - } - - return gvks, nil -} - -// extractGVKsFromV2XRD extracts GVKs from a v2 XRD using strongly-typed conversion. -func extractGVKsFromV2XRD(xrd *un.Unstructured) ([]schema.GroupVersionKind, error) { - typedXRD := &xpextv2.CompositeResourceDefinition{} - if err := runtime.DefaultUnstructuredConverter.FromUnstructured(xrd.Object, typedXRD); err != nil { - return nil, errors.Wrapf(err, "cannot convert XRD %s to v2 typed object", xrd.GetName()) - } - - // Extract GVKs for each version - no validation needed since XRDs from server are guaranteed valid - gvks := make([]schema.GroupVersionKind, 0, len(typedXRD.Spec.Versions)) - for _, version := range typedXRD.Spec.Versions { - gvks = append(gvks, schema.GroupVersionKind{ - Group: typedXRD.Spec.Group, - Version: version.Name, - Kind: typedXRD.Spec.Names.Kind, - }) +// GetCRDByName gets the named CRD, from the cache or else from the cluster, caching what it finds and a +// CRD found not to exist. Any other failure is not cached. +func (c *DefaultSchemaClient) GetCRDByName(ctx context.Context, name string) (*extv1.CustomResourceDefinition, error) { + crd, err := c.getCRD(ctx, name) + if err != nil { + return nil, errors.Wrapf(err, "cannot get CRD %s", name) } - return gvks, nil + return crd, nil } -// LoadCRDsFromXRDs fetches corresponding CRDs from the cluster for the given XRDs and caches them. -// Instead of converting XRDs to CRDs, this method fetches the actual CRDs that should already -// exist in the cluster since the Crossplane control plane manages both XRDs and their corresponding CRDs. -func (c *DefaultSchemaClient) LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error { - c.logger.Debug("Loading CRDs from cluster for XRDs", "xrdCount", len(xrds)) - - if len(xrds) == 0 { - c.logger.Debug("No XRDs provided, nothing to load") - return nil - } +// getCRD is GetCRDByName without wrapping the cluster's error, which each caller names in its own terms. +func (c *DefaultSchemaClient) getCRD(ctx context.Context, name string) (*extv1.CustomResourceDefinition, error) { + c.crdsMu.RLock() + crd, found := c.crdByName[name] + missingErr, missing := c.missingCRDs[name] + c.crdsMu.RUnlock() - // Extract GVKs from XRDs using the pure function - gvks, err := extractGVKsFromXRDs(xrds) - if err != nil { - return err // Error already wrapped with context from ExtractGVKsFromXRDs + switch { + case found: + c.logger.Debug("Using cached CRD", "crdName", name) + return crd, nil + case missing: + return nil, missingErr } - // Build XRD-to-CRD name mappings for later use - xrdToCRDMappings := make(map[string]string) // XRD name -> CRD name + c.logger.Debug("Looking up CRD", "crdName", name) - for _, xrd := range xrds { - // Extract the CRD name from XRD spec (format: {plural}.{group}) - group, _, _ := un.NestedString(xrd.Object, "spec", "group") - - plural, _, _ := un.NestedString(xrd.Object, "spec", "names", "plural") - if group != "" && plural != "" { - crdName := plural + "." + group - xrdName := xrd.GetName() - xrdToCRDMappings[xrdName] = crdName - c.logger.Debug("Mapped XRD to CRD", "xrdName", xrdName, "crdName", crdName) - } + // Define the CRD GVR directly to avoid recursion + crdGVR := schema.GroupVersionResource{ + Group: "apiextensions.k8s.io", + Version: "v1", + Resource: "customresourcedefinitions", } - // Load CRDs from the extracted GVKs - err = c.loadCRDsFromGVKs(ctx, gvks) + crdObj, err := c.dynamicClient.Resource(crdGVR).Get(ctx, name, metav1.GetOptions{}) if err != nil { - return err // Error already wrapped with context from LoadCRDsFromGVKs - } - - // Store XRD-to-CRD name mappings - c.crdsMu.Lock() - - maps.Copy(c.xrdToCRDName, xrdToCRDMappings) - - c.crdsMu.Unlock() + c.logger.Debug("Failed to get CRD", "crdName", name, "error", err) - c.logger.Debug("Successfully stored XRD-to-CRD mappings", "count", len(xrdToCRDMappings)) - - return nil -} - -// loadCRDsFromGVKs fetches CRDs from the cluster for the given GVKs and caches them. -// This method fetches the actual CRDs from the cluster for each provided GVK. -func (c *DefaultSchemaClient) loadCRDsFromGVKs(ctx context.Context, gvks []schema.GroupVersionKind) error { - c.logger.Debug("Loading CRDs from cluster for GVKs", "gvkCount", len(gvks)) - - if len(gvks) == 0 { - c.logger.Debug("No GVKs provided, nothing to load") - return nil - } - - // TODO: Consider parallel fetching of CRDs to improve performance for large numbers of GVKs. - // This could significantly speed up initialization when dealing with many XRDs. - // For now, we fetch sequentially to keep the implementation simple. - - // Fetch CRDs from cluster for each GVK - fail fast if any CRD is missing - // Per repository guidelines: never continue in a degraded state - fetchedCRDs := make([]*extv1.CustomResourceDefinition, 0, len(gvks)) - - for _, gvk := range gvks { - crd, err := c.GetCRD(ctx, gvk) - if err != nil { - c.logger.Debug("Failed to fetch required CRD for GVK", "gvk", gvk.String(), "error", err) - return errors.Wrapf(err, "cannot fetch required CRD for %s", gvk.String()) + if apierrors.IsNotFound(err) { + c.crdsMu.Lock() + c.missingCRDs[name] = err + c.crdsMu.Unlock() } - fetchedCRDs = append(fetchedCRDs, crd) + return nil, err } - c.logger.Debug("Successfully fetched all required CRDs from cluster", "count", len(fetchedCRDs)) - - return nil -} - -// GetCRDByName gets a CRD by its name from the cache. -// If the name is not found directly, it will also check if it's an XRD name -// that maps to a different CRD name (e.g., claim XRDs). -func (c *DefaultSchemaClient) GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) { - c.crdsMu.RLock() - defer c.crdsMu.RUnlock() - - // First, try direct lookup by CRD name - if crd, exists := c.crdByName[name]; exists { - return crd, nil - } + c.logger.Debug("Successfully retrieved CRD", "crdName", name) - // If not found, check if this is an XRD name that maps to a different CRD name - if crdName, exists := c.xrdToCRDName[name]; exists { - if crd, exists := c.crdByName[crdName]; exists { - c.logger.Debug("Found CRD for XRD via name mapping", "xrdName", name, "crdName", crdName) - return crd, nil - } + crdTyped := &extv1.CustomResourceDefinition{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(crdObj.Object, crdTyped); err != nil { + c.logger.Debug("Error converting CRD", "crdName", name, "error", err) + return nil, errors.Wrapf(err, "cannot convert CRD %s to typed", name) } - return nil, errors.Errorf("CRD with name %s not found in cache", name) + return c.addCRD(crdTyped), nil } -// GetAllCRDs returns all cached CRDs. +// GetAllCRDs returns every CRD fetched so far. func (c *DefaultSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { c.crdsMu.RLock() defer c.crdsMu.RUnlock() @@ -384,15 +215,16 @@ func (c *DefaultSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { return result } -// addCRD adds a CRD to the cache. -func (c *DefaultSchemaClient) addCRD(crd *extv1.CustomResourceDefinition) { +// addCRD adds a CRD to the cache and returns the cached CRD of its name: crd, or the one a concurrent +// caller fetched first. +func (c *DefaultSchemaClient) addCRD(crd *extv1.CustomResourceDefinition) *extv1.CustomResourceDefinition { c.crdsMu.Lock() defer c.crdsMu.Unlock() // Check if already cached to avoid duplicates - if _, exists := c.crdByName[crd.Name]; exists { + if cached, exists := c.crdByName[crd.Name]; exists { c.logger.Debug("CRD already in cache, skipping", "crdName", crd.Name) - return + return cached } // Add to slice @@ -402,4 +234,6 @@ func (c *DefaultSchemaClient) addCRD(crd *extv1.CustomResourceDefinition) { c.crdByName[crd.Name] = crd c.logger.Debug("Added CRD to cache", "crdName", crd.Name) + + return crd } diff --git a/internal/diff/client/kubernetes/schema_client_test.go b/internal/diff/client/kubernetes/schema_client_test.go index 3d8bdc27..5262b7ac 100644 --- a/internal/diff/client/kubernetes/schema_client_test.go +++ b/internal/diff/client/kubernetes/schema_client_test.go @@ -7,6 +7,8 @@ import ( "github.com/google/go-cmp/cmp" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -16,6 +18,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/cli/v2/internal/diff/client/core" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -142,7 +145,6 @@ func TestSchemaClient_IsCRDRequired(t *testing.T) { typeConverter: tc.setupConverter(), logger: tu.TestLogger(t, false), resourceTypeMap: make(map[schema.GroupVersionKind]bool), - xrdToCRDName: make(map[string]string), } // Call the method under test @@ -297,7 +299,7 @@ func TestSchemaClient_GetCRD(t *testing.T) { resourceTypeMap: make(map[schema.GroupVersionKind]bool), crds: []*extv1.CustomResourceDefinition{}, crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + missingCRDs: make(map[string]error), } crd, err := c.GetCRD(tc.args.ctx, tc.args.gvk) @@ -328,271 +330,131 @@ func TestSchemaClient_GetCRD(t *testing.T) { } } -func TestSchemaClient_LoadCRDsFromXRDs(t *testing.T) { - ctx := t.Context() - scheme := runtime.NewScheme() - - // Create sample XRD with proper schema using builder - xrd := tu.NewXRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). +func TestGetCRDByNameOnMiss(t *testing.T) { + crdName := testXResourcePlural + "." + testExampleOrgGroup + crd := tu.NewCRD(crdName, testExampleOrgGroup, testXResourceKind). WithPlural(testXResourcePlural). WithSingular("xresource"). WithDefaultVersion(). - WithRawSchema([]byte(`{ - "type": "object", - "properties": { - "spec": { - "type": "object", - "properties": { - "field": { - "type": "string" - } - } - }, - "status": { - "type": "object" - } - } - }`)). - BuildAsUnstructured() + Build() + crdGVR := schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} - // Create corresponding CRD that should exist in cluster using CRD builder - correspondingCRDObj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured( - tu.NewCRD(testXResourcePlural+".example.org", testExampleOrgGroup, testXResourceKind). - WithPlural(testXResourcePlural). - WithSingular("xresource"). - WithDefaultVersion(). - Build()) - correspondingCRD := &un.Unstructured{Object: correspondingCRDObj} + asUnstructured := func(t *testing.T) *un.Unstructured { + t.Helper() - tests := map[string]struct { - reason string - setupClient func() *DefaultSchemaClient - xrds []*un.Unstructured - expectErr bool - errMsg string - expectedCRDs int - }{ - "SuccessfulFetchFromCluster": { - reason: "Should successfully fetch CRDs from cluster for given XRDs", - setupClient: func() *DefaultSchemaClient { - // Setup dynamic client to return the corresponding CRD - dynamicClient := fake.NewSimpleDynamicClient(scheme) - dynamicClient.PrependReactor("get", "customresourcedefinitions", func(action kt.Action) (bool, runtime.Object, error) { - getAction := action.(kt.GetAction) - if getAction.GetName() == testXResourcePlural+".example.org" { - return true, correspondingCRD, nil - } + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(crd) + if err != nil { + t.Fatalf("cannot convert CRD: %v", err) + } - return false, nil, nil - }) + u := &un.Unstructured{Object: obj} + u.SetGroupVersionKind(extv1.SchemeGroupVersion.WithKind("CustomResourceDefinition")) - mockConverter := tu.NewMockTypeConverter(). - WithResourceNameForGVK(schema.GroupVersionKind{ - Group: testExampleOrgGroup, - Version: "v1", - Kind: testXResourceKind, - }, testXResourcePlural).Build() + return u + } - return &DefaultSchemaClient{ - dynamicClient: dynamicClient, - typeConverter: mockConverter, - logger: tu.TestLogger(t, false), - resourceTypeMap: make(map[schema.GroupVersionKind]bool), - crds: []*extv1.CustomResourceDefinition{}, - crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), - } - }, - xrds: []*un.Unstructured{xrd}, - expectErr: false, - expectedCRDs: 1, + // A result of one GetCRDByName call: the name of the CRD it returned, or the kind of error. + type result struct { + Name string + NotFound bool + Failed bool + } + + found := result{Name: crdName} + notFound := result{NotFound: true, Failed: true} + + tests := map[string]struct { + reason string + // inCluster says whether the cluster holds the CRD before the first call. + inCluster bool + // failFirstGet makes the first GET fail with an error other than NotFound. + failFirstGet bool + // between changes the cluster after the first call. + between func(t *testing.T, c *fake.FakeDynamicClient) + want []result + }{ + "FetchesFromClusterOnMiss": { + reason: "A CRD not yet cached is fetched from the cluster", + inCluster: true, + want: []result{found}, }, - "EmptyXRDs": { - reason: "Should handle empty XRD list gracefully", - setupClient: func() *DefaultSchemaClient { - return &DefaultSchemaClient{ - logger: tu.TestLogger(t, false), - resourceTypeMap: make(map[schema.GroupVersionKind]bool), - crds: []*extv1.CustomResourceDefinition{}, - crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), - } - }, - xrds: []*un.Unstructured{}, - expectErr: false, - expectedCRDs: 0, + "MissingCRDIsNotFound": { + reason: "A CRD the cluster does not hold is reported NotFound", + want: []result{notFound}, }, - "NilXRDs": { - reason: "Should handle nil XRD list gracefully", - setupClient: func() *DefaultSchemaClient { - return &DefaultSchemaClient{ - logger: tu.TestLogger(t, false), - resourceTypeMap: make(map[schema.GroupVersionKind]bool), - crds: []*extv1.CustomResourceDefinition{}, - crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + "FoundCRDIsCached": { + reason: "A CRD once found is returned from the cache, even after the cluster drops it", + inCluster: true, + between: func(t *testing.T, c *fake.FakeDynamicClient) { + t.Helper() + + if err := c.Resource(crdGVR).Delete(t.Context(), crdName, metav1.DeleteOptions{}); err != nil { + t.Fatalf("cannot delete CRD: %v", err) } }, - xrds: nil, - expectErr: false, - expectedCRDs: 0, + want: []result{found, found}, }, - "CRDNotFoundInCluster": { - reason: "Should fail fast when required CRD is not found in cluster", - setupClient: func() *DefaultSchemaClient { - // Setup dynamic client to return error for CRD requests - dynamicClient := fake.NewSimpleDynamicClient(scheme) - dynamicClient.PrependReactor("get", "customresourcedefinitions", func(kt.Action) (bool, runtime.Object, error) { - return true, nil, errors.New("CRD not found") - }) + "MissingCRDIsCached": { + reason: "A CRD once found missing stays missing for the run, even after the cluster gains it", + between: func(t *testing.T, c *fake.FakeDynamicClient) { + t.Helper() - mockConverter := tu.NewMockTypeConverter(). - WithResourceNameForGVK(schema.GroupVersionKind{ - Group: testExampleOrgGroup, - Version: "v1", - Kind: testXResourceKind, - }, testXResourcePlural).Build() - - return &DefaultSchemaClient{ - dynamicClient: dynamicClient, - typeConverter: mockConverter, - logger: tu.TestLogger(t, false), - resourceTypeMap: make(map[schema.GroupVersionKind]bool), - crds: []*extv1.CustomResourceDefinition{}, - crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + if _, err := c.Resource(crdGVR).Create(t.Context(), asUnstructured(t), metav1.CreateOptions{}); err != nil { + t.Fatalf("cannot create CRD: %v", err) } }, - xrds: []*un.Unstructured{xrd}, - expectErr: true, - errMsg: "cannot fetch required CRD", - expectedCRDs: 0, + want: []result{notFound, notFound}, + }, + "OtherErrorIsNotCached": { + reason: "A failure other than NotFound says nothing about the CRD, so the next call fetches again", + inCluster: true, + failFirstGet: true, + want: []result{{Failed: true}, found}, }, } for name, tc := range tests { t.Run(name, func(t *testing.T) { - client := tc.setupClient() - - err := client.LoadCRDsFromXRDs(ctx, tc.xrds) - - if tc.expectErr { - if err == nil { - t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected error but got none", tc.reason) - return - } - - if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { - t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected error containing %q, got %q", - tc.reason, tc.errMsg, err.Error()) - } - - return - } - - if err != nil { - t.Errorf("\n%s\nLoadCRDsFromXRDs(): unexpected error: %v", tc.reason, err) - return + var objs []runtime.Object + if tc.inCluster { + objs = append(objs, asUnstructured(t)) } - // Verify expected number of CRDs were cached - crds := client.GetAllCRDs() - if len(crds) != tc.expectedCRDs { - t.Errorf("\n%s\nLoadCRDsFromXRDs(): expected %d CRDs to be cached, got %d", - tc.reason, tc.expectedCRDs, len(crds)) - } - }) - } -} + dc := fake.NewSimpleDynamicClient(runtime.NewScheme(), objs...) + if tc.failFirstGet { + failed := false + dc.PrependReactor("get", "customresourcedefinitions", func(kt.Action) (bool, runtime.Object, error) { + if failed { + return false, nil, nil + } -func TestSchemaClient_GetCRDByName(t *testing.T) { - // Create test CRDs - testCRDName := testXResourcePlural + "." + testExampleOrgGroup - testCRD := tu.NewCRD(testCRDName, testExampleOrgGroup, testXResourceKind). - WithPlural(testXResourcePlural). - WithSingular("xresource"). - WithDefaultVersion(). - Build() + failed = true - tests := map[string]struct { - reason string - setupCRDs []*extv1.CustomResourceDefinition - searchName string - expectError bool - expectedCRD *extv1.CustomResourceDefinition - }{ - "CRDFound": { - reason: "Should return CRD when it exists in cache", - setupCRDs: []*extv1.CustomResourceDefinition{testCRD}, - searchName: testCRDName, - expectError: false, - expectedCRD: testCRD, - }, - "CRDNotFound": { - reason: "Should return error when CRD doesn't exist in cache", - setupCRDs: []*extv1.CustomResourceDefinition{}, - searchName: testCRDName, - expectError: true, - expectedCRD: nil, - }, - "DifferentCRDInCache": { - reason: "Should return error when searching for CRD that doesn't exist", - setupCRDs: []*extv1.CustomResourceDefinition{ - tu.NewCRD("other."+testExampleOrgGroup, testExampleOrgGroup, "OtherKind").WithDefaultVersion().Build(), - }, - searchName: testCRDName, - expectError: true, - expectedCRD: nil, - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - // Create schema client with test CRDs - client := &DefaultSchemaClient{ - logger: tu.TestLogger(t, false), - crds: make([]*extv1.CustomResourceDefinition, 0), - crdByName: make(map[string]*extv1.CustomResourceDefinition), - resourceTypeMap: make(map[schema.GroupVersionKind]bool), - xrdToCRDName: make(map[string]string), + return true, nil, errors.New("connection refused") + }) } - // Pre-populate cache with test CRDs - for _, crd := range tc.setupCRDs { - client.addCRD(crd) - } + c := NewSchemaClient(&core.Clients{Dynamic: dc}, tu.NewMockTypeConverter().Build(), tu.TestLogger(t, false)) - // Call GetCRDByName - crd, err := client.GetCRDByName(tc.searchName) + got := make([]result, 0, len(tc.want)) - // Check error expectations - if tc.expectError { - if err == nil { - t.Errorf("\n%s\nGetCRDByName(): expected error but got none", tc.reason) + for i := range tc.want { + if i == 1 && tc.between != nil { + tc.between(t, dc) } - return - } - - if err != nil { - t.Errorf("\n%s\nGetCRDByName(): unexpected error: %v", tc.reason, err) - return - } + crd, err := c.GetCRDByName(t.Context(), crdName) - // Verify returned CRD - if crd == nil { - t.Errorf("\n%s\nGetCRDByName(): expected CRD but got nil", tc.reason) - return - } + r := result{Failed: err != nil, NotFound: apierrors.IsNotFound(err)} + if crd != nil { + r.Name = crd.GetName() + } - if crd.Name != tc.expectedCRD.Name { - t.Errorf("\n%s\nGetCRDByName(): expected CRD name %s, got %s", - tc.reason, tc.expectedCRD.Name, crd.Name) + got = append(got, r) } - if crd.Spec.Group != tc.expectedCRD.Spec.Group { - t.Errorf("\n%s\nGetCRDByName(): expected CRD group %s, got %s", - tc.reason, tc.expectedCRD.Spec.Group, crd.Spec.Group) + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("\n%s\nGetCRDByName(...) results: -want, +got:\n%s", tc.reason, diff) } }) } @@ -627,7 +489,7 @@ func TestSchemaClient_GetAllCRDs(t *testing.T) { resourceTypeMap: make(map[schema.GroupVersionKind]bool), crds: tc.setupCRDs, crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + missingCRDs: make(map[string]error), } // Add CRDs to name lookup map @@ -655,139 +517,6 @@ func TestSchemaClient_GetAllCRDs(t *testing.T) { } } -func TestExtractGVKsFromXRD(t *testing.T) { - tests := map[string]struct { - reason string - xrd *un.Unstructured - expectedGVKs []schema.GroupVersionKind - expectErr bool - errMsg string - }{ - "ValidV1XRD": { - reason: "Should extract GVKs from a valid v1 XRD with multiple versions", - xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", testXResourcePlural+".example.org"). - WithSpec(map[string]any{ - "group": testExampleOrgGroup, - "names": map[string]any{ - "kind": testXResourceKind, - "plural": testXResourcePlural, - "singular": "xresource", - }, - "versions": []any{ - map[string]any{ - "name": "v1alpha1", - "served": true, - }, - map[string]any{ - "name": "v1", - "served": true, - "referenceable": true, - }, - }, - }).Build(), - expectedGVKs: []schema.GroupVersionKind{ - {Group: testExampleOrgGroup, Version: "v1alpha1", Kind: testXResourceKind}, - {Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind}, - }, - expectErr: false, - }, - "ValidV2XRD": { - reason: "Should extract GVKs from a valid v2 XRD with multiple versions", - xrd: tu.NewResource("apiextensions.crossplane.io/v2", "CompositeResourceDefinition", testXResourcePlural+".example.org"). - WithSpec(map[string]any{ - "group": testExampleOrgGroup, - "names": map[string]any{ - "kind": testXResourceKind, - "plural": testXResourcePlural, - "singular": "xresource", - }, - "versions": []any{ - map[string]any{ - "name": "v1beta1", - "served": true, - }, - map[string]any{ - "name": "v1", - "served": true, - "referenceable": true, - }, - }, - }).Build(), - expectedGVKs: []schema.GroupVersionKind{ - {Group: testExampleOrgGroup, Version: "v1beta1", Kind: testXResourceKind}, - {Group: testExampleOrgGroup, Version: "v1", Kind: testXResourceKind}, - }, - expectErr: false, - }, - - "UnsupportedAPIVersion": { - reason: "Should fail when XRD has unsupported apiVersion", - // Unsupported version - xrd: tu.NewResource("apiextensions.crossplane.io/v3", "CompositeResourceDefinition", "invalid-xrd"). - WithSpec(map[string]any{ - "group": testExampleOrgGroup, - "names": map[string]any{ - "kind": testXResourceKind, - }, - "versions": []any{ - map[string]any{ - "name": "v1", - "served": true, - }, - }, - }).Build(), - expectErr: true, - errMsg: "unsupported XRD apiVersion", - }, - "ConversionError": { - reason: "Should fail when XRD cannot be converted to typed object", - xrd: tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "invalid-xrd"). - WithSpec(map[string]any{ - "group": testExampleOrgGroup, - "names": "invalid-names-should-be-object", // Invalid structure - "versions": []any{ - map[string]any{ - "name": "v1", - "served": true, - }, - }, - }).Build(), - expectErr: true, - errMsg: "cannot convert XRD", - }, - } - - for name, tc := range tests { - t.Run(name, func(t *testing.T) { - gvks, err := extractGVKsFromXRD(tc.xrd) - - if tc.expectErr { - if err == nil { - t.Errorf("\n%s\nextractGVKsFromXRD(): expected error but got none", tc.reason) - return - } - - if tc.errMsg != "" && !strings.Contains(err.Error(), tc.errMsg) { - t.Errorf("\n%s\nextractGVKsFromXRD(): expected error containing %q, got %q", - tc.reason, tc.errMsg, err.Error()) - } - - return - } - - if err != nil { - t.Errorf("\n%s\nextractGVKsFromXRD(): unexpected error: %v", tc.reason, err) - - return - } - - if diff := cmp.Diff(tc.expectedGVKs, gvks); diff != "" { - t.Errorf("\n%s\nextractGVKsFromXRD(): -want, +got:\n%s", tc.reason, diff) - } - }) - } -} - func TestSchemaClient_CachingBehavior(t *testing.T) { ctx := t.Context() scheme := runtime.NewScheme() @@ -838,7 +567,7 @@ func TestSchemaClient_CachingBehavior(t *testing.T) { resourceTypeMap: make(map[schema.GroupVersionKind]bool), crds: []*extv1.CustomResourceDefinition{}, crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + missingCRDs: make(map[string]error), } return client, &callCount @@ -882,7 +611,7 @@ func TestSchemaClient_CachingBehavior(t *testing.T) { resourceTypeMap: make(map[schema.GroupVersionKind]bool), crds: []*extv1.CustomResourceDefinition{}, crdByName: make(map[string]*extv1.CustomResourceDefinition), - xrdToCRDName: make(map[string]string), + missingCRDs: make(map[string]error), } // Pre-populate cache by making first call diff --git a/internal/diff/diffprocessor/defaulter.go b/internal/diff/diffprocessor/defaulter.go index a0945435..dc0cb30a 100644 --- a/internal/diff/diffprocessor/defaulter.go +++ b/internal/diff/diffprocessor/defaulter.go @@ -105,11 +105,11 @@ func otherStorageVersion(crd *extv1.CustomResourceDefinition, version string) st return "" } -// crdFor finds the CRD for gvk. An XR's comes from its XRD, by name, from the CRDs loaded when the -// processor initialized; any other resource's is looked up by GVK. A built-in type has none. +// crdFor finds the CRD for gvk. An XR's is named after its XRD (both are .); any other +// resource's is looked up by GVK. A built-in type has none. func (d *CRDDefaulter) crdFor(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { if xrd, err := d.defClient.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { - crd, err := d.schemaClient.GetCRDByName(xrd.GetName()) + crd, err := d.schemaClient.GetCRDByName(ctx, xrd.GetName()) if err != nil { return nil, errors.Wrapf(err, "cannot find CRD for XRD %s", xrd.GetName()) } diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 9f0f14f4..10470839 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -154,18 +154,13 @@ func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) return processor } -// Initialize loads required resources like CRDs and environment configs. +// Initialize prepares the requirements provider. CRDs and XRDs are fetched as the resources being +// diffed need them, so nothing unrelated to the input is read from the cluster. func (p *DefaultDiffProcessor) Initialize(ctx context.Context) error { p.config.Logger.Debug("Initializing diff processor") - // Load CRDs (handled by the schema validator) - err := p.initializeSchemaValidator(ctx) - if err != nil { - return err - } - // Init requirements provider - err = p.requirementsProvider.Initialize(ctx) + err := p.requirementsProvider.Initialize(ctx) if err != nil { return err } @@ -221,22 +216,6 @@ func CleanupDetached(ctx context.Context, c Cleaner, logger logging.Logger) { } } -// initializeSchemaValidator initializes the schema validator with CRDs. -func (p *DefaultDiffProcessor) initializeSchemaValidator(ctx context.Context) error { - // If the schema validator implements our interface with LoadCRDs, use it - if validator, ok := p.schemaValidator.(*DefaultSchemaValidator); ok { - err := validator.LoadCRDs(ctx) - if err != nil { - return errors.Wrap(err, "cannot load CRDs") - } - - p.config.Logger.Debug("Schema validator initialized with CRDs", - "crdCount", len(validator.GetCRDs())) - } - - return nil -} - // PerformDiff processes resources using a composition provider function, and returns the report for // the caller to render. The report is non-nil whenever resources were processed, even with an error. func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) { @@ -438,7 +417,7 @@ func (p *DefaultDiffProcessor) diffSingleResourceInternal(ctx context.Context, r p.config.Logger.Debug("Resource setup complete", "resource", resourceID, "composition", comp.GetName(), "revision", resolved.RevisionName) // Get functions for this composition (provider handles caching internally) - fns, err := p.functionProvider.GetFunctionsForComposition(comp) + fns, err := p.functionProvider.GetFunctionsForComposition(ctx, comp) if err != nil { p.config.Logger.Debug("Failed to get functions", "resource", resourceID, "error", err) return nil, types.RenderedResources{}, errors.Wrap(err, "cannot get functions for composition") diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index c6d60960..52dde738 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -16,8 +16,10 @@ import ( extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic/fake" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -29,6 +31,7 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/internal/diff/client/core" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" @@ -513,7 +516,6 @@ func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { Build(), Credential: &tu.MockCredentialClient{}, Definition: tu.NewMockDefinitionClient(). - WithEmptyXRDsFetch(). WithXRDForGVK(schema.GroupVersionKind{Group: testGroup, Version: "v1", Kind: testKind}, mainXRD). WithXRDForGVK(schema.GroupVersionKind{Group: "cpd.org", Version: "v1", Kind: "ComposedResource"}, composedXRD). Build(), @@ -1304,86 +1306,131 @@ func TestDefaultDiffProcessor_warnIfDeleting(t *testing.T) { } } -func TestDefaultDiffProcessor_Initialize(t *testing.T) { - // Setup test context - ctx := t.Context() +// TestDiffFetchesOnlyTheInputsCRD runs a diff through the real schema and definition clients, with +// validation and defaulting as the processor builds them, against a cluster that forbids listing XRDs +// and holds an XRD unrelated to the input whose CRD is missing. The run reads only what the input +// needs, so neither matters; the input's own CRD going missing still fails it. +func TestDiffFetchesOnlyTheInputsCRD(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: testGroup, Version: testAPIVersion, Kind: testKind} + xrdGVK := schema.GroupVersionKind{Group: xp.CrossplaneAPIExtGroup, Version: "v1", Kind: xp.CompositeResourceDefinitionKind} - // Create test resources - xrd1 := tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xrd1"). - WithSpecField("group", "example.org"). - WithSpecField("names", map[string]any{ - "kind": "XExampleResource", - "plural": "xexampleresources", - "singular": "xexampleresource", - }). + inputXRD := tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + WithVersion(testAPIVersion, true, true). + BuildAsUnstructured() + unrelatedXRD := tu.NewXRD("xothers."+testGroup, testGroup, "XOther"). + WithPlural("xothers"). + WithSingular("xother"). + WithVersion(testAPIVersion, true, true). + BuildAsUnstructured() + + xr := tu.NewResource(testGroup+"/"+testAPIVersion, testKind, "my-xr").WithSpecField("coolField", "value").Build() + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef(testGroup+"/"+testAPIVersion, testKind). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). Build() - // Test cases tests := map[string]struct { - setupMocks func() (k8.Clients, xp.Clients) - processorOpts []ProcessorOption - want error + reason string + // crds are the CRDs in the cluster. + crds []*extv1.CustomResourceDefinition + wantHasChanges bool + wantErr string }{ - "XRDsError": { - setupMocks: func() (k8.Clients, xp.Clients) { - // Create Kubernetes client mocks - k8sClients := k8.Clients{ - Apply: tu.NewMockApplyClient().Build(), - Resource: tu.NewMockResourceClient().Build(), - Schema: tu.NewMockSchemaClient().Build(), - Type: tu.NewMockTypeConverter().Build(), - } + "UnrelatedXRDWithoutCRD": { + reason: "An XRD unrelated to the input, and its missing CRD, must not fail the run", + crds: []*extv1.CustomResourceDefinition{makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)}, + wantHasChanges: true, + }, + "InputXRDWithoutCRD": { + reason: "The input's own CRD missing still fails the run", + wantErr: `unable to process resource XR1/my-xr: cannot apply XRD defaults: cannot default example.org/v1, Kind=XR1 "my-xr": ` + + `cannot find CRD for XRD xr1s.example.org: cannot get CRD xr1s.example.org: ` + + `customresourcedefinitions.apiextensions.k8s.io "xr1s.example.org" not found`, + }, + } - // Create Crossplane client mocks with a failing Definition client - xpClients := xp.Clients{ - Composition: tu.NewMockCompositionClient().Build(), - Credential: &tu.MockCredentialClient{}, - Definition: tu.NewMockDefinitionClient(). - WithFailedXRDsFetch("XRD not found"). - Build(), - Environment: tu.NewMockEnvironmentClient(). - WithNoEnvironmentConfigs(). - Build(), - Function: tu.NewMockFunctionClient().Build(), - ResourceTree: tu.NewMockResourceTreeClient().Build(), - } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + logger := tu.TestLogger(t, false) - return k8sClients, xpClients - }, - processorOpts: testProcessorOptions(t), - want: errors.Wrap(errors.Wrap(errors.New("XRD not found"), "cannot get XRDs"), "cannot load CRDs"), - }, - "EnvConfigsError": { - setupMocks: func() (k8.Clients, xp.Clients) { - // Create Kubernetes client mocks - k8sClients := k8.Clients{ - Apply: tu.NewMockApplyClient().Build(), - Resource: tu.NewMockResourceClient().Build(), - Schema: tu.NewMockSchemaClient().Build(), - Type: tu.NewMockTypeConverter().Build(), - } + crdObjs := make([]runtime.Object, 0, len(tt.crds)) - // Create Crossplane client mocks with a failing Environment client - xpClients := xp.Clients{ - Composition: tu.NewMockCompositionClient().Build(), - Credential: &tu.MockCredentialClient{}, - Definition: tu.NewMockDefinitionClient(). - WithEmptyXRDsFetch(). - Build(), - Environment: tu.NewMockEnvironmentClient(). - WithGetEnvironmentConfigs(func(_ context.Context) ([]*un.Unstructured, error) { - return nil, errors.New("env configs not found") - }). - Build(), - Function: tu.NewMockFunctionClient().Build(), - ResourceTree: tu.NewMockResourceTreeClient().Build(), + for _, crd := range tt.crds { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(crd) + if err != nil { + t.Fatalf("cannot convert CRD: %v", err) } - return k8sClients, xpClients - }, - processorOpts: testProcessorOptions(t), - want: errors.Wrap(errors.New("env configs not found"), "cannot get environment configs"), - }, + crdObjs = append(crdObjs, &un.Unstructured{Object: obj}) + } + + typeConverter := tu.NewMockTypeConverter().WithDefaultGetResourceNameForGVK().Build() + resourceClient := tu.NewMockResourceClient(). + WithFoundGVKs([]schema.GroupVersionKind{xrdGVK}). + WithResourcesExist(inputXRD, unrelatedXRD). + WithErrorOnListOf(xrdGVK). + WithClusterScopedResource(xrGVK). + Build() + + defClient := xp.NewDefinitionClient(resourceClient, typeConverter, logger) + if err := defClient.Initialize(ctx); err != nil { + t.Fatalf("cannot initialize definition client: %v", err) + } + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().WithSuccessfulDryRun().WithSuccessfulDryRunCreate().Build(), + Resource: resourceClient, + Schema: k8.NewSchemaClient(&core.Clients{Dynamic: fake.NewSimpleDynamicClient(runtime.NewScheme(), crdObjs...)}, typeConverter, logger), + Type: typeConverter, + } + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().WithSuccessfulCompositionMatch(composition).Build(), + Credential: &tu.MockCredentialClient{}, + Definition: defClient, + Environment: tu.NewMockEnvironmentClient().WithNoEnvironmentConfigs().Build(), + Function: tu.NewMockFunctionClient().WithSuccessfulFunctionsFetch([]pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "function-test"}}}).Build(), + ResourceTree: tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, testProcessorOptions(t)...) + if err := processor.Initialize(ctx); err != nil { + t.Fatalf("\n%s\nInitialize(...): unexpected error: %v", tt.reason, err) + } + + report, err := processor.PerformDiff(ctx, []*un.Unstructured{xr}, func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + }) + + gotErr := "" + if err != nil { + gotErr = err.Error() + } + + if diff := gcmp.Diff(tt.wantErr, gotErr); diff != "" { + t.Errorf("\n%s\nPerformDiff(...): -want error, +got error:\n%s", tt.reason, diff) + } + + if got := report != nil && report.HasChanges(); got != tt.wantHasChanges { + t.Errorf("\n%s\nPerformDiff(...): report.HasChanges() = %v, want %v", tt.reason, got, tt.wantHasChanges) + } + }) + } +} + +func TestDefaultDiffProcessor_Initialize(t *testing.T) { + // Setup test context + ctx := t.Context() + + // Test cases + tests := map[string]struct { + setupMocks func() (k8.Clients, xp.Clients) + processorOpts []ProcessorOption + want error + }{ "Success": { setupMocks: func() (k8.Clients, xp.Clients) { // Create Kubernetes client mocks @@ -1400,9 +1447,7 @@ func TestDefaultDiffProcessor_Initialize(t *testing.T) { xpClients := xp.Clients{ Composition: tu.NewMockCompositionClient().Build(), Credential: &tu.MockCredentialClient{}, - Definition: tu.NewMockDefinitionClient(). - WithSuccessfulXRDsFetch([]*un.Unstructured{xrd1}). - Build(), + Definition: tu.NewMockDefinitionClient().Build(), Environment: tu.NewMockEnvironmentClient(). WithNoEnvironmentConfigs(). Build(), diff --git a/internal/diff/diffprocessor/function_provider.go b/internal/diff/diffprocessor/function_provider.go index 21d3aeba..9ac2b287 100644 --- a/internal/diff/diffprocessor/function_provider.go +++ b/internal/diff/diffprocessor/function_provider.go @@ -35,7 +35,7 @@ import ( // Different implementations can fetch functions on-demand or return cached functions. type FunctionProvider interface { // GetFunctionsForComposition returns the functions needed to render a composition. - GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) + GetFunctionsForComposition(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) // Cleaner stops and removes any resources created during function execution. // For providers that don't create resources (like the wrappers), this is a no-op. @@ -78,12 +78,12 @@ func NewCachedFunctionProvider(fnClient xp.FunctionClient, logger logging.Logger } // GetFunctionsForComposition fetches and caches functions on first call per composition. -func (p *CachedFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { +func (p *CachedFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { compName := comp.GetName() key := functionCacheKey(comp) - // Held across the fetch, which only reads the function client's in-memory cache, so each - // composition is fetched once. + // Held across the fetch, so each composition's functions are fetched once however many + // concurrent renders ask for them. p.mu.Lock() defer p.mu.Unlock() @@ -95,7 +95,7 @@ func (p *CachedFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv // Cache miss - fetch and cache functions p.logger.Debug("Fetching functions for caching", "composition", compName) - fns, err := p.fnClient.GetFunctionsFromPipeline(comp) + fns, err := p.fnClient.GetFunctionsFromPipeline(ctx, comp) if err != nil { return nil, errors.Wrap(err, "cannot get functions from pipeline") } @@ -157,8 +157,8 @@ func NewRegistryOverrideFunctionProvider(inner FunctionProvider, registry string // GetFunctionsForComposition delegates to the wrapped provider and rewrites // the registry portion of each returned function's package ref. -func (p *RegistryOverrideFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { - fns, err := p.inner.GetFunctionsForComposition(comp) +func (p *RegistryOverrideFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns, err := p.inner.GetFunctionsForComposition(ctx, comp) if err != nil { return nil, err } @@ -216,8 +216,8 @@ func NewEnvInjectingFunctionProvider(inner FunctionProvider, envs map[string]str // configured env pairs into each function's runtime-docker-env annotation. The // upsert is idempotent so repeated calls over a caching inner provider don't // duplicate pairs. -func (p *EnvInjectingFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { - fns, err := p.inner.GetFunctionsForComposition(comp) +func (p *EnvInjectingFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + fns, err := p.inner.GetFunctionsForComposition(ctx, comp) if err != nil { return nil, err } diff --git a/internal/diff/diffprocessor/function_provider_test.go b/internal/diff/diffprocessor/function_provider_test.go index c0f045db..9dddecc7 100644 --- a/internal/diff/diffprocessor/function_provider_test.go +++ b/internal/diff/diffprocessor/function_provider_test.go @@ -17,6 +17,7 @@ limitations under the License. package diffprocessor import ( + "context" "strconv" "strings" "sync" @@ -80,7 +81,7 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_LazyLoading(t *testin ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, } - fns, err := provider.GetFunctionsForComposition(comp) + fns, err := provider.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition() error = %v", err) } @@ -124,7 +125,7 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_CacheHit(t *testing.T } // First call should fetch - fns1, err := provider.GetFunctionsForComposition(comp) + fns1, err := provider.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("First GetFunctionsForComposition() error = %v", err) } @@ -134,7 +135,7 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_CacheHit(t *testing.T } // Second call should use cache - fns2, err := provider.GetFunctionsForComposition(comp) + fns2, err := provider.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("Second GetFunctionsForComposition() error = %v", err) } @@ -165,7 +166,7 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_Error(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, } - _, err := provider.GetFunctionsForComposition(comp) + _, err := provider.GetFunctionsForComposition(t.Context(), comp) if err == nil { t.Fatal("GetFunctionsForComposition() expected error, got nil") } @@ -179,7 +180,7 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_MultipleCompositions( fetchCalls := make(map[string]int) fnClient := tu.NewMockFunctionClient(). - WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + WithGetFunctionsFromPipeline(func(_ context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { fetchCalls[comp.Name]++ if comp.Name == "composition-1" { @@ -219,19 +220,19 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_MultipleCompositions( } // Fetch for composition 1 - fns1, err := provider.GetFunctionsForComposition(comp1) + fns1, err := provider.GetFunctionsForComposition(t.Context(), comp1) if err != nil { t.Fatalf("GetFunctionsForComposition(comp1) error = %v", err) } // Fetch for composition 2 - fns2, err := provider.GetFunctionsForComposition(comp2) + fns2, err := provider.GetFunctionsForComposition(t.Context(), comp2) if err != nil { t.Fatalf("GetFunctionsForComposition(comp2) error = %v", err) } // Fetch comp1 again (should hit cache) - fns1Again, err := provider.GetFunctionsForComposition(comp1) + fns1Again, err := provider.GetFunctionsForComposition(t.Context(), comp1) if err != nil { t.Fatalf("GetFunctionsForComposition(comp1 again) error = %v", err) } @@ -259,7 +260,7 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_MultipleCompositions( func TestCachedFunctionProvider_GetFunctionsForComposition_SameNameDifferentPipelinesNotShared(t *testing.T) { // A CompositionRevision and its Composition share a name but may differ in pipeline. fnClient := tu.NewMockFunctionClient(). - WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + WithGetFunctionsFromPipeline(func(_ context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { fns := make([]pkgv1.Function, 0, len(comp.Spec.Pipeline)) for _, step := range comp.Spec.Pipeline { fns = append(fns, pkgv1.Function{ObjectMeta: metav1.ObjectMeta{Name: step.FunctionRef.Name}}) @@ -283,11 +284,11 @@ func TestCachedFunctionProvider_GetFunctionsForComposition_SameNameDifferentPipe return comp } - if _, err := provider.GetFunctionsForComposition(withPipeline("function-a")); err != nil { + if _, err := provider.GetFunctionsForComposition(t.Context(), withPipeline("function-a")); err != nil { t.Fatalf("GetFunctionsForComposition(function-a) error = %v", err) } - got, err := provider.GetFunctionsForComposition(withPipeline("function-b")) + got, err := provider.GetFunctionsForComposition(t.Context(), withPipeline("function-b")) if err != nil { t.Fatalf("GetFunctionsForComposition(function-b) error = %v", err) } @@ -390,7 +391,7 @@ func TestRegistryOverrideFunctionProvider(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "test-composition"}, } - fns, err := provider.GetFunctionsForComposition(comp) + fns, err := provider.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition() error = %v", err) } @@ -457,7 +458,7 @@ func TestEnvInjectingFunctionProvider(t *testing.T) { t.Run("injects when annotation absent", func(t *testing.T) { p := NewEnvInjectingFunctionProvider(newInner(nil), envs, tu.TestLogger(t, false)) - fns, err := p.GetFunctionsForComposition(comp) + fns, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition() error = %v", err) } @@ -470,7 +471,7 @@ func TestEnvInjectingFunctionProvider(t *testing.T) { t.Run("appends to existing runtime-docker-env", func(t *testing.T) { p := NewEnvInjectingFunctionProvider(newInner(map[string]string{render.AnnotationKeyRuntimeEnvironmentVariables: "FOO=bar"}), envs, tu.TestLogger(t, false)) - fns, err := p.GetFunctionsForComposition(comp) + fns, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition() error = %v", err) } @@ -482,11 +483,11 @@ func TestEnvInjectingFunctionProvider(t *testing.T) { t.Run("idempotent across repeated calls", func(t *testing.T) { p := NewEnvInjectingFunctionProvider(newInner(nil), envs, tu.TestLogger(t, false)) - if _, err := p.GetFunctionsForComposition(comp); err != nil { + if _, err := p.GetFunctionsForComposition(t.Context(), comp); err != nil { t.Fatalf("first call error = %v", err) } - fns, err := p.GetFunctionsForComposition(comp) + fns, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("second call error = %v", err) } @@ -519,7 +520,7 @@ func TestCachedFunctionProvider_ConcurrentUse(t *testing.T) { var wg sync.WaitGroup for i := range 8 { wg.Go(func() { - fns, err := provider.GetFunctionsForComposition(comp) + fns, err := provider.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Errorf("GetFunctionsForComposition(): %v", err) return @@ -535,7 +536,7 @@ func TestCachedFunctionProvider_ConcurrentUse(t *testing.T) { t.Errorf("fetches = %d, want 1", got) } - fns, err := provider.GetFunctionsForComposition(comp) + fns, err := provider.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition(): %v", err) } diff --git a/internal/diff/diffprocessor/project_function_provider.go b/internal/diff/diffprocessor/project_function_provider.go index dad143e4..b414e4ff 100644 --- a/internal/diff/diffprocessor/project_function_provider.go +++ b/internal/diff/diffprocessor/project_function_provider.go @@ -77,10 +77,10 @@ func NewProjectFunctionProviderFactory(fns []pkgv1.Function) func(xp.FunctionCli // GetFunctionsForComposition returns one function per pipeline step, in // pipeline order: the project function when the step names one, otherwise the // function the wrapped provider returns for that step. -func (p *ProjectFunctionProvider) GetFunctionsForComposition(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { +func (p *ProjectFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { // The wrapped provider owns rejecting non-pipeline compositions. if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { - return p.inner.GetFunctionsForComposition(comp) + return p.inner.GetFunctionsForComposition(ctx, comp) } clusterSteps := make([]apiextensionsv1.PipelineStep, 0, len(comp.Spec.Pipeline)) @@ -91,7 +91,7 @@ func (p *ProjectFunctionProvider) GetFunctionsForComposition(comp *apiextensions } if len(clusterSteps) == len(comp.Spec.Pipeline) { - return p.inner.GetFunctionsForComposition(comp) + return p.inner.GetFunctionsForComposition(ctx, comp) } clusterFns := map[string]pkgv1.Function{} @@ -103,7 +103,7 @@ func (p *ProjectFunctionProvider) GetFunctionsForComposition(comp *apiextensions narrowed := comp.DeepCopy() narrowed.Spec.Pipeline = clusterSteps - fns, err := p.inner.GetFunctionsForComposition(narrowed) + fns, err := p.inner.GetFunctionsForComposition(ctx, narrowed) if err != nil { return nil, err } diff --git a/internal/diff/diffprocessor/project_function_provider_test.go b/internal/diff/diffprocessor/project_function_provider_test.go index 492d559b..cada54d4 100644 --- a/internal/diff/diffprocessor/project_function_provider_test.go +++ b/internal/diff/diffprocessor/project_function_provider_test.go @@ -17,6 +17,7 @@ limitations under the License. package diffprocessor import ( + "context" "strings" "sync" "sync/atomic" @@ -61,7 +62,7 @@ func pipelineComp(name string, fnNames ...string) *apiextensionsv1.Composition { // function client does, and records the steps it was asked for. func clusterFnClient(installed map[string]pkgv1.Function, asked *[][]string, mu *sync.Mutex) xp.FunctionClient { return tu.NewMockFunctionClient(). - WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + WithGetFunctionsFromPipeline(func(_ context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { return nil, errors.Errorf("unsupported composition Mode '%s'", comp.Spec.Mode) } @@ -198,7 +199,7 @@ func TestProjectFunctionProvider_GetFunctionsForComposition(t *testing.T) { p := NewProjectFunctionProviderFactory(tc.project)(clusterFnClient(installed, &asked, &mu), tu.TestLogger(t, false)) - got, err := p.GetFunctionsForComposition(tc.comp) + got, err := p.GetFunctionsForComposition(t.Context(), tc.comp) if tc.want.errSub != "" { if err == nil || !strings.Contains(err.Error(), tc.want.errSub) { t.Fatalf("\n%s\nGetFunctionsForComposition(): want error containing %q, got %v", tc.reason, tc.want.errSub, err) @@ -230,12 +231,12 @@ func TestProjectFunctionProvider_CopyIsolation(t *testing.T) { p := NewProjectFunctionProvider(NewCachedFunctionProvider(clusterFnClient(installed, nil, nil), tu.TestLogger(t, false)), project, tu.TestLogger(t, false)) comp := pipelineComp("comp", "function-a", "function-b", "function-a") - first, err := p.GetFunctionsForComposition(comp) + first, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition(): %v", err) } - want, err := p.GetFunctionsForComposition(comp) + want, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition(): %v", err) } @@ -260,7 +261,7 @@ func TestProjectFunctionProvider_CopyIsolation(t *testing.T) { project[0].Spec.Package = "mutated-input" project[0].Annotations["k"] = "mutated-input" - got, err := p.GetFunctionsForComposition(comp) + got, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Fatalf("GetFunctionsForComposition(): %v", err) } @@ -277,7 +278,7 @@ func TestProjectFunctionProvider_PullPolicy(t *testing.T) { p := NewProjectFunctionProvider(NewCachedFunctionProvider(clusterFnClient(nil, nil, nil), tu.TestLogger(t, false)), []pkgv1.Function{testFn("function-a", "xpkg.io/project/function-a:local")}, tu.TestLogger(t, false)) - fns, err := p.GetFunctionsForComposition(pipelineComp("comp", "function-a")) + fns, err := p.GetFunctionsForComposition(t.Context(), pipelineComp("comp", "function-a")) if err != nil { t.Fatalf("GetFunctionsForComposition(): %v", err) } @@ -298,9 +299,9 @@ func TestProjectFunctionProvider_Concurrent(t *testing.T) { var fetches atomic.Int32 inner := tu.NewMockFunctionClient(). - WithGetFunctionsFromPipeline(func(comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { + WithGetFunctionsFromPipeline(func(ctx context.Context, comp *apiextensionsv1.Composition) ([]pkgv1.Function, error) { fetches.Add(1) - return clusterFnClient(installed, nil, nil).GetFunctionsFromPipeline(comp) + return clusterFnClient(installed, nil, nil).GetFunctionsFromPipeline(ctx, comp) }). Build() @@ -314,7 +315,7 @@ func TestProjectFunctionProvider_Concurrent(t *testing.T) { var wg sync.WaitGroup for range 16 { wg.Go(func() { - got, err := p.GetFunctionsForComposition(comp) + got, err := p.GetFunctionsForComposition(t.Context(), comp) if err != nil { t.Errorf("GetFunctionsForComposition(): %v", err) return diff --git a/internal/diff/diffprocessor/requirements_provider.go b/internal/diff/diffprocessor/requirements_provider.go index 5af7c3e2..1bda520f 100644 --- a/internal/diff/diffprocessor/requirements_provider.go +++ b/internal/diff/diffprocessor/requirements_provider.go @@ -61,22 +61,11 @@ func NewRequirementsProvider(res k8.ResourceClient, env xp.EnvironmentClient, lo } } -// Initialize pre-fetches resources like environment configs. -func (p *RequirementsProvider) Initialize(ctx context.Context) error { - p.logger.Debug("Initializing extra resource provider") - - // Pre-fetch environment configs - envConfigs, err := p.envClient.GetEnvironmentConfigs(ctx) - if err != nil { - return errors.Wrap(err, "cannot get environment configs") - } - - // Add to cache - p.cacheResources(envConfigs) - - p.logger.Debug("Extra resource provider initialized", - "envConfigCount", len(envConfigs), - "cacheSize", len(p.resourceCache)) +// Initialize prepares the provider. It fetches nothing: required resources, environment configs +// included, are fetched when a render asks for them (see ResolveSelectors), so the run needs no +// permission to list them. +func (p *RequirementsProvider) Initialize(_ context.Context) error { + p.logger.Debug("Extra resource provider initialized") return nil } diff --git a/internal/diff/diffprocessor/requirements_provider_test.go b/internal/diff/diffprocessor/requirements_provider_test.go index 0436621f..5a89bf64 100644 --- a/internal/diff/diffprocessor/requirements_provider_test.go +++ b/internal/diff/diffprocessor/requirements_provider_test.go @@ -13,9 +13,78 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) +// TestRequirementsWithoutEnvironmentConfigList checks that credentials which cannot list +// EnvironmentConfigs can still initialize and resolve a name selector for one, which is fetched +// by name. +func TestRequirementsWithoutEnvironmentConfigList(t *testing.T) { + envConfigGVK := schema.GroupVersionKind{Group: "apiextensions.crossplane.io", Version: "v1beta1", Kind: "EnvironmentConfig"} + envConfig := tu.NewResource("apiextensions.crossplane.io/v1beta1", "EnvironmentConfig", "env-config"). + WithSpecField("data", map[string]any{"region": "us-east-1"}). + Build() + + type want struct { + resources []*un.Unstructured + } + + tests := map[string]struct { + reason string + name string + want want + }{ + "EnvironmentConfigExists": { + reason: "Should return the named EnvironmentConfig", + name: "env-config", + want: want{resources: []*un.Unstructured{envConfig}}, + }, + "EnvironmentConfigMissing": { + reason: "Should treat a missing EnvironmentConfig as an unmet requirement, not an error", + name: "absent", + want: want{}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + logger := tu.TestLogger(t, false) + + resourceClient := tu.NewMockResourceClient(). + WithFoundGVKs([]schema.GroupVersionKind{envConfigGVK}). + WithClusterScopedResource(envConfigGVK). + WithResourcesExist(envConfig). + WithErrorOnListOf(envConfigGVK). + Build() + + envClient := xp.NewEnvironmentClient(resourceClient, logger) + if err := envClient.Initialize(ctx); err != nil { + t.Fatalf("\n%s\nEnvironmentClient.Initialize(): unexpected error: %v", tt.reason, err) + } + + provider := NewRequirementsProvider(resourceClient, envClient, logger) + if err := provider.Initialize(ctx); err != nil { + t.Fatalf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + } + + got, err := provider.ResolveSelectors(ctx, []*v1.ResourceSelector{{ + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &v1.ResourceSelector_MatchName{MatchName: tt.name}, + }}, "") + if err != nil { + t.Fatalf("\n%s\nResolveSelectors(): unexpected error: %v", tt.reason, err) + } + + if diff := cmp.Diff(tt.want.resources, got); diff != "" { + t.Errorf("\n%s\nResolveSelectors(): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + // TestRequirementsProvider_ResolveSelectors covers the selector-flat entry // point used by the new render.CompositionOutputs.RequiredResources shape. func TestRequirementsProvider_ResolveSelectors(t *testing.T) { diff --git a/internal/diff/diffprocessor/schema_validator.go b/internal/diff/diffprocessor/schema_validator.go index f32c6c13..523abdbd 100644 --- a/internal/diff/diffprocessor/schema_validator.go +++ b/internal/diff/diffprocessor/schema_validator.go @@ -50,29 +50,6 @@ func NewSchemaValidator(sClient k8.SchemaClient, rClient k8.ResourceClient, dCli } } -// LoadCRDs loads CRDs from the cluster. -func (v *DefaultSchemaValidator) LoadCRDs(ctx context.Context) error { - v.logger.Debug("Loading CRDs from cluster") - - // Get XRDs from the client (which will use its cache when available) - xrds, err := v.defClient.GetXRDs(ctx) - if err != nil { - v.logger.Debug("Failed to get XRDs", "error", err) - return errors.Wrap(err, "cannot get XRDs") - } - - // Use SchemaClient to load CRDs from XRDs - err = v.schemaClient.LoadCRDsFromXRDs(ctx, xrds) - if err != nil { - v.logger.Debug("Failed to load CRDs into schema client", "error", err) - return errors.Wrap(err, "cannot load CRDs into schema client") - } - - // Logging is handled internally by the schema client - - return nil -} - // GetCRDs returns the current CRDs. func (v *DefaultSchemaValidator) GetCRDs() []*extv1.CustomResourceDefinition { return v.schemaClient.GetAllCRDs() @@ -99,7 +76,7 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U resources = append(resources, &un.Unstructured{Object: composed[i].UnstructuredContent()}) } - // Ensure we have all the required CRDs + // Fetch the CRDs of the XR and the composed resources: none is loaded until a resource needs it. v.logger.Debug("Ensuring required CRDs for validation", "cachedCRDs", len(v.schemaClient.GetAllCRDs()), "resourceCount", len(resources)) diff --git a/internal/diff/diffprocessor/schema_validator_test.go b/internal/diff/diffprocessor/schema_validator_test.go index 36537dca..97c39b84 100644 --- a/internal/diff/diffprocessor/schema_validator_test.go +++ b/internal/diff/diffprocessor/schema_validator_test.go @@ -13,7 +13,6 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" cpd "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" - xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" tu "github.com/crossplane/cli/v2/internal/diff/testutils" pkgvalidate "github.com/crossplane/cli/v2/pkg/validate" ) @@ -114,7 +113,6 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { WithCachingBehavior(). Build() def := tu.NewMockDefinitionClient(). - WithEmptyXRDsFetch(). Build() return sch, def @@ -135,7 +133,6 @@ func TestDefaultSchemaValidator_ValidateResources(t *testing.T) { WithCachingBehavior(). Build() def := tu.NewMockDefinitionClient(). - WithEmptyXRDsFetch(). Build() return sch, def @@ -330,92 +327,6 @@ func TestDefaultSchemaValidator_EnsureComposedResourceCRDs(t *testing.T) { } } -func TestDefaultSchemaValidator_LoadCRDs(t *testing.T) { - ctx := t.Context() - - // Create sample CRDs as un - xrdUn := tu.NewResource("apiextensions.crossplane.io/v1", "CompositeResourceDefinition", "xrd1"). - WithSpecField("group", "testExampleOrg"). - WithSpecField("names", map[string]any{ - "kind": "XR", - "plural": "xrs", - "singular": "xr", - }). - Build() - - tests := map[string]struct { - setupClient func() xp.DefinitionClient - expectedErr bool - expectedErrMsg string - // for caching tests - callTwice bool // Test making two calls to LoadCRDs - expectXRDCalls int // Expected number of calls to GetXRDs - }{ - "SuccessfulLoad": { - setupClient: func() xp.DefinitionClient { - return tu.NewMockDefinitionClient(). - WithSuccessfulXRDsFetch([]*un.Unstructured{xrdUn}). - Build() - }, - expectedErr: false, - }, - "XRDFetchError": { - setupClient: func() xp.DefinitionClient { - return tu.NewMockDefinitionClient(). - WithFailedXRDsFetch("failed to fetch XRDs"). - Build() - }, - expectedErr: true, - }, - "UsesCachedXRDs": { - setupClient: func() xp.DefinitionClient { - // Create a tracking client that counts GetXRDs calls - return &xrdCountingClient{ - MockDefinitionClient: *tu.NewMockDefinitionClient(). - WithSuccessfulXRDsFetch([]*un.Unstructured{xrdUn}). - Build(), - } - }, - expectedErr: false, - callTwice: true, // Make two calls to LoadCRDs - expectXRDCalls: 1, // GetXRDs should only be called once due to caching - }, - } - - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - defClient := tt.setupClient() - logger := tu.TestLogger(t, false) - - // Create the schema validator with caching behavior - validator := NewSchemaValidator(tu.NewMockSchemaClient().WithCachingBehavior().Build(), tu.NewMockResourceClient().Build(), defClient, logger) - - // Call the function under test - err := validator.(*DefaultSchemaValidator).LoadCRDs(ctx) - - // Check error expectations - if tt.expectedErr { - if err == nil { - t.Errorf("LoadCRDs() expected error but got none") - } - - return - } - - if err != nil { - t.Errorf("LoadCRDs() unexpected error: %v", err) - return - } - - // Verify CRDs were loaded (for successful case) - crds := validator.(*DefaultSchemaValidator).GetCRDs() - if len(crds) == 0 { - t.Errorf("LoadCRDs() did not load any CRDs") - } - }) - } -} - // Helper function to create a simple CRD. func makeCRD(name string, kind string, group string, version string) *extv1.CustomResourceDefinition { return tu.NewCRD(name, group, kind). @@ -438,19 +349,6 @@ func createCRDWithStringField(baseCRD *extv1.CustomResourceDefinition) *extv1.Cu return crd } -// Helper type to track GetXRDs calls. -type xrdCountingClient struct { - tu.MockDefinitionClient - - getXRDsCallCount int -} - -// Override GetXRDs to count calls. -func (c *xrdCountingClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, error) { - c.getXRDsCallCount++ - return c.MockDefinitionClient.GetXRDs(ctx) -} - // ValidateResources must be read-only. Its results feed the diff calculator, and anything it added to // a resource would reach the server-side apply payload and claim that field (#503). The CRD here both // defaults and REQUIRES deletionPolicy, and the resource omits it, so the case also pins that diff --git a/internal/diff/integration/testdata/diff/first-xr-with-composition-ref.yaml b/internal/diff/integration/testdata/diff/first-xr-with-composition-ref.yaml new file mode 100644 index 00000000..9c567aa2 --- /dev/null +++ b/internal/diff/integration/testdata/diff/first-xr-with-composition-ref.yaml @@ -0,0 +1,11 @@ +# first-xr.yaml naming its composition, so finding the composition needs no list of every Composition. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: first-resource + namespace: default +spec: + coolField: "first-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/internal/diff/integration/testdata/diff/resources/composition-with-missing-function.yaml b/internal/diff/integration/testdata/diff/resources/composition-with-missing-function.yaml new file mode 100644 index 00000000..cb17b40f --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/composition-with-missing-function.yaml @@ -0,0 +1,17 @@ +# Names a function, function-not-installed, that no Function on the cluster provides. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-not-installed + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/diff/resources/unestablished-xrd.yaml b/internal/diff/integration/testdata/diff/resources/unestablished-xrd.yaml new file mode 100644 index 00000000..286e15ee --- /dev/null +++ b/internal/diff/integration/testdata/diff/resources/unestablished-xrd.yaml @@ -0,0 +1,25 @@ +# An XRD unrelated to any input, applied with no generated CRD: one Crossplane has not established yet, +# so the apiserver serves neither its XR type nor its claim type. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xunestablishedresources.unrelated.example.org +spec: + group: unrelated.example.org + names: + kind: XUnestablishedResource + plural: xunestablishedresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go index 5109b11b..9658391c 100644 --- a/internal/diff/testutils/mock_builder.go +++ b/internal/diff/testutils/mock_builder.go @@ -14,6 +14,7 @@ import ( apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" @@ -54,6 +55,9 @@ type MockResourceClientBuilder struct { // WithNamespacedResource / WithClusterScopedResource calls so the two // can be combined on one builder. resourceScopes map[schema.GroupVersionKind]bool + + // forbiddenLists holds the GVKs WithErrorOnListOf denies a full list of. + forbiddenLists map[schema.GroupVersionKind]bool } // NewMockResourceClient creates a new MockResourceClientBuilder. @@ -175,6 +179,22 @@ func (b *MockResourceClientBuilder) WithListResourcesFailure(errorStr string) *M }) } +// WithErrorOnListOf makes ListResources return Forbidden for these GVKs, as for credentials +// without list permission on them, and behave as otherwise configured for the rest. It can be +// combined with any other ListResources option, in any order. GetResource and +// GetResourcesByLabel are unaffected. +func (b *MockResourceClientBuilder) WithErrorOnListOf(gvks ...schema.GroupVersionKind) *MockResourceClientBuilder { + if b.forbiddenLists == nil { + b.forbiddenLists = make(map[schema.GroupVersionKind]bool) + } + + for _, gvk := range gvks { + b.forbiddenLists[gvk] = true + } + + return b +} + // WithGetResourcesByLabel sets the GetResourcesByLabel behavior. func (b *MockResourceClientBuilder) WithGetResourcesByLabel(fn func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error)) *MockResourceClientBuilder { b.mock.GetResourcesByLabelFn = fn @@ -227,6 +247,37 @@ func (b *MockResourceClientBuilder) WithResourcesFoundByLabel(resources []*un.Un }) } +// WithListableResources serves resources to ListResources and GetResourcesByLabel as a cluster holding +// them would: each returns those of the requested GVK in the requested namespace (every namespace for +// ""), and GetResourcesByLabel only those its selector matches, evaluated as the apiserver does. Other +// GVKs list empty. +func (b *MockResourceClientBuilder) WithListableResources(resources ...*un.Unstructured) *MockResourceClientBuilder { + listed := func(gvk schema.GroupVersionKind, namespace string, sel labels.Selector) []*un.Unstructured { + out := []*un.Unstructured{} + + for _, r := range resources { + if r.GroupVersionKind() == gvk && (namespace == "" || r.GetNamespace() == namespace) && sel.Matches(labels.Set(r.GetLabels())) { + out = append(out, r) + } + } + + return out + } + + b.WithListResources(func(_ context.Context, gvk schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) { + return listed(gvk, namespace, labels.Everything()), nil + }) + + return b.WithGetResourcesByLabel(func(_ context.Context, gvk schema.GroupVersionKind, namespace string, ls metav1.LabelSelector) ([]*un.Unstructured, error) { + sel, err := metav1.LabelSelectorAsSelector(&ls) + if err != nil { + return nil, err + } + + return listed(gvk, namespace, sel), nil + }) +} + // WithGetAllResourcesByLabels sets the GetAllResourcesByLabels behavior. func (b *MockResourceClientBuilder) WithGetAllResourcesByLabels(fn func(context.Context, []schema.GroupVersionKind, []metav1.LabelSelector) ([]*un.Unstructured, error)) *MockResourceClientBuilder { b.mock.GetAllResourcesByLabelsFn = fn @@ -281,7 +332,25 @@ func (b *MockResourceClientBuilder) withResourceScopes(gvks []schema.GroupVersio // Build returns the built mock. func (b *MockResourceClientBuilder) Build() *MockResourceClient { - return b.mock + if len(b.forbiddenLists) == 0 { + return b.mock + } + + forbidden := b.forbiddenLists + m := *b.mock + m.ListResourcesFn = func(ctx context.Context, gvk schema.GroupVersionKind, namespace string) ([]*un.Unstructured, error) { + if forbidden[gvk] { + return nil, apierrors.NewForbidden( + schema.GroupResource{Group: gvk.Group, Resource: strings.ToLower(gvk.Kind) + "s"}, + "", + errors.New("list is not permitted"), + ) + } + + return b.mock.ListResources(ctx, gvk, namespace) + } + + return &m } // MockSchemaClientBuilder helps build kubernetes.SchemaClient mocks. @@ -361,7 +430,10 @@ func (b *MockSchemaClientBuilder) WithFoundCRDs(crdMappings map[schema.GroupKind // WithGetCRDByName sets the GetCRDByName behavior. func (b *MockSchemaClientBuilder) WithGetCRDByName(fn func(string) (*extv1.CustomResourceDefinition, error)) *MockSchemaClientBuilder { - b.mock.GetCRDByNameFn = fn + b.mock.GetCRDByNameFn = func(_ context.Context, name string) (*extv1.CustomResourceDefinition, error) { + return fn(name) + } + return b } @@ -421,7 +493,7 @@ func (b *MockSchemaClientBuilder) WithGetAllCRDs(fn func() []*extv1.CustomResour return b } -// WithCachingBehavior creates a mock that simulates caching CRDs when GetCRD or LoadCRDsFromXRDs is called. +// WithCachingBehavior creates a mock that simulates caching CRDs when GetCRD is called. func (b *MockSchemaClientBuilder) WithCachingBehavior() *MockSchemaClientBuilder { cachedCRDs := make(map[string]*extv1.CustomResourceDefinition) @@ -448,67 +520,6 @@ func (b *MockSchemaClientBuilder) WithCachingBehavior() *MockSchemaClientBuilder return nil, errors.New("GetCRD not implemented") } - // Override LoadCRDsFromXRDs to simulate caching CRDs converted from XRDs - originalLoadCRDs := b.mock.LoadCRDsFromXRDsFn - b.mock.LoadCRDsFromXRDsFn = func(ctx context.Context, xrds []*un.Unstructured) error { - // Call original function first - if originalLoadCRDs != nil { - err := originalLoadCRDs(ctx, xrds) - if err != nil { - return err - } - } - - // Simulate caching CRDs converted from XRDs - // For test purposes, create a simple CRD from each XRD - for _, xrd := range xrds { - group, _, _ := un.NestedString(xrd.Object, fieldSpec, "group") - if group == "" { - continue // Skip invalid XRDs - } - - names, ok, _ := un.NestedMap(xrd.Object, fieldSpec, "names") - if !ok { - continue - } - - kind, _ := names["kind"].(string) - if kind == "" { - continue - } - - // Create a simple CRD for this XRD - crd := &extv1.CustomResourceDefinition{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "apiextensions.k8s.io/v1", - Kind: "CustomResourceDefinition", - }, - ObjectMeta: metav1.ObjectMeta{ - Name: xrd.GetName(), - }, - Spec: extv1.CustomResourceDefinitionSpec{ - Group: group, - Names: extv1.CustomResourceDefinitionNames{ - Kind: kind, - }, - Scope: extv1.NamespaceScoped, - Versions: []extv1.CustomResourceDefinitionVersion{ - { - Name: "v1", - Served: true, - Storage: true, - }, - }, - }, - } - - gvk := schema.GroupVersionKind{Group: group, Version: "v1", Kind: kind} - cachedCRDs[gvk.String()] = crd - } - - return nil - } - // Override GetAllCRDs to return cached CRDs b.mock.GetAllCRDsFn = func() []*extv1.CustomResourceDefinition { result := make([]*extv1.CustomResourceDefinition, 0, len(cachedCRDs)) @@ -966,28 +977,28 @@ func (b *MockFunctionClientBuilder) WithFailedInitialize(errMsg string) *MockFun } // WithGetFunctionsFromPipeline sets the GetFunctionsFromPipeline behavior. -func (b *MockFunctionClientBuilder) WithGetFunctionsFromPipeline(fn func(*xpextv1.Composition) ([]pkgv1.Function, error)) *MockFunctionClientBuilder { +func (b *MockFunctionClientBuilder) WithGetFunctionsFromPipeline(fn func(context.Context, *xpextv1.Composition) ([]pkgv1.Function, error)) *MockFunctionClientBuilder { b.mock.GetFunctionsFromPipelineFn = fn return b } // WithSuccessfulFunctionsFetch sets GetFunctionsFromPipeline to return specific functions. func (b *MockFunctionClientBuilder) WithSuccessfulFunctionsFetch(functions []pkgv1.Function) *MockFunctionClientBuilder { - return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return b.WithGetFunctionsFromPipeline(func(context.Context, *xpextv1.Composition) ([]pkgv1.Function, error) { return functions, nil }) } // WithNoFunctions sets GetFunctionsFromPipeline to return an empty function list. func (b *MockFunctionClientBuilder) WithNoFunctions() *MockFunctionClientBuilder { - return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return b.WithGetFunctionsFromPipeline(func(context.Context, *xpextv1.Composition) ([]pkgv1.Function, error) { return []pkgv1.Function{}, nil }) } // WithFailedFunctionsFetch sets GetFunctionsFromPipeline to return an error. func (b *MockFunctionClientBuilder) WithFailedFunctionsFetch(errMsg string) *MockFunctionClientBuilder { - return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return b.WithGetFunctionsFromPipeline(func(context.Context, *xpextv1.Composition) ([]pkgv1.Function, error) { return nil, errors.New(errMsg) }) } @@ -1001,7 +1012,7 @@ func (b *MockFunctionClientBuilder) WithListFunctions(fn func(context.Context) ( // WithFunctionsFetchCallback sets a callback that's called when GetFunctionsFromPipeline is invoked. // This is useful for testing caching behavior by tracking how many times functions are fetched. func (b *MockFunctionClientBuilder) WithFunctionsFetchCallback(callback func() ([]pkgv1.Function, error)) *MockFunctionClientBuilder { - return b.WithGetFunctionsFromPipeline(func(*xpextv1.Composition) ([]pkgv1.Function, error) { + return b.WithGetFunctionsFromPipeline(func(context.Context, *xpextv1.Composition) ([]pkgv1.Function, error) { return callback() }) } @@ -1116,33 +1127,6 @@ func (b *MockDefinitionClientBuilder) WithFailedInitialize(errMsg string) *MockD return b } -// WithGetXRDs sets the GetXRDs behavior. -func (b *MockDefinitionClientBuilder) WithGetXRDs(fn func(context.Context) ([]*un.Unstructured, error)) *MockDefinitionClientBuilder { - b.mock.GetXRDsFn = fn - return b -} - -// WithSuccessfulXRDsFetch sets GetXRDs to return specific XRDs. -func (b *MockDefinitionClientBuilder) WithSuccessfulXRDsFetch(xrds []*un.Unstructured) *MockDefinitionClientBuilder { - return b.WithGetXRDs(func(context.Context) ([]*un.Unstructured, error) { - return xrds, nil - }) -} - -// WithEmptyXRDsFetch sets GetXRDs to return an empty set of XRDs. -func (b *MockDefinitionClientBuilder) WithEmptyXRDsFetch() *MockDefinitionClientBuilder { - return b.WithGetXRDs(func(context.Context) ([]*un.Unstructured, error) { - return []*un.Unstructured{}, nil - }) -} - -// WithFailedXRDsFetch sets GetXRDs to return an error. -func (b *MockDefinitionClientBuilder) WithFailedXRDsFetch(errMsg string) *MockDefinitionClientBuilder { - return b.WithGetXRDs(func(context.Context) ([]*un.Unstructured, error) { - return nil, errors.New(errMsg) - }) -} - // WithGetXRDForClaim sets the GetXRDForClaim behavior. func (b *MockDefinitionClientBuilder) WithGetXRDForClaim(fn func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error)) *MockDefinitionClientBuilder { b.mock.GetXRDForClaimFn = fn diff --git a/internal/diff/testutils/mocks.go b/internal/diff/testutils/mocks.go index 2b5570b4..1ff74f25 100644 --- a/internal/diff/testutils/mocks.go +++ b/internal/diff/testutils/mocks.go @@ -489,10 +489,9 @@ func (m *MockResourceClient) IsNamespacedResource(ctx context.Context, gvk schem type MockSchemaClient struct { InitializeFn func(ctx context.Context) error GetCRDFn func(ctx context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) - GetCRDByNameFn func(name string) (*extv1.CustomResourceDefinition, error) + GetCRDByNameFn func(ctx context.Context, name string) (*extv1.CustomResourceDefinition, error) IsCRDRequiredFn func(ctx context.Context, gvk schema.GroupVersionKind) bool ValidateResourceFn func(ctx context.Context, resource *un.Unstructured) error - LoadCRDsFromXRDsFn func(ctx context.Context, xrds []*un.Unstructured) error GetAllCRDsFn func() []*extv1.CustomResourceDefinition } @@ -515,9 +514,9 @@ func (m *MockSchemaClient) GetCRD(ctx context.Context, gvk schema.GroupVersionKi } // GetCRDByName implements kubernetes.SchemaClient. -func (m *MockSchemaClient) GetCRDByName(name string) (*extv1.CustomResourceDefinition, error) { +func (m *MockSchemaClient) GetCRDByName(ctx context.Context, name string) (*extv1.CustomResourceDefinition, error) { if m.GetCRDByNameFn != nil { - return m.GetCRDByNameFn(name) + return m.GetCRDByNameFn(ctx, name) } return nil, errors.New("GetCRDByName not implemented") @@ -541,15 +540,6 @@ func (m *MockSchemaClient) ValidateResource(ctx context.Context, resource *un.Un return nil } -// LoadCRDsFromXRDs implements kubernetes.SchemaClient. -func (m *MockSchemaClient) LoadCRDsFromXRDs(ctx context.Context, xrds []*un.Unstructured) error { - if m.LoadCRDsFromXRDsFn != nil { - return m.LoadCRDsFromXRDsFn(ctx, xrds) - } - - return nil -} - // GetAllCRDs implements kubernetes.SchemaClient. func (m *MockSchemaClient) GetAllCRDs() []*extv1.CustomResourceDefinition { if m.GetAllCRDsFn != nil { @@ -712,7 +702,7 @@ func (m *MockCompositionClient) FindComposites(ctx context.Context, comp *un.Uns // MockFunctionClient implements the crossplane.FunctionClient interface. type MockFunctionClient struct { InitializeFn func(ctx context.Context) error - GetFunctionsFromPipelineFn func(comp *xpextv1.Composition) ([]pkgv1.Function, error) + GetFunctionsFromPipelineFn func(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) ListFunctionsFn func(ctx context.Context) ([]pkgv1.Function, error) } @@ -726,9 +716,9 @@ func (m *MockFunctionClient) Initialize(ctx context.Context) error { } // GetFunctionsFromPipeline implements crossplane.FunctionClient. -func (m *MockFunctionClient) GetFunctionsFromPipeline(comp *xpextv1.Composition) ([]pkgv1.Function, error) { +func (m *MockFunctionClient) GetFunctionsFromPipeline(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) { if m.GetFunctionsFromPipelineFn != nil { - return m.GetFunctionsFromPipelineFn(comp) + return m.GetFunctionsFromPipelineFn(ctx, comp) } return nil, errors.New("GetFunctionsFromPipeline not implemented") @@ -780,7 +770,6 @@ func (m *MockEnvironmentClient) GetEnvironmentConfig(ctx context.Context, name s // MockDefinitionClient implements the crossplane.DefinitionClient interface. type MockDefinitionClient struct { InitializeFn func(ctx context.Context) error - GetXRDsFn func(ctx context.Context) ([]*un.Unstructured, error) GetXRDForClaimFn func(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) GetXRDForXRFn func(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) IsClaimResourceFn func(ctx context.Context, resource *un.Unstructured) bool @@ -796,15 +785,6 @@ func (m *MockDefinitionClient) Initialize(ctx context.Context) error { return nil } -// GetXRDs implements crossplane.DefinitionClient. -func (m *MockDefinitionClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, error) { - if m.GetXRDsFn != nil { - return m.GetXRDsFn(ctx) - } - - return nil, errors.New("GetXRDs not implemented") -} - // GetXRDForClaim implements crossplane.DefinitionClient. func (m *MockDefinitionClient) GetXRDForClaim(ctx context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { if m.GetXRDForClaimFn != nil { @@ -955,14 +935,14 @@ func (m *MockCompositionProvider) GetComposition() (*xpextv1.Composition, error) // MockFunctionProvider provides a mock implementation for FunctionProvider. type MockFunctionProvider struct { - GetFunctionsForCompositionFn func(comp *xpextv1.Composition) ([]pkgv1.Function, error) + GetFunctionsForCompositionFn func(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) CleanupFn func(ctx context.Context) error } // GetFunctionsForComposition implements FunctionProvider. -func (m *MockFunctionProvider) GetFunctionsForComposition(comp *xpextv1.Composition) ([]pkgv1.Function, error) { +func (m *MockFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) { if m.GetFunctionsForCompositionFn != nil { - return m.GetFunctionsForCompositionFn(comp) + return m.GetFunctionsForCompositionFn(ctx, comp) } return nil, nil From 104855d6c9f47208d8812922143e16b69062e8b3 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 22:53:00 -0400 Subject: [PATCH 079/126] test(diff): port lazy-lookup integration cases Ported from crossplane-contrib/crossplane-diff#598 (d93eb87). Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 199 ++++++++++++++++-- internal/diff/integration/it_utils_test.go | 163 +++++++++++--- internal/diff/integration/plan_output_test.go | 2 +- .../diff/integration/provider_plan_test.go | 3 +- 4 files changed, 317 insertions(+), 50 deletions(-) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index cb42669d..9c3164a2 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -19,6 +19,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/sergi/go-diff/diffmatchpatch" + rbacv1 "k8s.io/api/rbac/v1" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -72,7 +73,15 @@ type IntegrationTestCase struct { // crossplaneManagedResources, each under its own // field manager. Use it when a test depends on which manager owns a field: a plain Create // records only the test client as owner, and the apiserver drops managedFields supplied on Create. - fieldManagerApplies []fieldManagerApply + fieldManagerApplies []fieldManagerApply + // unestablishedXRDs are XRD manifests applied after setupFiles but, unlike an XRD in setupFiles, given + // no generated CRDs: an XRD Crossplane has not established yet, so its XR and claim types are not served. + unestablishedXRDs []string + // rbacRules, when set, makes the case run the command twice: as the cluster admin, then as a user + // bound to a ClusterRole granting only these rules (plus the discovery and self-review access the + // apiserver's bootstrap RBAC grants every authenticated user). The two runs must print identical + // output, and the usual assertions apply to the second. + rbacRules []rbacv1.PolicyRule inputFiles []string // Input files to diff (XR YAML files or Composition YAML files) expectedOutput string expectedError bool @@ -158,11 +167,19 @@ func createTestScheme() *runtime.Scheme { return scheme } +// integrationCluster is a running test case's envtest cluster. +type integrationCluster struct { + cfg *rest.Config // the cluster admin's + userCfg *rest.Config // a user granted only the case's rbacRules; nil when the case sets none + client client.Client // the admin's + ctx context.Context +} + // startIntegrationCluster starts a test case's envtest cluster and puts its setup state in place: -// tt's setupFiles, crossplaneManagedResources, fieldManagerApplies and deleteAfterSetup. It returns the -// cluster's config, a client for it, and a context bounded by the case's timeout, which it also returns. -// The cluster is stopped and the context cancelled when the test ends. -func startIntegrationCluster(t *testing.T, testType DiffTestType, tt IntegrationTestCase, testTimeout time.Duration) (*rest.Config, client.Client, context.Context, time.Duration) { +// tt's setupFiles, unestablishedXRDs, crossplaneManagedResources, fieldManagerApplies, deleteAfterSetup +// and rbacRules. The context is bounded by the case's timeout. The cluster is stopped and the context +// cancelled when the test ends. +func startIntegrationCluster(t *testing.T, testType DiffTestType, tt IntegrationTestCase, testTimeout time.Duration) integrationCluster { t.Helper() // Create a fresh scheme for each test to avoid concurrent map access. @@ -230,7 +247,15 @@ func startIntegrationCluster(t *testing.T, testType DiffTestType, tt Integration t.Fatalf("failed to setup resources: %v", err) } - // No composition revision controller runs either: do what it would. + if err := applyResourcesFromFiles(ctx, k8sClient, defaults, tt.unestablishedXRDs); err != nil { + t.Fatalf("failed to setup unestablished XRDs: %v", err) + } + + // No definition or composition revision controller runs either: do what they would. + if err := reconcileXRDCRDOwnership(ctx, k8sClient); err != nil { + t.Fatalf("failed to make XRDs the controllers of their CRDs: %v", err) + } + if err := reconcileCompositionRevisions(ctx, k8sClient); err != nil { t.Fatalf("failed to reconcile CompositionRevisions: %v", err) } @@ -271,7 +296,16 @@ func startIntegrationCluster(t *testing.T, testType DiffTestType, tt Integration t.Fatalf("failed to delete resources: %v", err) } - return cfg, k8sClient, ctx, testTimeout + cluster := integrationCluster{cfg: cfg, client: k8sClient, ctx: ctx} + + if tt.rbacRules != nil { + cluster.userCfg, err = addUserWithRules(ctx, testEnv, k8sClient, cfg, tt.rbacRules) + if err != nil { + t.Fatalf("failed to set up a user with the case's RBAC rules: %v", err) + } + } + + return cluster } // runIntegrationTest runs a single integration test case for both XR and composition diff commands. @@ -433,36 +467,48 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC return } - cfg, k8sClient, ctx, _ := startIntegrationCluster(t, testType, tt, testTimeout) + cluster := startIntegrationCluster(t, testType, tt, testTimeout) + ctx := cluster.ctx // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff // cannot race the apiserver's policy informer. See the field's doc comment. - if err := awaitAdmissionDenial(ctx, k8sClient, tt.awaitAdmissionDenialFor); err != nil { + if err := awaitAdmissionDenial(ctx, cluster.client, tt.awaitAdmissionDenialFor); err != nil { t.Fatalf("admission policy never took effect: %v", err) } // runOnce executes the command against the running cluster, leaving its output in stdout / // stderr. Everything the command layer touches is rebuilt per call, so a second run starts // with cold client caches just as a second invocation of the CLI would. - runOnce := func() (int, error) { + runOnce := func(runCfg *rest.Config) (int, error) { stdout.Reset() stderr.Reset() - return runSimulate(t, cfg, testType, args, &stdout, &stderr) + return runSimulate(t, runCfg, testType, args, &stdout, &stderr) } - exitCode, err := runOnce() + exitCode, err := runOnce(cluster.cfg) if tt.assertDeterministicAcrossRuns { first := stdout.String() - exitCode, err = runOnce() + exitCode, err = runOnce(cluster.cfg) if second := stdout.String(); second != first { t.Errorf("output differs between two runs against the same cluster; a value the apiserver generated has leaked into the diff.\nfirst run:\n%s\nsecond run:\n%s", first, second) } } + if cluster.userCfg != nil { + admin, adminErr := stdout.String(), err + + exitCode, err = runOnce(cluster.userCfg) + + if restricted := stdout.String(); restricted != admin { + t.Errorf("output with only the case's RBAC rules differs from the admin's; the run needs access the rules don't grant.\nadmin (error: %v):\n%s\nrestricted (error: %v, stderr below):\n%s\n%s", + adminErr, admin, err, restricted, stderr.String()) + } + } + // Check exit code matches expected if exitCode != tt.expectedExitCode { t.Errorf("expected exit code %d, got %d", tt.expectedExitCode, exitCode) @@ -2049,6 +2095,71 @@ Summary: 2 modified, 2 removed`, expectedError: false, expectedExitCode: dp.ExitCodeDiffDetected, }, + "LeastPrivilegeCredentialsDiffSucceeds": { + reason: "A user who can only get Crossplane's types, not list them, gets the admin's diff of a new and a modified XR", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + // The new XR names its composition: one that names none is matched against every Composition, + // which takes a list of them. + inputFiles: []string{"testdata/diff/first-xr-with-composition-ref.yaml", "testdata/diff/modified-xr.yaml"}, + rbacRules: leastPrivilegeRules("get", "create", "patch"), + outputFormat: "json", + expectedStructuredOutput: simtest.ExpectDiff(). + WithSummary(2, 2, 0). + WithAddedResource("XDownstreamResource", "first-resource", "default"). + WithField("spec.forProvider.configData", "first-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithAddedResource("XNopResource", "first-resource", "default"). + WithField("spec.coolField", "first-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "UnrelatedXRDWithoutCRDDoesNotFailRun": { + reason: "An XRD unrelated to the input whose CRD does not exist yet does not affect the diff", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + unestablishedXRDs: []string{"testdata/diff/resources/unestablished-xrd.yaml"}, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + outputFormat: "json", + // The same diff NewResourceDiff shows, which has no such XRD. + expectedStructuredOutput: simtest.ExpectDiff(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MissingFunctionFailsWithSameError": { + reason: "A pipeline naming a Function the cluster does not have fails, naming the function and its step", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-missing-function.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedError: true, + expectedErrorContains: `cannot get functions from pipeline: function "function-not-installed" referenced in pipeline step "generate-resources" not found`, + expectedExitCode: dp.ExitCodeToolError, + }, "SelectCompositionByDirectReference": { reason: "Validates composition selection by direct reference", outputFormat: "json", @@ -5824,6 +5935,65 @@ Summary: 2 modified`, WithAffectedResources(1, 1, 0, 0). WithXRImpact("XNopResource", "test-resource", "default", "changed"), }, + "LeastPrivilegeCredentialsDiffSucceeds": { + reason: "A user who can list only the composition's XR type, not Crossplane's own types, gets the admin's impact analysis", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + // list on the XR type: without --resource, comp finds the composition's XRs by listing them. + rbacRules: leastPrivilegeRules("get", "list", "create", "patch"), + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: simtest.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 2, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + AndComp(). + WithXRImpact("XNopResource", "another-resource", "default", "changed"), + }, + "NameScopedRunSucceedsWithoutCompositeList": { + reason: "--resource looks its composites up by name, so a user who cannot list the XR type gets the admin's impact analysis", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/test-resource"}, + rbacRules: leastPrivilegeRules("get", "create", "patch"), + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructuredCompOutput: simtest.ExpectCompDiff(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"), + }, "ResourceFilterCommaSeparated": { reason: "--resource accepts comma-separated values via kong's auto-parsing", setupFiles: []string{ @@ -5997,7 +6167,7 @@ func TestDiffSingleResourceConcurrent(t *testing.T) { return } - cfg, _, ctx, _ := startIntegrationCluster(t, XRDiffTest, IntegrationTestCase{ + cluster := startIntegrationCluster(t, XRDiffTest, IntegrationTestCase{ setupFiles: []string{ "testdata/diff/resources/xrd.yaml", "testdata/diff/resources/composition.yaml", @@ -6010,6 +6180,7 @@ func TestDiffSingleResourceConcurrent(t *testing.T) { ), }, }, defaultTimeout) + cfg, ctx := cluster.cfg, cluster.ctx // One modified and one new XR, each diffed several times at once. var inputs []*un.Unstructured diff --git a/internal/diff/integration/it_utils_test.go b/internal/diff/integration/it_utils_test.go index 8775d5bc..bba79a01 100644 --- a/internal/diff/integration/it_utils_test.go +++ b/internal/diff/integration/it_utils_test.go @@ -18,6 +18,8 @@ import ( "time" gyaml "gopkg.in/yaml.v3" + authorizationv1 "k8s.io/api/authorization/v1" + rbacv1 "k8s.io/api/rbac/v1" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -25,6 +27,8 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" @@ -152,40 +156,6 @@ func createTestCompositionWithExtraResources() (*xpextv1.Composition, error) { }, nil } -// createTestXRD creates a test XRD for the XR. -// -//nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped -func createTestXRD() *xpextv1.CompositeResourceDefinition { - return tu.NewXRD("xexampleresources.example.org", "example.org", "XExampleResource"). - WithPlural("xexampleresources"). - WithSingular("xexampleresource"). - WithRawSchema([]byte(`{ - "type": "object", - "properties": { - "spec": { - "type": "object", - "properties": { - "coolParam": { - "type": "string" - }, - "replicas": { - "type": "integer" - } - } - }, - "status": { - "type": "object", - "properties": { - "coolStatus": { - "type": "string" - } - } - } - } - }`)). - Build() -} - // createExtraResource creates a test extra resource. // //nolint:unused // carried verbatim from crossplane-diff; the cases using it are gap-skipped @@ -567,6 +537,89 @@ func envtestCRDs(crdDirs, setupFiles []string) ([]*extv1.CustomResourceDefinitio return crds, nil } +// leastPrivilegeRules is the access a diff of an XNopResource composing XDownstreamResources needs, the +// types the testdata XRDs, compositions and CRDs use. It grants Crossplane's own types only get, except a +// list of CompositionRevisions, which are found by their composition's label. It grants list only where a +// lookup is scoped by a label. xrVerbs are the verbs on +// the XR type: get to read the existing XR, patch and create to dry-run it, and list only where the +// command finds composites by listing them. +func leastPrivilegeRules(xrVerbs ...string) []rbacv1.PolicyRule { + return []rbacv1.PolicyRule{ + // Fetched by name as the input needs them: the XR's XRD and CRD, its Composition, the + // pipeline's Functions, and any EnvironmentConfig a function requires by name. + {APIGroups: []string{"apiextensions.crossplane.io"}, Resources: []string{"compositeresourcedefinitions", "compositions", "environmentconfigs"}, Verbs: []string{"get"}}, + {APIGroups: []string{"apiextensions.k8s.io"}, Resources: []string{"customresourcedefinitions"}, Verbs: []string{"get"}}, + {APIGroups: []string{"pkg.crossplane.io"}, Resources: []string{"functions"}, Verbs: []string{"get"}}, + // A composition's revisions are listed by its crossplane.io/composition-name label. + {APIGroups: []string{"apiextensions.crossplane.io"}, Resources: []string{"compositionrevisions"}, Verbs: []string{"get", "list"}}, + // The XR type. + {APIGroups: []string{"ns.diff.example.org"}, Resources: []string{"xnopresources"}, Verbs: xrVerbs}, + // Composed resources: read from the XR's resource tree, listed by their crossplane.io/composite + // label to find a new XR's existing ones, and dry-run created or applied. + {APIGroups: []string{"ns.nop.example.org"}, Resources: []string{"xdownstreamresources"}, Verbs: []string{"get", "list", "create", "patch"}}, + } +} + +// addUserWithRules provisions an envtest user bound to a ClusterRole granting exactly rules, and returns +// its config once the apiserver's RBAC authorizer honours the binding. envtest's apiserver authorizes +// with RBAC, so the user can do only what rules allow, plus what bootstrap RBAC grants every +// authenticated user (discovery, and reviewing its own access). +func addUserWithRules(ctx context.Context, env *envtest.Environment, c client.Client, admin *rest.Config, rules []rbacv1.PolicyRule) (*rest.Config, error) { + const name = "least-privilege" + + role := &rbacv1.ClusterRole{ObjectMeta: metav1.ObjectMeta{Name: name}, Rules: rules} + if err := c.Create(ctx, role); err != nil { + return nil, fmt.Errorf("cannot create ClusterRole %s: %w", name, err) + } + + binding := &rbacv1.ClusterRoleBinding{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + RoleRef: rbacv1.RoleRef{APIGroup: rbacv1.GroupName, Kind: "ClusterRole", Name: name}, + Subjects: []rbacv1.Subject{{APIGroup: rbacv1.GroupName, Kind: rbacv1.UserKind, Name: name}}, + } + if err := c.Create(ctx, binding); err != nil { + return nil, fmt.Errorf("cannot create ClusterRoleBinding %s: %w", name, err) + } + + user, err := env.AddUser(envtest.User{Name: name}, &rest.Config{QPS: admin.QPS, Burst: admin.Burst}) + if err != nil { + return nil, fmt.Errorf("cannot add user %s: %w", name, err) + } + + cfg := user.Config() + + kube, err := kubernetes.NewForConfig(cfg) + if err != nil { + return nil, fmt.Errorf("cannot create a client for user %s: %w", name, err) + } + + // The authorizer learns of the binding through an informer, so wait until the user holds the first + // rule's first grant before running anything as the user. + first := rules[0] + review := &authorizationv1.SelfSubjectAccessReview{Spec: authorizationv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authorizationv1.ResourceAttributes{ + Group: first.APIGroups[0], Resource: first.Resources[0], Verb: first.Verbs[0], + }, + }} + + for { + got, err := kube.AuthorizationV1().SelfSubjectAccessReviews().Create(ctx, review, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("cannot review user %s's access: %w", name, err) + } + + if got.Status.Allowed { + return cfg, nil + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("user %s never gained the access its ClusterRoleBinding grants: %w", name, ctx.Err()) + case <-time.After(100 * time.Millisecond): + } + } +} + // createResources creates all resources in the cluster // Assumes resources don't already exist - fails if they do. func createResources(ctx context.Context, c client.Client, resources []*un.Unstructured) error { @@ -1017,6 +1070,48 @@ func reconcileCompositionRevisions(ctx context.Context, c client.Client) error { return nil } +// reconcileXRDCRDOwnership does what Crossplane's definition controllers would, since envtest runs none: it +// makes each XRD the controller of the XR and claim CRDs generated from it. The CRDs are installed before +// any XRD exists, so CRDsForXRD cannot give them the reference xcrd does. An XRD whose CRD does not exist +// (one in unestablishedXRDs) is skipped. +func reconcileXRDCRDOwnership(ctx context.Context, c client.Client) error { + xrds := &xpextv1.CompositeResourceDefinitionList{} + if err := c.List(ctx, xrds); err != nil { + return err + } + + for i := range xrds.Items { + xrd := &xrds.Items[i] + owner := meta.AsController(meta.TypedReferenceTo(xrd, xpextv1.CompositeResourceDefinitionGroupVersionKind)) + + names := []string{xrd.Spec.Names.Plural + "." + xrd.Spec.Group} + if xrd.Spec.ClaimNames != nil { + names = append(names, xrd.Spec.ClaimNames.Plural+"."+xrd.Spec.Group) + } + + for _, name := range names { + crd := &extv1.CustomResourceDefinition{} + + err := c.Get(ctx, client.ObjectKey{Name: name}, crd) + if apierrors.IsNotFound(err) { + continue + } + + if err != nil { + return err + } + + meta.AddOwnerReference(crd, owner) + + if err := c.Update(ctx, crd); err != nil { + return err + } + } + } + + return nil +} + // newCompositionRevision is crossplane's composition.NewCompositionRevision, which is internal. func newCompositionRevision(comp *xpextv1.Composition, revision int64, owner metav1.OwnerReference) *xpextv1.CompositionRevision { name, hash := revisionIdentity(comp.GetName(), comp.Hash()) diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index 88c7bd7d..57ba35bd 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -200,7 +200,7 @@ Note: Cloud-layer plan not run: no provider diff servers serve these resources. t.Skip(reason) } - cfg, _, _, _ := startIntegrationCluster(t, tc.testType, tc.tt, defaultTimeout) + cfg := startIntegrationCluster(t, tc.testType, tc.tt, defaultTimeout).cfg var stdout, stderr bytes.Buffer argv := append([]string{"project", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) diff --git a/internal/diff/integration/provider_plan_test.go b/internal/diff/integration/provider_plan_test.go index af50c6db..60469c06 100644 --- a/internal/diff/integration/provider_plan_test.go +++ b/internal/diff/integration/provider_plan_test.go @@ -239,7 +239,8 @@ func TestSimulateProviderPlan(t *testing.T) { t.Skip(reason) } - cfg, kube, ctx, _ := startIntegrationCluster(t, XRDiffTest, xrCase, defaultTimeout) + cluster := startIntegrationCluster(t, XRDiffTest, xrCase, defaultTimeout) + cfg, kube, ctx := cluster.cfg, cluster.client, cluster.ctx if tc.ownCRD { ownByProviderRevision(ctx, t, kube, "xdownstreamresources.ns.nop.example.org") } From dc71e43549f09736ac8b476deef8d2e1a9feeaf7 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 23:07:53 -0400 Subject: [PATCH 080/126] fix(simulate): stop mutating the global scheme in NewKubeClient Build a private, once-initialised scheme (client-go types plus crossplane pkg) instead of registering into the global scheme on every call. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/servers.go | 24 +++++++++++++++++++++--- internal/simulate/servers_test.go | 18 ++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/internal/simulate/servers.go b/internal/simulate/servers.go index 0fb5d8bd..b55b18a5 100644 --- a/internal/simulate/servers.go +++ b/internal/simulate/servers.go @@ -34,7 +34,7 @@ import ( "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/client-go/discovery" "k8s.io/client-go/discovery/cached/memory" - "k8s.io/client-go/kubernetes/scheme" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/restmapper" "sigs.k8s.io/controller-runtime/pkg/client" @@ -359,13 +359,31 @@ func LoadImageConfigs(path string) ([]pkgv1beta1.ImageConfig, error) { return ics, nil } +// kubeScheme is built once and never mutated, so concurrent NewKubeClient +// calls don't race on shared scheme maps. +// +//nolint:gochecknoglobals // Built once, read-only afterwards. +var kubeScheme = sync.OnceValues(func() (*runtime.Scheme, error) { + s := runtime.NewScheme() + if err := clientgoscheme.AddToScheme(s); err != nil { + return nil, err + } + if err := pkg.AddToScheme(s); err != nil { + return nil, err + } + return s, nil +}) + // NewKubeClient returns a client and a discovery-backed REST mapper for cfg. func NewKubeClient(cfg *rest.Config) (client.Client, meta.RESTMapper, error) { - cl, err := client.New(cfg, client.Options{Scheme: scheme.Scheme}) + sch, err := kubeScheme() + if err != nil { + return nil, nil, errors.Wrap(err, "cannot build scheme") + } + cl, err := client.New(cfg, client.Options{Scheme: sch}) if err != nil { return nil, nil, errors.Wrap(err, "cannot init kubeclient") } - _ = pkg.AddToScheme(cl.Scheme()) discoveryClient, err := discovery.NewDiscoveryClientForConfig(cfg) if err != nil { diff --git a/internal/simulate/servers_test.go b/internal/simulate/servers_test.go index 7fa7f923..91c5964d 100644 --- a/internal/simulate/servers_test.go +++ b/internal/simulate/servers_test.go @@ -24,9 +24,11 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "github.com/google/go-cmp/cmp" + "k8s.io/client-go/rest" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" @@ -271,3 +273,19 @@ func TestServerSetEnsure(t *testing.T) { t.Error("Ran() = true, want false") } } + +func TestNewKubeClientConcurrent(t *testing.T) { + var wg sync.WaitGroup + for range 16 { + wg.Go(func() { + // A bogus host makes client.New succeed lazily; we only care that + // no shared scheme is mutated. + _, _, _ = NewKubeClient(&rest.Config{Host: "http://127.0.0.1:1"}) + }) + } + wg.Wait() + + if s, err := kubeScheme(); err != nil || s == nil { + t.Errorf("kubeScheme() = %v, %v", s, err) + } +} From 1cbed0a08c3d2cc713888f79ceacf0d6b0f828f9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 23:49:11 -0400 Subject: [PATCH 081/126] fix(diff): pause the per-XR budget while run-wide work runs Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 3 +- cmd/crossplane/project/simulate.go | 8 +- .../diff/diffprocessor/comp_processor_test.go | 2 +- .../diff/diffprocessor/diff_processor_test.go | 6 +- .../diff/diffprocessor/processor_config.go | 110 +++++++++++++++--- .../diffprocessor/processor_config_test.go | 94 +++++++++++++-- internal/diff/diffprocessor/render_engine.go | 19 ++- internal/simulate/xrplan.go | 3 +- internal/simulate/xrplan_test.go | 35 ++++-- 9 files changed, 227 insertions(+), 53 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 8003e931..1760c16e 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -78,7 +78,8 @@ Note: Cloud-layer plan not run (provider diff servers not yet wired). The `--timeout` flag, 2m by default, bounds the time to simulate one composite resource: its render and its resources' provider plans. Function and provider -image pulls don't count. +image pulls don't count, but the render image pull counts against the first +composite resource's budget. By default `simulate` also runs provider plans: it asks each provider's diff server to plan the managed resources, so the plan shows changes the cluster diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index fd5451b1..35e8555b 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -84,13 +84,13 @@ type simulateCmd struct { MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` - Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` - Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` + Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` + Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` Name string `help:"Only simulate the composite resource with this name. Use with --namespace to name a namespaced resource."` IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` - Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count."` - Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` + Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count, but the render image pull counts against the first composite."` + Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 0a3626f7..9d68bc4a 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -1326,7 +1326,7 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_PerXR(t *testing.T) { } want := map[string]string{ - "slow": "unable to process resource example.org/v1/XResource//slow: timed out after 50ms: context deadline exceeded", + "slow": "unable to process resource example.org/v1/XResource//slow: timed out after 50ms: context canceled", "fast": "1 diffs", "failing": "unable to process resource example.org/v1/XResource//failing: plan failed", } diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index 52dde738..253ca293 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -867,9 +867,9 @@ func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { xrTimeout: 50 * time.Millisecond, want: got{ XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, - GroupErrs: map[string]string{"my-xr-1": "timed out after 50ms: cannot calculate diffs for composed resources: context deadline exceeded"}, - GlobalErrs: []string{"timed out after 50ms: cannot calculate diffs for composed resources: context deadline exceeded"}, - Err: "unable to process resource XR1/my-xr-1: timed out after 50ms: cannot calculate diffs for composed resources: context deadline exceeded", + GroupErrs: map[string]string{"my-xr-1": "timed out after 50ms: cannot calculate diffs for composed resources: context canceled"}, + GlobalErrs: []string{"timed out after 50ms: cannot calculate diffs for composed resources: context canceled"}, + Err: "unable to process resource XR1/my-xr-1: timed out after 50ms: cannot calculate diffs for composed resources: context canceled", }, }, // An AfterXRDiff error fails its XR with none of its diffs, as a failed render does. diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index ee414b5c..a89778e6 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -2,6 +2,7 @@ package diffprocessor import ( "context" + "sync" "time" corev1 "k8s.io/api/core/v1" @@ -454,35 +455,110 @@ func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) } } +// errXRTimedOut is the cause an XR context is cancelled with when its XRTimeout budget runs out. +var errXRTimedOut = errors.New("composite resource timed out") + // xrContext returns the context one composite resource's diff runs under: ctx bounded by XRTimeout -// when it is set. +// when it is set. The budget can be paused with OutsideXRDeadline. func (c *ProcessorConfig) xrContext(ctx context.Context) (context.Context, context.CancelFunc) { if c.XRTimeout <= 0 { return ctx, func() {} } - return ContextWithXRDeadline(ctx, c.XRTimeout) + xrCtx, cancel := context.WithCancelCause(ctx) + b := &xrBudget{remaining: c.XRTimeout, run: ctx, cancel: cancel, start: time.Now()} + b.timer = time.AfterFunc(c.XRTimeout, b.expire) + + return context.WithValue(xrCtx, budgetKey{}, b), func() { + b.mu.Lock() + b.timer.Stop() + b.mu.Unlock() + cancel(context.Canceled) + } +} + +// NewXRContext returns the context a composite resource's diff runs under with an XRTimeout of d. +func NewXRContext(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { + return (&ProcessorConfig{XRTimeout: d}).xrContext(ctx) +} + +// budgetKey keys the xrBudget of a composite resource's context. +type budgetKey struct{} + +// xrBudget is the pausable XRTimeout of one composite resource. +type xrBudget struct { + run context.Context //nolint:containedctx // The budget hands the run context to OutsideXRDeadline's callers. + cancel context.CancelCauseFunc + + mu sync.Mutex + timer *time.Timer + start time.Time + remaining time.Duration + pauses int + expired bool +} + +func (b *xrBudget) expire() { + b.mu.Lock() + b.expired = true + b.mu.Unlock() + b.cancel(errXRTimedOut) +} + +// pause stops the clock, reporting false when the budget already ran out. +func (b *xrBudget) pause() bool { + b.mu.Lock() + defer b.mu.Unlock() + + if b.expired { + return false + } + + if b.pauses == 0 { + if !b.timer.Stop() { + // The timer fired; its expiry is on its way. + b.expired = true + return false + } + + b.remaining -= time.Since(b.start) + } + + b.pauses++ + + return true } -// ContextWithXRDeadline returns the context a composite resource's diff runs under: ctx bounded by -// d, remembering ctx as the run context WithoutXRDeadline returns. -func ContextWithXRDeadline(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) { - return context.WithTimeout(context.WithValue(ctx, runContextKey{}, ctx), d) +// resume restarts the clock once the last concurrent pause ends. +func (b *xrBudget) resume() { + b.mu.Lock() + defer b.mu.Unlock() + + b.pauses-- + if b.pauses == 0 && !b.expired { + b.start = time.Now() + b.timer = time.AfterFunc(max(b.remaining, 0), b.expire) + } } -// runContextKey keys the context a composite resource's XRTimeout deadline was added to. -type runContextKey struct{} +// OutsideXRDeadline runs fn with the run context, which ctx's composite resource budget (XRTimeout) +// does not bound, and does not count fn's time against that budget. Work shared by the whole run, +// like starting function runtimes and provider diff servers, which pulls images, goes through it so +// it neither fails nor is billed to the composite that happens to need it first. Calls may nest and +// run concurrently. Without a budget in ctx it calls fn(ctx). If the budget already ran out it +// returns that expiry without running fn. +func OutsideXRDeadline(ctx context.Context, fn func(run context.Context) error) error { + b, ok := ctx.Value(budgetKey{}).(*xrBudget) + if !ok { + return fn(ctx) + } -// WithoutXRDeadline returns the context ctx's composite resource deadline was added to, or ctx when -// it has none. Starting function runtimes, which pulls their images, runs under it: that work is -// shared by the whole run, so it must not count against, or fail, the composite that happens to -// need it first. An AfterXRDiff hook uses it the same way for run-wide work of its own. -func WithoutXRDeadline(ctx context.Context) context.Context { - if run, ok := ctx.Value(runContextKey{}).(context.Context); ok { - return run + if !b.pause() { + return errXRTimedOut } + defer b.resume() - return ctx + return fn(b.run) //nolint:contextcheck // The run context is deliberately the one the XR budget does not bound. } // afterXR finishes one composite resource's diff, which returned diffs and err under xrCtx, a child @@ -493,7 +569,7 @@ func (c *ProcessorConfig) afterXR(ctx, xrCtx context.Context, diffs map[string]* } // Only the composite's own deadline is named; the caller's ending reads as it always has. - if err != nil && ctx.Err() == nil && errors.Is(xrCtx.Err(), context.DeadlineExceeded) { + if err != nil && ctx.Err() == nil && errors.Is(context.Cause(xrCtx), errXRTimedOut) { return errors.Wrapf(err, "timed out after %s", c.XRTimeout) } diff --git a/internal/diff/diffprocessor/processor_config_test.go b/internal/diff/diffprocessor/processor_config_test.go index 2058d688..b6baf7c1 100644 --- a/internal/diff/diffprocessor/processor_config_test.go +++ b/internal/diff/diffprocessor/processor_config_test.go @@ -2,6 +2,8 @@ package diffprocessor import ( "context" + "errors" + "strings" "testing" "time" @@ -183,17 +185,85 @@ func TestWithMaxRecvMessageSize(t *testing.T) { } } -func TestWithoutXRDeadline(t *testing.T) { - cfg := &ProcessorConfig{XRTimeout: time.Nanosecond} - run := context.Background() - xrCtx, cancel := cfg.xrContext(run) - defer cancel() - <-xrCtx.Done() +func TestOutsideXRDeadline(t *testing.T) { + cfg := &ProcessorConfig{XRTimeout: 50 * time.Millisecond} - if got := WithoutXRDeadline(xrCtx); got.Err() != nil { - t.Errorf("WithoutXRDeadline(expired XR context).Err() = %v, want the run context, which has not ended", got.Err()) - } - if got := WithoutXRDeadline(run); got != run { - t.Errorf("WithoutXRDeadline(run) did not return run unchanged") - } + t.Run("NoBudgetCallsFnWithCtx", func(t *testing.T) { + ctx := context.Background() + _ = OutsideXRDeadline(ctx, func(run context.Context) error { + if run != ctx { + t.Errorf("run context != ctx without a budget") + } + return nil + }) + }) + + t.Run("SlowWorkOutsideDoesNotTimeOutXR", func(t *testing.T) { + xrCtx, cancel := cfg.xrContext(context.Background()) + defer cancel() + + err := OutsideXRDeadline(xrCtx, func(run context.Context) error { + time.Sleep(150 * time.Millisecond) + return run.Err() + }) + if err != nil || xrCtx.Err() != nil { + t.Errorf("after slow outside work: fn err %v, xr ctx err %v; want both nil", err, xrCtx.Err()) + } + if err := cfg.afterXR(context.Background(), xrCtx, nil, nil); err != nil { + t.Errorf("afterXR() = %v, want nil", err) + } + }) + + t.Run("NestedAndConcurrentPausesRearmOnce", func(t *testing.T) { + xrCtx, cancel := cfg.xrContext(context.Background()) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + _ = OutsideXRDeadline(xrCtx, func(context.Context) error { + time.Sleep(120 * time.Millisecond) + return nil + }) + }() + time.Sleep(10 * time.Millisecond) + _ = OutsideXRDeadline(xrCtx, func(context.Context) error { + return OutsideXRDeadline(xrCtx, func(context.Context) error { + time.Sleep(60 * time.Millisecond) + return nil + }) + }) + if xrCtx.Err() != nil { + t.Errorf("xr ctx ended while another pause was active: %v", xrCtx.Err()) + } + <-done + }) + + t.Run("WorkInsideStillTimesOut", func(t *testing.T) { + xrCtx, cancel := cfg.xrContext(context.Background()) + defer cancel() + + _ = OutsideXRDeadline(xrCtx, func(context.Context) error { + time.Sleep(20 * time.Millisecond) + return nil + }) + <-xrCtx.Done() + + err := cfg.afterXR(context.Background(), xrCtx, nil, xrCtx.Err()) + if err == nil || !strings.Contains(err.Error(), "timed out after 50ms") { + t.Errorf("afterXR() = %v, want a timed out after 50ms error", err) + } + }) + + t.Run("ExpiredBudgetSkipsFn", func(t *testing.T) { + xrCtx, cancel := cfg.xrContext(context.Background()) + defer cancel() + <-xrCtx.Done() + + ran := false + err := OutsideXRDeadline(xrCtx, func(context.Context) error { ran = true; return nil }) + if ran || !errors.Is(err, errXRTimedOut) { + t.Errorf("ran = %v, err = %v; want fn skipped and errXRTimedOut", ran, err) + } + }) } diff --git a/internal/diff/diffprocessor/render_engine.go b/internal/diff/diffprocessor/render_engine.go index 45160daf..a8bcb248 100644 --- a/internal/diff/diffprocessor/render_engine.go +++ b/internal/diff/diffprocessor/render_engine.go @@ -288,12 +288,25 @@ func (e *EngineRenderFn) Render(ctx context.Context, log logging.Logger, in Rend // writes it, and only without CROSSPLANE_DIFF_DOCKER_NETWORK. Upgrade (e.g. a // once-guarded first Setup) if empty pipelines get diffed concurrently. func (e *EngineRenderFn) startFunctions(ctx context.Context, log logging.Logger, fns []pkgv1.Function) (map[string]string, error) { + // Starting functions is shared by the run, so a composite's XRTimeout neither bounds nor + // counts it, including the wait for another composite's start. + var addrs map[string]string + + err := OutsideXRDeadline(ctx, func(run context.Context) error { + var err error + + addrs, err = e.startFunctionsLocked(run, log, fns) + + return err + }) + + return addrs, err +} + +func (e *EngineRenderFn) startFunctionsLocked(ctx context.Context, log logging.Logger, fns []pkgv1.Function) (map[string]string, error) { e.mu.Lock() defer e.mu.Unlock() - // Starting functions is shared by the run, so a composite's XRTimeout does not bound it. - ctx = WithoutXRDeadline(ctx) - if e.addrs == nil { e.addrs = make(map[string]string) } diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go index 854f371b..05f02b2f 100644 --- a/internal/simulate/xrplan.go +++ b/internal/simulate/xrplan.go @@ -65,7 +65,8 @@ func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.Resour } } // Server start and image pulls are run-wide work, not the XR's. - if err := x.ensure(diffprocessor.WithoutXRDeadline(ctx), resources); err != nil { + err := diffprocessor.OutsideXRDeadline(ctx, func(run context.Context) error { return x.ensure(run, resources) }) + if err != nil { return errors.Wrap(err, "cannot start provider diff servers") } } diff --git a/internal/simulate/xrplan_test.go b/internal/simulate/xrplan_test.go index 309e21e9..e49cfc8a 100644 --- a/internal/simulate/xrplan_test.go +++ b/internal/simulate/xrplan_test.go @@ -19,6 +19,8 @@ package simulate import ( "context" "errors" + "slices" + "sort" "strings" "testing" "time" @@ -42,9 +44,12 @@ import ( type funcPlanClient struct { fakePlanClient fail map[string]bool + // ctxErr is the error of the context the last Plan call ran under. + ctxErr error } -func (f *funcPlanClient) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { +func (f *funcPlanClient) Plan(ctx context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + f.ctxErr = ctx.Err() if f.fail[in.GetDesiredResource().GetFields()["metadata"].GetStructValue().GetFields()["name"].GetStringValue()] { return nil, status.Error(codes.Internal, "boom") } @@ -138,7 +143,8 @@ func TestXRPlanner(t *testing.T) { t.Errorf("AfterXRDiff(...): want bad-a before bad-b, got %v", err) } } - if len(ensured) != len(tc.wantEnsured) { + sort.Strings(ensured) + if !slices.Equal(ensured, tc.wantEnsured) && len(ensured)+len(tc.wantEnsured) > 0 { t.Errorf("ensure got %v, want %v", ensured, tc.wantEnsured) } if len(tc.wantEnsured) > 0 && (len(ensureCtxs) == 0 || ensureCtxs[0].Err() != nil) { @@ -157,20 +163,27 @@ func TestXRPlanner(t *testing.T) { } t.Run("EnsureRunsOutsideXRDeadline", func(t *testing.T) { - var ensureCtxs []context.Context + // ensure outlasts the XR's budget; the budget is paused meanwhile, so the plans still run. ensure := func(ctx context.Context, _ []*unstructured.Unstructured) error { - ensureCtxs = append(ensureCtxs, ctx) - return errors.New("stop before planning") + time.Sleep(150 * time.Millisecond) + return ctx.Err() } - xrCtx, cancel := diffprocessor.ContextWithXRDeadline(context.Background(), time.Nanosecond) + xrCtx, cancel := diffprocessor.NewXRContext(context.Background(), 50*time.Millisecond) defer cancel() - <-xrCtx.Done() x := NewXRPlanner(planner, nil, ensure) - _ = x.AfterXRDiff(xrCtx, map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16"))}) - - if len(ensureCtxs) == 0 || ensureCtxs[0].Err() != nil { - t.Errorf("ensure ctx missing or ended: %v", ensureCtxs) + err := x.AfterXRDiff(xrCtx, map[string]*dt.ResourceDiff{"a": added(vpc("a", "10.0.0.0/16"))}) + if err != nil { + t.Errorf("AfterXRDiff(...) = %v, want nil", err) + } + if xrCtx.Err() != nil { + t.Errorf("XR ctx ended during ensure: %v", xrCtx.Err()) + } + if pc.ctxErr != nil { + t.Errorf("plan client saw ctx error %v, want a live context", pc.ctxErr) + } + if len(x.Plans()) != 1 { + t.Errorf("Plans() = %v, want the one plan", x.Plans()) } }) From dd65518ca8d2189a346f197ed57c96569026acb9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Wed, 21 Oct 2026 23:58:37 -0400 Subject: [PATCH 082/126] fix(project): note when no diff server served any plan Count the plans routed to a diff server and emit the no-diff-servers note when planning ran and none were, even if no-op placeholders exist. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 12 ++++++++---- cmd/crossplane/project/simulate_test.go | 21 +++++++++++---------- internal/simulate/xrplan.go | 19 +++++++++++++++++-- 3 files changed, 36 insertions(+), 16 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 35e8555b..34a3836f 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -816,19 +816,23 @@ func (c *simulateCmd) providerPlans() map[string]*plan.ResourcePlan { } // planNotes returns the notes that replace the result's own, or nil to keep -// them. noServers is true when diff servers were wanted but none ran. -func planNotes(skipPlan bool, plans map[string]*plan.ResourcePlan, noServers bool) []string { +// them. planned is true when planning ran; routed counts the plans a diff +// server served. +func planNotes(skipPlan, planned bool, routed int) []string { switch { case skipPlan: return []string{simulate.NoteProviderPlanSkipped} - case plans != nil && len(plans) == 0 && noServers: + case planned && routed == 0: return []string{simulate.NoteNoDiffServers} } return nil } func (c *simulateCmd) notes() []string { - return planNotes(c.SkipPlan, c.providerPlans(), c.servers != nil && !c.servers.Ran()) + if c.xrPlanner == nil { + return planNotes(c.SkipPlan, false, 0) + } + return planNotes(c.SkipPlan, true, c.xrPlanner.Routed()) } // renderXRReport writes an xr or project run's report in the format -o diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index c05596fb..5c7686e3 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -1095,20 +1095,21 @@ func TestCheckComposites(t *testing.T) { func TestPlanNotes(t *testing.T) { cases := map[string]struct { - skipPlan bool - plans map[string]*plan.ResourcePlan - noServers bool - want []string + skipPlan bool + planned bool + routed int + want []string }{ - "SkipPlan": {skipPlan: true, want: []string{simulate.NoteProviderPlanSkipped}}, - "NilPlans": {}, - "EmptyServersNotRun": {plans: map[string]*plan.ResourcePlan{}, noServers: true, want: []string{simulate.NoteNoDiffServers}}, - "EmptyServersRan": {plans: map[string]*plan.ResourcePlan{}}, - "NonEmptyPlans": {plans: map[string]*plan.ResourcePlan{"k": {}}, noServers: true}, + "SkipPlan": {skipPlan: true, want: []string{simulate.NoteProviderPlanSkipped}}, + "NotPlanned": {}, + "NoneRouted": {planned: true, want: []string{simulate.NoteNoDiffServers}}, + "SomeRouted": {planned: true, routed: 1}, + "ManyRouted": {planned: true, routed: 5}, + "SkipPlanRouted": {skipPlan: true, planned: true, routed: 2, want: []string{simulate.NoteProviderPlanSkipped}}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - if diff := cmp.Diff(tc.want, planNotes(tc.skipPlan, tc.plans, tc.noServers)); diff != "" { + if diff := cmp.Diff(tc.want, planNotes(tc.skipPlan, tc.planned, tc.routed)); diff != "" { t.Errorf("planNotes(...): -want, +got:\n%s", diff) } }) diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go index 05f02b2f..c99504e3 100644 --- a/internal/simulate/xrplan.go +++ b/internal/simulate/xrplan.go @@ -45,8 +45,9 @@ type XRPlanner struct { keep func(*unstructured.Unstructured) bool ensure EnsureServers - mu sync.Mutex - plans map[string]*plan.ResourcePlan + mu sync.Mutex + plans map[string]*plan.ResourcePlan + routed int } // NewXRPlanner returns an XRPlanner that plans with p. keep and ensure may be nil. @@ -87,6 +88,11 @@ func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.Resour x.mu.Lock() defer x.mu.Unlock() maps.Copy(x.plans, plans) + for key, d := range diffs { + if _, ok := plans[key]; ok && x.planner.CanPlan(d.Gvk.GroupKind()) { + x.routed++ + } + } return nil } @@ -98,3 +104,12 @@ func (x *XRPlanner) Plans() map[string]*plan.ResourcePlan { return x.plans } + +// Routed returns how many collected plans went to a diff server. Plans that +// stand in for a kind no server serves are not counted. +func (x *XRPlanner) Routed() int { + x.mu.Lock() + defer x.mu.Unlock() + + return x.routed +} From 43ebcd70acd62cb0fc09969068a653083c492b65 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 00:00:11 -0400 Subject: [PATCH 083/126] fix(simulate): don't cache ProviderConfig lookups that failed on a context error Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simulate.go | 8 ++++++- internal/simulate/simulate_test.go | 34 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go index 273360a4..6328779a 100644 --- a/internal/simulate/simulate.go +++ b/internal/simulate/simulate.go @@ -241,10 +241,16 @@ func (c *objectStoreCache) gather(ctx context.Context, kubeClient client.Client, var store []*structpb.Struct pc := &unstructured.Unstructured{} pc.SetGroupVersionKind(mapping.GroupVersionKind) - if err := kubeClient.Get(ctx, key, pc); err == nil { + switch err := kubeClient.Get(ctx, key, pc); { + case err == nil: if s, err := structpb.NewStruct(pc.Object); err == nil { store = []*structpb.Struct{s} } + case apierrors.IsNotFound(err): + default: + // Any other failure, notably a timed-out XR's expired context, must + // not poison the cache for the XRs after it. + return nil } c.cache[ck] = store return store diff --git a/internal/simulate/simulate_test.go b/internal/simulate/simulate_test.go index 21cb0704..c7155e43 100644 --- a/internal/simulate/simulate_test.go +++ b/internal/simulate/simulate_test.go @@ -349,6 +349,40 @@ func TestPlanner(t *testing.T) { } }) + t.Run("GatherDoesNotCacheContextErrors", func(t *testing.T) { + pcGVK := schema.GroupVersionKind{Group: "aws.upbound.io", Version: "v1beta1", Kind: "ProviderConfig"} + sc := runtime.NewScheme() + sc.AddKnownTypeWithName(pcGVK, &unstructured.Unstructured{}) + sc.AddKnownTypeWithName(pcGVK.GroupVersion().WithKind("ProviderConfigList"), &unstructured.UnstructuredList{}) + mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion(), pcGVK.GroupVersion()}) + mapper.Add(vpcGVK, meta.RESTScopeRoot) + mapper.Add(pcGVK, meta.RESTScopeRoot) + + pc := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "aws.upbound.io/v1beta1", + "kind": "ProviderConfig", + "metadata": map[string]any{"name": "default"}, + }} + kube := fake.NewClientBuilder().WithScheme(sc).WithObjects(pc). + WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if err := ctx.Err(); err != nil { + return err + } + return c.Get(ctx, key, obj, opts...) + }}).Build() + + expired, cancel := context.WithCancel(ctx) + cancel() + + stores := newObjectStoreCache() + if got := stores.gather(expired, kube, mapper, "default", vpc("a", "10.0.0.0/16"), nil); len(got) != 0 { + t.Fatalf("gather(expired ctx): want no objects, got %v", got) + } + if got := stores.gather(ctx, kube, mapper, "default", vpc("a", "10.0.0.0/16"), nil); len(got) != 1 { + t.Errorf("gather(live ctx): want the ProviderConfig after an expired gather, got %v", got) + } + }) + t.Run("DiscoversOnlyRelevantProviders", func(t *testing.T) { crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} From d89e11611ba8aefcc7ca7b6098e2be3744a070d9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 00:01:38 -0400 Subject: [PATCH 084/126] fix(project): reject a non-positive --timeout The planner also skips its own bound when the timeout is not positive. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 4 ++++ cmd/crossplane/project/simulate_test.go | 5 +++++ internal/simulate/simulate.go | 10 +++++++--- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 34a3836f..cd55a073 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -210,6 +210,10 @@ func (c *simulateCmd) Validate() error { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } + if c.Timeout <= 0 { + return errors.Errorf("--timeout must be greater than 0, got %s", c.Timeout) + } + if c.SkipPlan { for _, f := range []struct{ name, val string }{{"providers", c.Providers}, {"image-configs", c.ImageConfigs}, {"session-id", c.SessionID}} { if f.val != "" { diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 5c7686e3..3f849f33 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -253,6 +253,11 @@ func TestSimulateFlags(t *testing.T) { args: []string{"--max-iterations", "0"}, want: want{err: true}, }, + "ZeroTimeout": { + reason: "A timeout of zero or less would fail every composite at once.", + args: []string{"--timeout", "0s"}, + want: want{err: true}, + }, "RenderVersionBelowFloor": { reason: "A pinned render version below the floor should be rejected.", args: []string{"--crossplane-version", "v2.2.0"}, diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go index 6328779a..60fa96fc 100644 --- a/internal/simulate/simulate.go +++ b/internal/simulate/simulate.go @@ -62,7 +62,7 @@ type Planner struct { // NewPlanner returns a Planner that reads live state with kubeClient and // rmapper, resolves unset namespaces to defaultNamespace, and bounds each -// diff server plan by timeout. +// diff server plan by timeout, when positive. func NewPlanner(kubeClient client.Client, rmapper meta.RESTMapper, defaultNamespace string, r Router, timeout time.Duration) *Planner { return &Planner{ kube: kubeClient, @@ -115,8 +115,12 @@ func (p *Planner) PlanWithLive(ctx context.Context, desired, live *unstructured. return plan.Fallback(p.crds.apply(ctx, p.kube, p.rmapper, desired), live, fmt.Sprintf("no diff server for %s", gk)) } - pctx, cancel := context.WithTimeout(ctx, p.timeout) - defer cancel() + pctx := ctx + if p.timeout > 0 { + var cancel context.CancelFunc + pctx, cancel = context.WithTimeout(ctx, p.timeout) + defer cancel() + } store := p.stores.gather(ctx, p.kube, p.rmapper, p.defaultNamespace, desired, live) From d301f7292c3d640a4c40263122698cc0a0dcf73f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 00:13:32 -0400 Subject: [PATCH 085/126] fix(simulate): degrade when a provider lookup fails in on-demand mode A failed CRD or ProviderRevision lookup now means no provider for that kind, with one warning per kind, as ProviderManaged already treats it. Lookups are memoized across composites. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 6 +- cmd/crossplane/simulate/simulate.go | 5 +- internal/simulate/simulate.go | 89 ++++++++++++++++++----------- internal/simulate/simulate_test.go | 35 ++++++++++-- 4 files changed, 89 insertions(+), 46 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index cd55a073..21df7822 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -395,11 +395,9 @@ func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logge return nil, err } default: + cache := simulate.NewImageCache() ensure = func(ctx context.Context, rs []*un.Unstructured) error { - imgs, err := simulate.RelevantProviderImages(ctx, kubeClient, rmapper, rs, len(ics) > 0) - if err != nil { - return err - } + imgs := simulate.RelevantProviderImages(ctx, kubeClient, rmapper, rs, len(ics) > 0, cache, k.Stderr) return c.servers.Ensure(ctx, diffserver.Images(imgs)) } } diff --git a/cmd/crossplane/simulate/simulate.go b/cmd/crossplane/simulate/simulate.go index c051cdb6..d9224f28 100644 --- a/cmd/crossplane/simulate/simulate.go +++ b/cmd/crossplane/simulate/simulate.go @@ -132,10 +132,7 @@ func (c *Cmd) Run(ctx context.Context, k *kong.Context, logger logging.Logger, s // With local ImageConfigs the rewrites apply to original package // references, so discovery must hand those out; without them the // cluster's resolved reference is the exact artifact it runs. - images, err := isimulate.RelevantProviderImages(ctx, kubeClient, rmapper, resources, len(imageConfigs) > 0) - if err != nil { - return errors.Wrap(err, "cannot discover the providers serving the given resources") - } + images := isimulate.RelevantProviderImages(ctx, kubeClient, rmapper, resources, len(imageConfigs) > 0, isimulate.NewImageCache(), k.Stderr) inputs = diffserver.Images(images) } pkgClient, err := clixpkg.NewClient(clixpkg.NewRemoteFetcher(), diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go index 60fa96fc..0e8ba749 100644 --- a/internal/simulate/simulate.go +++ b/internal/simulate/simulate.go @@ -22,6 +22,7 @@ package simulate import ( "context" "fmt" + "io" "strings" "time" @@ -281,58 +282,78 @@ func providerConfigMapping(rmapper meta.RESTMapper, group, kind string) *meta.RE } } +// ImageCache remembers which provider image serves each kind, so a run +// looks a kind up on the cluster once however many composites use it. The +// zero value is not usable; use NewImageCache. +type ImageCache struct { + images map[schema.GroupKind]string +} + +// NewImageCache returns an empty ImageCache. +func NewImageCache() *ImageCache { + return &ImageCache{images: map[schema.GroupKind]string{}} +} + // RelevantProviderImages returns the package images of exactly the providers // that serve the given resources, so simulate never starts a diff server it // will not route to. Discovery follows the ownership chain on the cluster: // each resource's kind maps to a CRD, the CRD is controlled by the active // ProviderRevision, and the revision carries the resolved package image. // Kinds that resolve to no provider are skipped; their resources degrade to -// the approximate client-side diff. -func RelevantProviderImages(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, resources []*unstructured.Unstructured, preferOriginal bool) ([]string, error) { - seenGK := map[schema.GroupKind]bool{} +// the approximate client-side diff. So are kinds whose lookup fails (such as +// forbidden): ProviderManaged treats them the same way, and a warning to warn +// names each such kind once. cache carries answers across calls. +func RelevantProviderImages(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, resources []*unstructured.Unstructured, preferOriginal bool, cache *ImageCache, warn io.Writer) []string { seenImage := map[string]bool{} var images []string for _, r := range resources { gvk := r.GroupVersionKind() gk := gvk.GroupKind() - if seenGK[gk] { - continue - } - seenGK[gk] = true - - revision, crdName, err := controllingRevision(ctx, kubeClient, rmapper, gvk) - if err != nil { - return nil, err - } - if revision == "" { - continue - } - - prv := &pkgv1.ProviderRevision{} - if err := kubeClient.Get(ctx, client.ObjectKey{Name: revision}, prv); err != nil { - return nil, errors.Wrapf(err, "cannot get provider revision %s owning CRD %s", revision, crdName) - } - // Prefer the resolved package: after the cluster's ImageConfig - // rewriting, it is the identity of the artifact the cluster actually - // runs, which is what the preview promises to use. If that reference - // is not pullable from this machine (e.g. a cluster-only mirror), the - // manager reports the image as skipped, its resources degrade to the - // approximate diff, and --provider-images remains the override for - // picking a locally reachable image instead. With local ImageConfigs - // in play (preferOriginal) the original reference is handed out - // instead, because that is what local rewrite rules are written - // against. - img := prv.Status.ResolvedPackage - if preferOriginal || img == "" { - img = prv.Spec.Package + img, ok := cache.images[gk] + if !ok { + var err error + img, err = providerImage(ctx, kubeClient, rmapper, gvk, preferOriginal) + if err != nil { + _, _ = fmt.Fprintf(warn, "warning: cannot look up the provider for %s, so its diff stays client-side only: %s\n", gk, err) + } + cache.images[gk] = img } if img != "" && !seenImage[img] { seenImage[img] = true images = append(images, img) } } - return images, nil + return images +} + +// providerImage returns the package image of the provider serving gvk, or +// empty when none does. +func providerImage(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, gvk schema.GroupVersionKind, preferOriginal bool) (string, error) { + revision, crdName, err := controllingRevision(ctx, kubeClient, rmapper, gvk) + if err != nil || revision == "" { + return "", err + } + + prv := &pkgv1.ProviderRevision{} + if err := kubeClient.Get(ctx, client.ObjectKey{Name: revision}, prv); err != nil { + return "", errors.Wrapf(err, "cannot get provider revision %s owning CRD %s", revision, crdName) + } + // Prefer the resolved package: after the cluster's ImageConfig + // rewriting, it is the identity of the artifact the cluster actually + // runs, which is what the preview promises to use. If that reference + // is not pullable from this machine (e.g. a cluster-only mirror), the + // manager reports the image as skipped, its resources degrade to the + // approximate diff, and --provider-images remains the override for + // picking a locally reachable image instead. With local ImageConfigs + // in play (preferOriginal) the original reference is handed out + // instead, because that is what local rewrite rules are written + // against. + img := prv.Status.ResolvedPackage + if preferOriginal || img == "" { + img = prv.Spec.Package + } + return img, nil } // ProviderManaged returns a predicate reporting whether a resource's kind is diff --git a/internal/simulate/simulate_test.go b/internal/simulate/simulate_test.go index c7155e43..539ac509 100644 --- a/internal/simulate/simulate_test.go +++ b/internal/simulate/simulate_test.go @@ -17,7 +17,11 @@ limitations under the License. package simulate import ( + "bytes" "context" + "errors" + "io" + "strings" "testing" "time" @@ -28,6 +32,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/structpb" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -431,15 +436,37 @@ func TestPlanner(t *testing.T) { "kind": "Key", "metadata": map[string]any{"name": "k"}, }} - got, err := RelevantProviderImages(ctx, kube, rm, []*unstructured.Unstructured{vpc("a", "10.0.0.0/16"), vpc("b", "10.1.0.0/16"), unserved}, false) - if err != nil { - t.Fatalf("RelevantProviderImages(...): unexpected error: %v", err) - } + cache := NewImageCache() + got := RelevantProviderImages(ctx, kube, rm, []*unstructured.Unstructured{vpc("a", "10.0.0.0/16"), vpc("b", "10.1.0.0/16"), unserved}, false, cache, io.Discard) want := []string{"xpkg.upbound.io/upbound/provider-aws-ec2:v2.9.0"} if len(got) != 1 || got[0] != want[0] { t.Errorf("RelevantProviderImages(...): want %v, got %v", want, got) } + // A repeat call is served from the cache, not the cluster. + if err := kube.Delete(ctx, prv); err != nil { + t.Fatalf("cannot delete revision: %v", err) + } + if again := RelevantProviderImages(ctx, kube, rm, []*unstructured.Unstructured{vpc("c", "10.2.0.0/16")}, false, cache, io.Discard); len(again) != 1 || again[0] != want[0] { + t.Errorf("RelevantProviderImages(...) repeat: want %v from the cache, got %v", want, again) + } + + // A forbidden lookup yields no image, a warning, and no failure. + forbidden := fake.NewClientBuilder().WithScheme(discScheme). + WithInterceptorFuncs(interceptor.Funcs{Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return apierrors.NewForbidden(schema.GroupResource{Resource: "customresourcedefinitions"}, "vpcs.ec2.aws.upbound.io", errors.New("denied")) + }}).Build() + var warned bytes.Buffer + fc := NewImageCache() + for range 2 { + if imgs := RelevantProviderImages(ctx, forbidden, rm, []*unstructured.Unstructured{vpc("a", "10.0.0.0/16")}, false, fc, &warned); len(imgs) != 0 { + t.Errorf("RelevantProviderImages(forbidden): want no images, got %v", imgs) + } + } + if n := strings.Count(warned.String(), "warning:"); n != 1 { + t.Errorf("RelevantProviderImages(forbidden): want 1 warning, got %d: %q", n, warned.String()) + } + // ProviderManaged follows the same chain: a VPC's CRD is controlled // by a ProviderRevision; an unserved kind has no CRD. managed := ProviderManaged(ctx, kube, rm) From a3e04971c01617fb53a23e3b997d6bede48fe8b2 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 00:24:02 -0400 Subject: [PATCH 086/126] fix(simulate): keep the first plan per diff key and document sequential use Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simulate.go | 4 +++- internal/simulate/xrplan.go | 20 +++++++++++++++++--- internal/simulate/xrplan_test.go | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go index 0e8ba749..35885ce2 100644 --- a/internal/simulate/simulate.go +++ b/internal/simulate/simulate.go @@ -50,7 +50,9 @@ type Router interface { // A Planner plans desired resources one at a time against the diff servers // its Router routes to. It caches the object stores it gathers, so reuse one -// Planner across a run. +// Planner across a run. A Planner is not safe for concurrent use: the engine +// runs XRs sequentially, and the object store cache (like ProviderManaged's +// kind map and ImageCache) is unlocked. type Planner struct { kube client.Client rmapper meta.RESTMapper diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go index c99504e3..e469abfd 100644 --- a/internal/simulate/xrplan.go +++ b/internal/simulate/xrplan.go @@ -40,6 +40,16 @@ type EnsureServers func(ctx context.Context, resources []*unstructured.Unstructu // XRPlanner plans each composite resource's composed resources with their // 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 +// first XR's plan wins, so a composed resource rendered differently by two +// inputs shows the first XR's plan in both reports. Plans merge in the +// AfterXRDiff hook, before schema validation verdicts, so an XR that +// validation later fails has already contributed its plans. +// +// The engine runs XRs sequentially, so the mutex only guards Plans and +// Routed against readers; the Planner and the maps ensure and keep close over +// are not safe for concurrent AfterXRDiff calls. type XRPlanner struct { planner *Planner keep func(*unstructured.Unstructured) bool @@ -87,7 +97,11 @@ func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.Resour x.mu.Lock() defer x.mu.Unlock() - maps.Copy(x.plans, plans) + for key, p := range plans { + if _, ok := x.plans[key]; !ok { + x.plans[key] = p + } + } for key, d := range diffs { if _, ok := plans[key]; ok && x.planner.CanPlan(d.Gvk.GroupKind()) { x.routed++ @@ -97,12 +111,12 @@ func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.Resour return nil } -// Plans returns the plans collected so far, never nil. +// Plans returns a copy of the plans collected so far, never nil. func (x *XRPlanner) Plans() map[string]*plan.ResourcePlan { x.mu.Lock() defer x.mu.Unlock() - return x.plans + return maps.Clone(x.plans) } // Routed returns how many collected plans went to a diff server. Plans that diff --git a/internal/simulate/xrplan_test.go b/internal/simulate/xrplan_test.go index e49cfc8a..4830125d 100644 --- a/internal/simulate/xrplan_test.go +++ b/internal/simulate/xrplan_test.go @@ -193,3 +193,22 @@ func TestXRPlanner(t *testing.T) { } }) } + +func TestXRPlannerFirstPlanWins(t *testing.T) { + s := runtime.NewScheme() + s.AddKnownTypeWithName(vpcGVK, &unstructured.Unstructured{}) + s.AddKnownTypeWithName(vpcGVK.GroupVersion().WithKind("VPCList"), &unstructured.UnstructuredList{}) + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) + rm.Add(vpcGVK, meta.RESTScopeRoot) + pc := &funcPlanClient{fakePlanClient: fakePlanClient{rsp: &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_CREATE}}} + x := NewXRPlanner(NewPlanner(fake.NewClientBuilder().WithScheme(s).Build(), rm, "default", &fakeRouter{client: pc}, time.Second), nil, nil) + + for _, name := range []string{"first", "second"} { + if err := x.AfterXRDiff(context.Background(), map[string]*dt.ResourceDiff{"k": added(vpc(name, "10.0.0.0/16"))}); err != nil { + t.Fatalf("AfterXRDiff(%s): %v", name, err) + } + } + if got := x.Plans()["k"]; got == nil || got.Name != "first" { + t.Errorf("Plans()[k]: want the first XR's plan, got %+v", got) + } +} From 16994ca124084208fdbb190b90aba8e89e065340 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 00:24:02 -0400 Subject: [PATCH 087/126] fix(project): allow --cache-dir with planning, select servers in a testable function, note when no servers run, fix help Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 16 +++-- cmd/crossplane/project/simulate.go | 95 +++++++++++++++++-------- cmd/crossplane/project/simulate_test.go | 80 ++++++++++++++++++--- 3 files changed, 146 insertions(+), 45 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 1760c16e..28248fc8 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -39,8 +39,10 @@ own. Pass `--include-pinned` to simulate them too. Some flags apply only to some inputs. `--namespace`, `--name`, `--include-pinned`, `--analyze-on`, and `--minimize-composition` choose which composite resources a Composition change reaches, so composite resource files -don't take them. `--max-concurrency` and `--cache-dir` configure the project's -function builds, so only a project file takes them. +don't take them. `--max-concurrency` configures the project's function builds, +so only a project file takes it. `--cache-dir` caches function builds and +provider packages, so Composition and composite resource files take it unless +`--skip-plan` is set. The `-o` flag picks the output format: @@ -55,7 +57,7 @@ The `-o` flag picks the output format: is the manifest sent to the provider, not the provider's view. - `diff` prints the line diff `crossplane-diff` prints. -For example, a changed Composition prints a plan like this: +For example, a changed Composition run with `--skip-plan` prints a plan like this: ``` [~] Composition.apiextensions.crossplane.io xnetworks.example.org (update) @@ -73,7 +75,7 @@ XR: XNetwork.example.org team-a/prod-network Plan: 0 to add, 2 to change, 0 to destroy. -Note: Cloud-layer plan not run (provider diff servers not yet wired). +Note: Cloud-layer plan skipped (--skip-plan). ``` The `--timeout` flag, 2m by default, bounds the time to simulate one composite @@ -100,7 +102,8 @@ Provider plans are controlled by these flags: diff servers, instead of discovering providers from the cluster. - `--image-configs` takes a YAML file of ImageConfig manifests that rewrite provider image paths for local pulls. It defaults to the project's - imageConfigs when `-f` names a project file. + imageConfigs when `-f` names a project file; in Composition and XR modes it + falls back, best effort, to the project file in the current directory. - `--session-id` sets the `simulate.crossplane.io/session` label on diff server containers. It defaults to a random per-process ID. @@ -125,7 +128,8 @@ the result: - 0: the simulation succeeded and found no changes. - 1: the simulation failed. A provider plan that fails fails its whole - composite resource, which then reports no plans. + composite resource, which then reports an error entry and none of its + resources' plans. - 2: only schema validation failed. - 3: the simulation succeeded and found changes, including drift a provider plan found that the cluster diff didn't. diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 21df7822..77512293 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -79,10 +79,12 @@ type simulateCmd struct { // Run sets these; nil with --skip-plan (servers also when Hooks.Router is set). xrPlanner *simulate.XRPlanner servers *simulate.ServerSet + // noServersNoted is set once the "no diff servers" line is printed. + noServersNoted bool Input []string `default:"${project_file}" help:"Project file (default), or changed Composition or composite resource files. Repeatable; - reads standard input." placeholder:"PATH" sep:"none" short:"f"` MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents (functions and providers)." name:"cache-dir"` Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` @@ -105,9 +107,9 @@ type simulateCmd struct { DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` SkipPlan bool `help:"Don't run provider diff servers; show only the cluster-layer diff."` - Providers string `help:"YAML file or directory with Provider manifests to run as diff servers, overriding discovery from the cluster. Supports per-provider runtime annotations." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` - ImageConfigs string `help:"YAML file with ImageConfig manifests that rewrite provider image paths for local pulls. Defaults to the project's imageConfigs when -f names a project file." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` - SessionID string `help:"Session ID to apply as the simulate.crossplane.io/session label on diff server containers. Defaults to a random per-process ID." placeholder:"ID"` + Providers string `help:"YAML file or directory with Provider manifests to run as diff servers, overriding discovery from the cluster. Supports per-provider runtime annotations." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` + ImageConfigs string `help:"YAML file with ImageConfig manifests that rewrite provider image paths for local pulls. Defaults to the project's imageConfigs when -f names a project file; in Composition and XR modes it falls back, best effort, to the project file in the current directory." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` + SessionID string `help:"Session ID to apply as the simulate.crossplane.io/session label on diff server containers. Defaults to a random per-process ID." placeholder:"ID"` // CrossplaneVersion and CrossplaneImage select the crossplane render // backend, as they do for crossplane-diff. When neither is set the render @@ -242,7 +244,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin if err != nil { return err } - if err := validateModeFlags(in.mode, setFlags(k)); err != nil { + if err := validateModeFlags(in.mode, setFlags(k), c.SkipPlan); err != nil { return err } @@ -376,29 +378,24 @@ func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logge } var ensure simulate.EnsureServers - switch { - case c.servers == nil: - case c.Providers != "": - inputs, err := simulate.LoadProviderInputs(c.Providers) - if err != nil { - return nil, errors.Wrapf(err, "cannot load provider manifests from %q", c.Providers) - } - if err := c.servers.Ensure(sigCtx, inputs); err != nil { - return nil, err - } - case in.mode == projectMode: - images, err := project.LoadProviderDependencies(depMgr, proj) + if c.servers != nil { + var upfront []diffserver.Input + upfront, ensure, err = selectServers(c.Providers, in.mode == projectMode, simulate.LoadProviderInputs, + func() ([]string, error) { return project.LoadProviderDependencies(depMgr, proj) }, + func() simulate.EnsureServers { + cache := simulate.NewImageCache() + return func(ctx context.Context, rs []*un.Unstructured) error { + imgs := simulate.RelevantProviderImages(ctx, kubeClient, rmapper, rs, len(ics) > 0, cache, k.Stderr) + return c.servers.Ensure(ctx, diffserver.Images(imgs)) + } + }) if err != nil { - return nil, errors.Wrap(err, "cannot load the project's provider dependencies") - } - if err := c.servers.Ensure(sigCtx, diffserver.Images(images)); err != nil { return nil, err } - default: - cache := simulate.NewImageCache() - ensure = func(ctx context.Context, rs []*un.Unstructured) error { - imgs := simulate.RelevantProviderImages(ctx, kubeClient, rmapper, rs, len(ics) > 0, cache, k.Stderr) - return c.servers.Ensure(ctx, diffserver.Images(imgs)) + if ensure == nil { + if err := c.servers.Ensure(sigCtx, upfront); err != nil { + return nil, err + } } } @@ -407,6 +404,39 @@ func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logge return []dp.ProcessorOption{dp.WithAfterXRDiff(c.xrPlanner.AfterXRDiff), dp.WithKeepEqualDiffs(keep)}, nil } +// selectServers picks where the diff servers come from: --providers when +// set, else the project's provider dependencies in project mode, both +// returned as inputs to start up front with a nil ensure; otherwise the +// ensure func from onDemand, which starts servers per composite resource. +func selectServers(providers string, projectMode bool, loadInputs func(string) ([]diffserver.Input, error), loadDeps func() ([]string, error), onDemand func() simulate.EnsureServers) ([]diffserver.Input, simulate.EnsureServers, error) { + switch { + case providers != "": + inputs, err := loadInputs(providers) + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot load provider manifests from %q", providers) + } + return inputs, nil, nil + case projectMode: + images, err := loadDeps() + if err != nil { + return nil, nil, errors.Wrap(err, "cannot load the project's provider dependencies") + } + return diffserver.Images(images), nil, nil + default: + return nil, onDemand(), nil + } +} + +// noteNoServers prints once, when planning is on and no diff server ever +// ran, that the diff shows only the cluster layer. -o diff has no notes. +func (c *simulateCmd) noteNoServers(k *kong.Context) { + if c.servers == nil || c.servers.Ran() || c.noServersNoted { + return + } + c.noServersNoted = true + _, _ = fmt.Fprintln(k.Stderr, "No provider diff servers to run; showing the cluster-layer diff.") +} + // restConfig returns the cluster to simulate against: the hooks' when set, // otherwise the kubeconfig's. func (c *simulateCmd) restConfig() (*rest.Config, error) { @@ -653,15 +683,16 @@ func pipelineInputs(resources []*un.Unstructured) (map[string]int, error) { // simulate, so the flags that pick and tune which ones a Composition change // reaches don't apply to them, as in crossplane-diff, whose xr command lacks // its comp command's equivalents. Functions come from the cluster unless -f -// names a project, so the build flags apply only to a project. -func validateModeFlags(mode inputMode, set map[string]bool) error { +// names a project, so --max-concurrency applies only to a project; --cache-dir also caches provider packages, so it applies whenever planning is on. +func validateModeFlags(mode inputMode, set map[string]bool, skipPlan bool) error { if mode == projectMode { return nil } - for _, f := range []string{"max-concurrency", "cache-dir"} { - if set[f] { - return errors.Errorf("--%s configures the project's function builds, so it applies only when -f names a project file; Composition and composite resource files use the cluster's functions", f) - } + if set["max-concurrency"] { + return errors.New("--max-concurrency configures the project's function builds, so it applies only when -f names a project file; Composition and composite resource files use the cluster's functions") + } + if set["cache-dir"] && skipPlan { + return errors.New("--cache-dir caches function builds in project mode and provider packages when planning; with --skip-plan and no project file there is nothing to cache, so drop it or drop --skip-plan") } if mode != xrMode { return nil @@ -841,6 +872,7 @@ func (c *simulateCmd) notes() []string { // selects: the engine's human diff for diff, and plan.Result otherwise. It // returns whether the run changed anything, provider plans included. func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport) (bool, error) { + c.noteNoServers(k) opts := c.diffOptions(k) plans := c.providerPlans() if plans != nil { @@ -863,6 +895,7 @@ func (c *simulateCmd) renderXRReport(k *kong.Context, logger logging.Logger, r * // renderCompReport writes a composition run's report in the format -o // selects, as renderXRReport does. func (c *simulateCmd) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput) (bool, error) { + c.noteNoServers(k) opts := c.diffOptions(k) opts.MinimizeComposition = c.MinimizeComposition plans := c.providerPlans() diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 3f849f33..70e0f1ee 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -48,6 +48,7 @@ import ( "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/diffserver" "github.com/crossplane/cli/v2/internal/plan" "github.com/crossplane/cli/v2/internal/simulate" ) @@ -955,10 +956,11 @@ spec: func TestValidateModeFlags(t *testing.T) { cases := map[string]struct { - reason string - mode inputMode - args []string - err string + reason string + mode inputMode + args []string + skipPlan bool + err string }{ "ProjectAcceptsAll": { reason: "A project accepts every flag.", @@ -976,11 +978,23 @@ func TestValidateModeFlags(t *testing.T) { args: []string{"--max-concurrency", "2"}, err: "--max-concurrency configures the project's function builds", }, - "XRRejectsCacheDir": { - reason: "Composite resources use the cluster's functions, so the build flags don't apply.", + "XRAcceptsCacheDir": { + reason: "--cache-dir also caches provider packages, so it applies whenever planning is on.", mode: xrMode, args: []string{"--cache-dir", "/c"}, - err: "--cache-dir configures the project's function builds", + }, + "XRRejectsCacheDirSkipPlan": { + reason: "With --skip-plan and no project there is nothing to cache.", + mode: xrMode, + args: []string{"--cache-dir", "/c"}, + skipPlan: true, + err: "--cache-dir caches function builds in project mode", + }, + "ProjectAcceptsCacheDirSkipPlan": { + reason: "A project caches function builds even without planning.", + mode: projectMode, + args: []string{"--cache-dir", "/c"}, + skipPlan: true, }, "XRRejectsNamespace": { reason: "Composite resource files name what to simulate, so -n doesn't apply.", @@ -1021,7 +1035,7 @@ func TestValidateModeFlags(t *testing.T) { t.Fatalf("Parse(%v): %v", tc.args, err) } - err = validateModeFlags(tc.mode, setFlags(k)) + err = validateModeFlags(tc.mode, setFlags(k), tc.skipPlan) if tc.err == "" { if err != nil { t.Errorf("\n%s\nvalidateModeFlags(...): %v", tc.reason, err) @@ -1120,3 +1134,53 @@ func TestPlanNotes(t *testing.T) { }) } } + +func TestSelectServers(t *testing.T) { + loadInputs := func(string) ([]diffserver.Input, error) { return diffserver.Images([]string{"p:1"}), nil } + loadDeps := func(images ...string) func() ([]string, error) { + return func() ([]string, error) { return images, nil } + } + onDemand := func() simulate.EnsureServers { + return func(context.Context, []*un.Unstructured) error { return nil } + } + + cases := map[string]struct { + providers string + project bool + deps []string + wantInputs int + wantEnsure bool + wantInputIm string + }{ + "Providers": {providers: "dir", project: true, deps: []string{"d:1"}, wantInputs: 1, wantInputIm: "p:1"}, + "ProjectDeps": {project: true, deps: []string{"d:1", "d:2"}, wantInputs: 2, wantInputIm: "d:1"}, + "ProjectNoDeps": {project: true}, + "OnDemandNoInputs": {wantEnsure: true}, + "OnDemandIgnoresDeps": {deps: []string{"d:1"}, wantEnsure: true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + inputs, ensure, err := selectServers(tc.providers, tc.project, loadInputs, loadDeps(tc.deps...), onDemand) + if err != nil { + t.Fatalf("selectServers(...): %v", err) + } + if len(inputs) != tc.wantInputs { + t.Errorf("inputs: want %d, got %d", tc.wantInputs, len(inputs)) + } + if (ensure != nil) != tc.wantEnsure { + t.Errorf("ensure set: want %v, got %v", tc.wantEnsure, ensure != nil) + } + if tc.wantInputIm != "" && inputs[0].Image != tc.wantInputIm { + t.Errorf("first input image: want %q, got %q", tc.wantInputIm, inputs[0].Image) + } + }) + } + + boom := errors.New("boom") + if _, _, err := selectServers("", true, loadInputs, func() ([]string, error) { return nil, boom }, onDemand); !errors.Is(err, boom) { + t.Errorf("project dependency load error: want %v, got %v", boom, err) + } + if _, _, err := selectServers("x", false, func(string) ([]diffserver.Input, error) { return nil, boom }, loadDeps(), onDemand); !errors.Is(err, boom) { + t.Errorf("provider load error: want %v, got %v", boom, err) + } +} From 4f8b38dc9e95fbb005388e97bc89e8fc318ea41d Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 01:08:56 -0400 Subject: [PATCH 088/126] fix(diff): test function start outside the XR budget, close the budget on cancel, export ErrXRTimedOut Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../diff/diffprocessor/processor_config.go | 15 ++++++---- .../diffprocessor/processor_config_test.go | 4 +-- .../diff/diffprocessor/render_engine_test.go | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 8 deletions(-) diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index a89778e6..8ad2b8be 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -455,8 +455,9 @@ func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) } } -// errXRTimedOut is the cause an XR context is cancelled with when its XRTimeout budget runs out. -var errXRTimedOut = errors.New("composite resource timed out") +// ErrXRTimedOut is the cause an XR context is cancelled with when its XRTimeout budget runs out. +// OutsideXRDeadline also returns it, so callers can match it with errors.Is. +var ErrXRTimedOut = errors.New("composite resource timed out") // xrContext returns the context one composite resource's diff runs under: ctx bounded by XRTimeout // when it is set. The budget can be paused with OutsideXRDeadline. @@ -471,6 +472,7 @@ func (c *ProcessorConfig) xrContext(ctx context.Context) (context.Context, conte return context.WithValue(xrCtx, budgetKey{}, b), func() { b.mu.Lock() + b.expired = true // A pause still running must not re-arm the timer. b.timer.Stop() b.mu.Unlock() cancel(context.Canceled) @@ -500,9 +502,10 @@ type xrBudget struct { func (b *xrBudget) expire() { b.mu.Lock() + defer b.mu.Unlock() + b.expired = true - b.mu.Unlock() - b.cancel(errXRTimedOut) + b.cancel(ErrXRTimedOut) } // pause stops the clock, reporting false when the budget already ran out. @@ -554,7 +557,7 @@ func OutsideXRDeadline(ctx context.Context, fn func(run context.Context) error) } if !b.pause() { - return errXRTimedOut + return ErrXRTimedOut } defer b.resume() @@ -569,7 +572,7 @@ func (c *ProcessorConfig) afterXR(ctx, xrCtx context.Context, diffs map[string]* } // Only the composite's own deadline is named; the caller's ending reads as it always has. - if err != nil && ctx.Err() == nil && errors.Is(context.Cause(xrCtx), errXRTimedOut) { + if err != nil && ctx.Err() == nil && errors.Is(context.Cause(xrCtx), ErrXRTimedOut) { return errors.Wrapf(err, "timed out after %s", c.XRTimeout) } diff --git a/internal/diff/diffprocessor/processor_config_test.go b/internal/diff/diffprocessor/processor_config_test.go index b6baf7c1..efb113c4 100644 --- a/internal/diff/diffprocessor/processor_config_test.go +++ b/internal/diff/diffprocessor/processor_config_test.go @@ -262,8 +262,8 @@ func TestOutsideXRDeadline(t *testing.T) { ran := false err := OutsideXRDeadline(xrCtx, func(context.Context) error { ran = true; return nil }) - if ran || !errors.Is(err, errXRTimedOut) { - t.Errorf("ran = %v, err = %v; want fn skipped and errXRTimedOut", ran, err) + if ran || !errors.Is(err, ErrXRTimedOut) { + t.Errorf("ran = %v, err = %v; want fn skipped and ErrXRTimedOut", ran, err) } }) } diff --git a/internal/diff/diffprocessor/render_engine_test.go b/internal/diff/diffprocessor/render_engine_test.go index 08608a76..da4150e2 100644 --- a/internal/diff/diffprocessor/render_engine_test.go +++ b/internal/diff/diffprocessor/render_engine_test.go @@ -990,3 +990,31 @@ func TestEngineRenderFn_RunOwnsFunctionContainers(t *testing.T) { }) } } + +func TestEngineRenderFn_StartFunctionsOutsideXRBudget(t *testing.T) { + mock := &render.MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{CompositeResource: req.GetComposite().GetCompositeResource()}, + }, + }, nil + }, + } + + var startCalls, stopCalls int32 + + e := newTestRenderFn(mock, &startCalls, &stopCalls) + // Starting the function outlasts the XR's budget and fails if it runs under it. + e.startRuntimes = func(ctx context.Context, _ logging.Logger, _ []pkgv1.Function) (*render.FunctionAddresses, error) { + time.Sleep(150 * time.Millisecond) + return &render.FunctionAddresses{}, ctx.Err() + } + + xrCtx, cancel := NewXRContext(t.Context(), 50*time.Millisecond) + defer cancel() + + if _, err := e.Render(xrCtx, logging.NewNopLogger(), minimalRenderInputs()); err != nil { + t.Errorf("Render(...): function start counted against the XR budget: %v", err) + } +} From b55b80d207bd8e027419a998a4961fd30111742a Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 01:08:56 -0400 Subject: [PATCH 089/126] fix(simulate): count routed plans that fail, capitalize the warning, make the concurrent client test honest Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/servers_test.go | 7 ++----- internal/simulate/simulate.go | 2 +- internal/simulate/simulate_test.go | 2 +- internal/simulate/xrplan.go | 18 +++++++++++------- internal/simulate/xrplan_test.go | 11 +++++++++++ 5 files changed, 26 insertions(+), 14 deletions(-) diff --git a/internal/simulate/servers_test.go b/internal/simulate/servers_test.go index 91c5964d..f15d228d 100644 --- a/internal/simulate/servers_test.go +++ b/internal/simulate/servers_test.go @@ -274,7 +274,7 @@ func TestServerSetEnsure(t *testing.T) { } } -func TestNewKubeClientConcurrent(t *testing.T) { +func TestNewKubeClientConcurrent(_ *testing.T) { var wg sync.WaitGroup for range 16 { wg.Go(func() { @@ -284,8 +284,5 @@ func TestNewKubeClientConcurrent(t *testing.T) { }) } wg.Wait() - - if s, err := kubeScheme(); err != nil || s == nil { - t.Errorf("kubeScheme() = %v, %v", s, err) - } + // ponytail: no assertion; this test exists for -race, to catch shared scheme mutation. } diff --git a/internal/simulate/simulate.go b/internal/simulate/simulate.go index 35885ce2..000ee71c 100644 --- a/internal/simulate/simulate.go +++ b/internal/simulate/simulate.go @@ -317,7 +317,7 @@ func RelevantProviderImages(ctx context.Context, kubeClient client.Client, rmapp var err error img, err = providerImage(ctx, kubeClient, rmapper, gvk, preferOriginal) if err != nil { - _, _ = fmt.Fprintf(warn, "warning: cannot look up the provider for %s, so its diff stays client-side only: %s\n", gk, err) + _, _ = fmt.Fprintf(warn, "Warning: cannot look up the provider for %s, so its diff stays client-side only: %s\n", gk, err) } cache.images[gk] = img } diff --git a/internal/simulate/simulate_test.go b/internal/simulate/simulate_test.go index 539ac509..a3027d2b 100644 --- a/internal/simulate/simulate_test.go +++ b/internal/simulate/simulate_test.go @@ -463,7 +463,7 @@ func TestPlanner(t *testing.T) { t.Errorf("RelevantProviderImages(forbidden): want no images, got %v", imgs) } } - if n := strings.Count(warned.String(), "warning:"); n != 1 { + if n := strings.Count(warned.String(), "Warning:"); n != 1 { t.Errorf("RelevantProviderImages(forbidden): want 1 warning, got %d: %q", n, warned.String()) } diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go index e469abfd..85d98231 100644 --- a/internal/simulate/xrplan.go +++ b/internal/simulate/xrplan.go @@ -90,23 +90,27 @@ func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.Resour failed = append(failed, fmt.Sprintf("%s/%s: %s", p.Kind, p.Name, p.Error)) } } + // Count what reached a server even when its plan failed, so the run still knows servers served it. + var routed int + for key, d := range diffs { + if _, ok := plans[key]; ok && x.planner.CanPlan(d.Gvk.GroupKind()) { + routed++ + } + } + + x.mu.Lock() + defer x.mu.Unlock() + x.routed += routed if len(failed) > 0 { sort.Strings(failed) return errors.Errorf("cannot plan %d resources with their diff servers: %s", len(failed), strings.Join(failed, "; ")) } - x.mu.Lock() - defer x.mu.Unlock() for key, p := range plans { if _, ok := x.plans[key]; !ok { x.plans[key] = p } } - for key, d := range diffs { - if _, ok := plans[key]; ok && x.planner.CanPlan(d.Gvk.GroupKind()) { - x.routed++ - } - } return nil } diff --git a/internal/simulate/xrplan_test.go b/internal/simulate/xrplan_test.go index 4830125d..17f2ec64 100644 --- a/internal/simulate/xrplan_test.go +++ b/internal/simulate/xrplan_test.go @@ -187,6 +187,17 @@ func TestXRPlanner(t *testing.T) { } }) + t.Run("FailedPlanStillRouted", func(t *testing.T) { + x := NewXRPlanner(planner, nil, nil) + err := x.AfterXRDiff(context.Background(), map[string]*dt.ResourceDiff{"bad-a": added(vpc("bad-a", "10.0.0.0/16"))}) + if err == nil { + t.Fatal("AfterXRDiff(...) = nil, want the plan error") + } + if x.Routed() != 1 || len(x.Plans()) != 0 { + t.Errorf("Routed() = %d, Plans() = %v; want 1 routed and no merged plans", x.Routed(), x.Plans()) + } + }) + t.Run("EmptyPlansNonNil", func(t *testing.T) { if NewXRPlanner(planner, nil, nil).Plans() == nil { t.Errorf("Plans() = nil, want an empty non-nil map") From 2ea5858ab192533aba0dad2cdb710ad4fa208998 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 01:22:24 -0400 Subject: [PATCH 090/126] fix(simulate): key incremental server start by name for Development providers Development inputs carry no image, so keying the handled set by image treated a second Development provider as already started. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/servers.go | 9 +++++++-- internal/simulate/servers_test.go | 12 ++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/internal/simulate/servers.go b/internal/simulate/servers.go index b55b18a5..f52dcf10 100644 --- a/internal/simulate/servers.go +++ b/internal/simulate/servers.go @@ -124,10 +124,15 @@ func (s *ServerSet) Ensure(ctx context.Context, inputs []diffserver.Input) error var fresh []diffserver.Input for _, in := range inputs { - if s.handled[in.Image] { + // A Development provider has no image; its manifest name identifies it. + key := in.Image + if key == "" { + key = in.Name + } + if s.handled[key] { continue } - s.handled[in.Image] = true + s.handled[key] = true fresh = append(fresh, in) } if len(fresh) == 0 { diff --git a/internal/simulate/servers_test.go b/internal/simulate/servers_test.go index f15d228d..f6604833 100644 --- a/internal/simulate/servers_test.go +++ b/internal/simulate/servers_test.go @@ -274,6 +274,18 @@ func TestServerSetEnsure(t *testing.T) { } } +func TestServerSetEnsureDevelopmentKeys(t *testing.T) { + s := NewServerSet(terminal.NewNopSpinnerPrinter(), logging.NewNopLogger(), &bytes.Buffer{}, &failingPkgs{}, "s") + // Development inputs have no image, so each is keyed by its own name. + _ = s.Ensure(context.Background(), []diffserver.Input{ + {Runtime: diffserver.RuntimeDevelopment, Name: "a", DevelopmentTarget: "localhost:1", APIGroups: []string{"a.example.org"}}, + {Runtime: diffserver.RuntimeDevelopment, Name: "b", DevelopmentTarget: "localhost:2", APIGroups: []string{"b.example.org"}}, + }) + if len(s.handled) != 2 { + t.Errorf("handled %d Development providers, want 2: %v", len(s.handled), s.handled) + } +} + func TestNewKubeClientConcurrent(_ *testing.T) { var wg sync.WaitGroup for range 16 { From daf21fa6b563af4e86ca95b966f7da13a4efcf7f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 11:22:04 -0400 Subject: [PATCH 091/126] feat(project): accept repeated and namespace-qualified --name in project simulate --name now takes [namespace/]name, repeatable or comma-separated. The engine error no longer names --resource. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 7 ++ cmd/crossplane/project/simulate.go | 54 ++++++--- cmd/crossplane/project/simulate_test.go | 66 ++++++++++- internal/diff/diffprocessor/comp_processor.go | 2 +- internal/diff/integration/plan_output_test.go | 105 ++++++++++++++++++ .../diff/integration/simulate_harness_test.go | 22 ++-- 6 files changed, 220 insertions(+), 36 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 28248fc8..86319e20 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -158,6 +158,13 @@ Simulate a single namespaced composite resource: crossplane project simulate -n team-a --name=my-database ``` +`--name` takes `[namespace/]name` and is repeatable or comma-separated. Simulate +two composite resources in different namespaces: + +```shell +crossplane project simulate --name=team-a/my-database --name=team-b/my-database +``` + Print the plan as JSON: ```shell diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 77512293..fa8cd8c4 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -24,6 +24,7 @@ import ( "os" "path/filepath" "slices" + "strings" "time" "github.com/alecthomas/kong" @@ -54,6 +55,7 @@ import ( xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8client "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + "github.com/crossplane/cli/v2/internal/diff/ref" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diffserver" @@ -86,13 +88,13 @@ type simulateCmd struct { MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents (functions and providers)." name:"cache-dir"` - Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` - Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` - Name string `help:"Only simulate the composite resource with this name. Use with --namespace to name a namespaced resource."` + Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` + Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` + Name []string `help:"Only simulate the composite resource with this [namespace/]name. Repeatable or comma-separated. A bare name pairs with --namespace, or is cluster scoped without it." sep:","` IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` - MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` - Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count, but the render image pull counts against the first composite."` - Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` + MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` + Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count, but the render image pull counts against the first composite."` + Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` @@ -511,7 +513,10 @@ func (c *simulateCmd) diffCompositions(k *kong.Context, logger logging.Logger, p msg = "cannot simulate project" } return func(ctx context.Context) (bool, error) { - ns, refs := c.scope() + ns, refs, err := c.scope() + if err != nil { + return false, errors.Wrap(err, msg) + } output, err := proc.DiffComposition(ctx, comps, ns, refs) // Render the report even when there are errors (e.g. every @@ -763,14 +768,33 @@ func (c *simulateCmd) simulate(ctx context.Context, logger logging.Logger, statu } // scope returns the namespace and composite resource refs DiffComposition -// limits its impact analysis to. A --name selects one composite resource, -// namespaced when --namespace is set and cluster scoped otherwise, so the -// namespace moves into its ref. -func (c *simulateCmd) scope() (string, []k8stypes.NamespacedName) { - if c.Name == "" { - return c.Namespace, nil - } - return "", []k8stypes.NamespacedName{{Namespace: c.Namespace, Name: c.Name}} +// limits its impact analysis to. Each --name selects one composite resource: a +// bare name is namespaced when --namespace is set and cluster scoped +// otherwise, and namespace/name carries its own namespace. The namespace moves +// into the refs, so it is not also returned. +func (c *simulateCmd) scope() (string, []k8stypes.NamespacedName, error) { + if len(c.Name) == 0 { + return c.Namespace, nil, nil + } + refs := make([]k8stypes.NamespacedName, 0, len(c.Name)) + for _, v := range c.Name { + if strings.TrimSpace(v) == "" { + return "", nil, errors.New("--name must not be empty; pass a name or namespace/name") + } + if !strings.Contains(v, "/") { + refs = append(refs, k8stypes.NamespacedName{Namespace: c.Namespace, Name: strings.TrimSpace(v)}) + continue + } + if c.Namespace != "" { + return "", nil, errors.Errorf("--name %q includes a namespace, so it can't be combined with --namespace; drop --namespace or pass the bare name", v) + } + r, err := ref.Parse(v) + if err != nil { + return "", nil, errors.New(strings.ReplaceAll(err.Error(), "--resource", "--name")) + } + refs = append(refs, r) + } + return "", refs, nil } // processorOptions returns the diff processor options for this run. Both the diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 70e0f1ee..8024ca0a 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -113,7 +113,7 @@ func TestSimulateFlags(t *testing.T) { Input: []string{"p.yaml"}, Context: "staging", Namespace: "team-a", - Name: "db", + Name: []string{"db"}, IncludePinned: true, MaxIterations: 3, Timeout: 2 * time.Minute, @@ -310,6 +310,7 @@ func TestSimulateScope(t *testing.T) { type want struct { namespace string refs []k8stypes.NamespacedName + err string } cases := map[string]struct { @@ -327,19 +328,53 @@ func TestSimulateScope(t *testing.T) { }, "ClusterScopedName": { reason: "--name alone should name a cluster-scoped composite resource.", - cmd: simulateCmd{Name: "db"}, + cmd: simulateCmd{Name: []string{"db"}}, want: want{refs: []k8stypes.NamespacedName{{Name: "db"}}}, }, "NamespacedName": { reason: "--name with --namespace should name a namespaced composite resource, not scope discovery.", - cmd: simulateCmd{Namespace: "team-a", Name: "db"}, + cmd: simulateCmd{Namespace: "team-a", Name: []string{"db"}}, want: want{refs: []k8stypes.NamespacedName{{Namespace: "team-a", Name: "db"}}}, }, + "ManyNames": { + reason: "Each --name should become a ref, bare names taking --namespace.", + cmd: simulateCmd{Namespace: "team-a", Name: []string{"db", "cache"}}, + want: want{refs: []k8stypes.NamespacedName{{Namespace: "team-a", Name: "db"}, {Namespace: "team-a", Name: "cache"}}}, + }, + "QualifiedNames": { + reason: "namespace/name should carry its own namespace, mixed with a cluster-scoped bare name.", + cmd: simulateCmd{Name: []string{"team-a/db", "team-b/db", "global"}}, + want: want{refs: []k8stypes.NamespacedName{{Namespace: "team-a", Name: "db"}, {Namespace: "team-b", Name: "db"}, {Name: "global"}}}, + }, + "QualifiedWithNamespaceFlag": { + reason: "namespace/name together with --namespace should be rejected.", + cmd: simulateCmd{Namespace: "team-a", Name: []string{"team-a/db"}}, + want: want{err: "can't be combined with --namespace"}, + }, + "EmptyName": { + reason: "An empty --name value should be rejected.", + cmd: simulateCmd{Name: []string{"db", " "}}, + want: want{err: "--name must not be empty"}, + }, + "MalformedName": { + reason: "A malformed namespace/name should be rejected naming --name.", + cmd: simulateCmd{Name: []string{"a/b/c"}}, + want: want{err: "invalid --name value"}, + }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - ns, refs := tc.cmd.scope() + ns, refs, err := tc.cmd.scope() + if tc.want.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.want.err) { + t.Errorf("\n%s\nscope(): want error containing %q, got %v", tc.reason, tc.want.err, err) + } + return + } + if err != nil { + t.Fatalf("\n%s\nscope(): unexpected error: %v", tc.reason, err) + } if diff := cmp.Diff(tc.want.namespace, ns); diff != "" { t.Errorf("\n%s\nscope(): -want namespace, +got:\n%s", tc.reason, diff) } @@ -738,7 +773,7 @@ func TestSimulateRun(t *testing.T) { }, "Scoped": { reason: "--name and --namespace should reach DiffComposition as a ref.", - args: args{cmd: simulateCmd{Namespace: "team-a", Name: "db"}, initClients: noInit, proc: &fakeCompProc{}}, + args: args{cmd: simulateCmd{Namespace: "team-a", Name: []string{"db"}}, initClients: noInit, proc: &fakeCompProc{}}, want: want{diffed: true, refs: []k8stypes.NamespacedName{{Namespace: "team-a", Name: "db"}}}, }, "ClientInitError": { @@ -1184,3 +1219,24 @@ func TestSelectServers(t *testing.T) { t.Errorf("provider load error: want %v, got %v", boom, err) } } + +func TestSimulateNameFlagParse(t *testing.T) { + for name, args := range map[string][]string{ + "Repeated": {"--name=a/db", "--name=b/db"}, + "CommaSeparated": {"--name=a/db,b/db"}, + } { + t.Run(name, func(t *testing.T) { + var c simulateCmd + k, err := kong.New(&c, kong.Vars{"project_file": "crossplane-project.yaml"}) + if err != nil { + t.Fatal(err) + } + if _, err := k.Parse(args); err != nil { + t.Fatal(err) + } + if diff := cmp.Diff([]string{"a/db", "b/db"}, c.Name); diff != "" { + t.Errorf("Name: -want, +got:\n%s", diff) + } + }) + } +} diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 7e5a4be9..2a1294e4 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -356,7 +356,7 @@ func (p *DefaultCompDiffProcessor) preflightResourceRefs(ctx context.Context, co names = append(names, ref.Format(r)) } - return nil, errors.Errorf("--resource ref(s) not relevant to any supplied composition: %v (resource not found, or it doesn't reference one of the supplied compositions)", names) + return nil, errors.Errorf("resource ref(s) not relevant to any supplied composition: %v (resource not found, or it doesn't reference one of the supplied compositions)", names) } return perComp, nil diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index 57ba35bd..cdc4dbda 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -22,6 +22,7 @@ import ( "bytes" "encoding/json" "os" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -190,6 +191,46 @@ Note: Cloud-layer plan not run: no provider diff servers serve these resources. }, }, }, + "NameRepeatedScopesToEach": { + reason: "repeated --name with --namespace scopes the run to each named composite, as the unscoped run covers both.", + testType: CompositionDiffTest, + tt: compCase, + args: []string{"-o", "json", "-f", "testdata/comp/updated-composition.yaml", "-n", "default", "--name=another-resource", "--name=test-resource"}, + wantResult: &planShape{ + Summary: plan.Summary{Update: 3, NoOp: 2}, + Composites: []plan.CompositeResult{{Ref: another, Status: plan.CompositeChanged, Composition: &comp}, {Ref: xnop, Status: plan.CompositeChanged, Composition: &comp}}, + Compositions: []plan.CompositionResult{{ + Ref: comp, + RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2}, + }}, + Plans: []planEntry{ + {Kind: "Composition", Name: comp.Name, Action: plan.ActionUpdate}, + {Kind: "XDownstreamResource", Name: "another-resource", Action: plan.ActionUpdate, Composite: "another-resource"}, + {Kind: "XNopResource", Name: "another-resource", Action: plan.ActionNoOp, Composite: "another-resource"}, + {Kind: "XDownstreamResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + {Kind: "XNopResource", Name: "test-resource", Action: plan.ActionNoOp, Composite: "test-resource"}, + }, + }, + }, + "NameNamespaceQualifiedScopesOne": { + reason: "a namespace/name --name without --namespace scopes the run to that one composite.", + testType: CompositionDiffTest, + tt: compCase, + args: []string{"-o", "json", "-f", "testdata/comp/updated-composition.yaml", "--name=default/test-resource"}, + wantResult: &planShape{ + Summary: plan.Summary{Update: 2, NoOp: 1}, + Composites: []plan.CompositeResult{{Ref: xnop, Status: plan.CompositeChanged, Composition: &comp}}, + Compositions: []plan.CompositionResult{{ + Ref: comp, + RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}, + }}, + Plans: []planEntry{ + {Kind: "Composition", Name: comp.Name, Action: plan.ActionUpdate}, + {Kind: "XDownstreamResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + {Kind: "XNopResource", Name: "test-resource", Action: plan.ActionNoOp, Composite: "test-resource"}, + }, + }, + }, } for name, tc := range cases { @@ -283,3 +324,67 @@ func integrationEnvGap() string { } return "" } + +// TestSimulateNameRejected runs the --name scopes simulate refuses: one +// naming a composite no supplied Composition reaches, and a namespace/name +// combined with --namespace. +func TestSimulateNameRejected(t *testing.T) { + t.Parallel() + + tu.SetupKubeTestLogger(t) + + compCase := IntegrationTestCase{ + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes("testdata/comp/resources/existing-xr-1.yaml", "testdata/comp/resources/existing-downstream-1.yaml"), + composes("testdata/comp/resources/existing-xr-2.yaml", "testdata/comp/resources/existing-downstream-2.yaml"), + }, + } + base := []string{"-f", "testdata/comp/updated-composition.yaml"} + + cases := map[string]struct { + reason string + args []string + wantErr string + }{ + "NamePartiallyUnmatchedFailsBeforeRendering": { + reason: "one --name no composite matches fails the run, and no plan is rendered.", + args: append([]string{"--name=default/test-resource", "--name=default/missing"}, base...), + wantErr: "resource ref(s) not relevant to any supplied composition: [default/missing]", + }, + "NameQualifiedWithNamespaceFlagRejected": { + reason: "namespace/name with --namespace is ambiguous and is rejected.", + args: append([]string{"--namespace=default", "--name=default/test-resource"}, base...), + wantErr: "can't be combined with --namespace", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + cfg := startIntegrationCluster(t, CompositionDiffTest, compCase, defaultTimeout).cfg + + var stdout, stderr bytes.Buffer + argv := append([]string{"project", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) + code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("\n%s\nsimulate: want error containing %q, got %v (exit %d)", tc.reason, tc.wantErr, err, code) + } + if code != dp.ExitCodeToolError { + t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, dp.ExitCodeToolError, code) + } + if stdout.Len() != 0 { + t.Errorf("\n%s\nsimulate: want no plan output, got:\n%s", tc.reason, stdout.String()) + } + }) + } +} diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index e191a4c7..69f340e7 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -106,19 +106,12 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA // current simulate surface, or "" when they can. The runner calls it // before starting envtest, so a skipped case costs nothing, and // runSimulate never executes args it could not express. -func simulateGap(_ DiffTestType, a *simArgs) string { - // simulate scopes to one composite resource with --name (with - // --namespace for a namespaced one); it cannot express the repeated - // or comma-separated --resource scoping these cases need. - if len(a.Resource) > 1 { - return "gap: simulate has no repeated --resource scoping (single --name only)" - } - +func simulateGap(_ DiffTestType, _ *simArgs) string { return "" } // simulateArgv translates the parsed crossplane-diff args into project -// simulate's: the input files as -f, a single --resource as --name (and +// simulate's: the input files as -f, each --resource as a --name (and // --namespace), --include-manual as --include-pinned, and // --analyze-unchanged as its replacement --analyze-on=always. The output // is passed explicitly: crossplane-diff's default diff, since simulate @@ -136,15 +129,14 @@ func simulateArgv(t *testing.T, a *simArgs) []string { argv = append(argv, "-f", f) } - ns := a.Namespace - if len(a.Resource) == 1 { - r, err := ref.Parse(a.Resource[0]) + for _, v := range a.Resource { + r, err := ref.Parse(v) if err != nil { - t.Fatalf("cannot parse --resource %q: %v", a.Resource[0], err) + t.Fatalf("cannot parse --resource %q: %v", v, err) } - ns = r.Namespace - argv = append(argv, "--name="+r.Name) + argv = append(argv, "--name="+ref.Format(r)) } + ns := a.Namespace if ns != "" { argv = append(argv, "--namespace="+ns) } From b06d6ca7a0c3688e52690b0e20ece6051b7e90db Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 11:37:03 -0400 Subject: [PATCH 092/126] feat(diff): render pinned XRs against their revision when it calls a changed function When ChangedFunctions is set and IncludeManual is not, a Manual XR pinned to a revision whose pipeline calls a changed function is kept instead of filtered as manual_policy, rendered against that revision, and reported with Pinned=true and the new XRImpact.RenderedRevision. A failed revision lookup is that XR's own error. Other pinned XRs stay deferred. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/comp_processor.go | 126 ++++++++++++++-- .../diff/diffprocessor/comp_processor_test.go | 141 +++++++++++++++++- internal/diff/result/comp.go | 4 + 3 files changed, 256 insertions(+), 15 deletions(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 2a1294e4..30128471 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -27,6 +27,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" @@ -426,6 +427,21 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, result.RevisionImpact.RepointedComposites = len(repointingXRs) } + // A composition whose pipeline calls a changed function renders differently without creating a + // revision, so it is analysed as at least a metadata-scope change: any-change analyses it, + // spec-change still skips it. Only the decision is raised; RevisionImpact still reports the real scope. + analysisScope := comparison.scope + if analysisScope == ChangeScopeNone && callsChangedFunction(newComp, p.config.ChangedFunctions) { + analysisScope = ChangeScopeMetadata + } + + // A Manual XR pinned to a revision that calls a changed function is not deferred when the analysis + // runs: it is rendered against that revision. See pinnedFunctionChangeXRs. + var pinnedXRs map[string]pinnedResolution + if analysisScope.triggersAnalysis(p.config.AnalyzeOn) { + pinnedXRs, keptXRs, droppedXRs = p.pinnedFunctionChangeXRs(ctx, keptXRs, droppedXRs) + } + counts := countFilterReasons(droppedXRs) p.config.Logger.Debug("Filtered XRs by deletion state, update policy, and revision selector", @@ -472,15 +488,6 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // or a modeling artifact of this tool — which we cannot tell apart, so reporting it as this // composition's "impact" would attribute cluster state to a change that does not exist. // See issues #453 and #472. - // - // A composition whose pipeline calls a changed function renders differently without creating a - // revision, so it is analysed as at least a metadata-scope change: any-change analyses it, - // spec-change still skips it. Only the decision is raised; RevisionImpact still reports the real scope. - analysisScope := comparison.scope - if analysisScope == ChangeScopeNone && callsChangedFunction(newComp, p.config.ChangedFunctions) { - analysisScope = ChangeScopeMetadata - } - if !analysisScope.triggersAnalysis(p.config.AnalyzeOn) { p.config.Logger.Debug("Skipping impact analysis", "composition", newComp.GetName(), @@ -522,7 +529,7 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // Process kept XRs and collect diffs to determine which ones have changes p.config.Logger.Debug("Processing XRs to collect diff information", "count", len(keptXRs)) - xrResults := p.collectXRDiffs(ctx, keptXRs, newComp, revisionName, predictedRevisionLabels(newComp, pred)) + xrResults := p.collectXRDiffs(ctx, keptXRs, newComp, revisionName, predictedRevisionLabels(newComp, pred), pinnedXRs) // Build impact analysis and counts from results for the kept set, then merge in any // already-appended filtered entries. @@ -530,7 +537,12 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // buildImpactAnalysis keeps keptXRs' order. A kept composite that does not re-point is one // --include-manual kept although its Manual policy pins it. for i, xr := range keptXRs { - keptImpacts[i].Pinned = !repointingXRs[dt.MakeDiffKeyFromResource(xr)] + key := dt.MakeDiffKeyFromResource(xr) + keptImpacts[i].Pinned = !repointingXRs[key] + + if pinned, ok := pinnedXRs[key]; ok && pinned.err == nil { + keptImpacts[i].RenderedRevision = pinned.resolved.RevisionName + } } result.ImpactAnalysis = append(result.ImpactAnalysis, keptImpacts...) @@ -547,7 +559,10 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // revisionName is the name of the CompositionRevision applying newComp would leave its composites on, // or empty when that is not predictable; revisionLabels is the label set that revision would carry. See // cliCompositionRevision for which composites are rendered with that name. -func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un.Unstructured, newComp *un.Unstructured, revisionName string, revisionLabels map[string]string) map[string]*XRDiffResult { +// +// pinned, keyed by dt.MakeDiffKeyFromResource, holds the composites rendered against their own pinned +// revision instead of newComp; see pinnedFunctionChangeXRs. +func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un.Unstructured, newComp *un.Unstructured, revisionName string, revisionLabels map[string]string, pinned map[string]pinnedResolution) map[string]*XRDiffResult { // Convert the CLI composition to typed once for reuse cliComp := &apiextensionsv1.Composition{} if err := runtime.DefaultUnstructuredConverter.FromUnstructured(newComp.Object, cliComp); err != nil { @@ -602,6 +617,14 @@ func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un // Check 1: Is this a root-level resource (XR or Claim supplied as affectedXRs to this composition)? // Root-level resources always use the CLI composition, even claims whose GVK differs from the XR type. key := dt.MakeDiffKeyFromResource(res) + if pin, ok := pinned[key]; ok { + p.config.Logger.Debug("Resource is pinned to a revision calling a changed function, using that revision", + "resource", resourceID, + "revision", pin.resolved.RevisionName) + + return pin.resolved, nil + } + if rootResourceKeys[key] { p.config.Logger.Debug("Resource is root-level (uses this composition), using CLI composition", "resource", resourceID, @@ -636,6 +659,15 @@ func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un for _, xr := range xrs { resourceID := dt.MakeDiffKeyFromResource(xr) + if pin, ok := pinned[resourceID]; ok && pin.err != nil { + results[resourceID] = &XRDiffResult{ + Diffs: make(map[string]*dt.ResourceDiff), + Error: errors.Wrapf(pin.err, "unable to process resource %s", resourceID), + } + + continue + } + xrCtx, cancel := p.config.xrContext(ctx) diffs, err := p.xrProc.DiffSingleResource(xrCtx, xr, compositionProvider) err = p.config.afterXR(ctx, xrCtx, diffs, err) @@ -660,6 +692,76 @@ func (p *DefaultCompDiffProcessor) collectXRDiffs(ctx context.Context, xrs []*un return results } +// pinnedResolution is the composition a Manual XR is pinned to, or why it could not be resolved. +type pinnedResolution struct { + resolved dtypes.ResolvedComposition + err error +} + +// pinnedFunctionChangeXRs moves out of dropped the Manual XRs that stay pinned to a revision whose +// pipeline calls a changed function. Such an XR is not unaffected by applying the project: the revision +// it runs is unchanged, but the function it calls is not, so it is kept and rendered against that +// revision instead of the diffed composition. It returns those XRs' resolutions (keyed by +// dt.MakeDiffKeyFromResource), kept with the moved XRs appended, and dropped without them. +// +// It does nothing when no function changed or under IncludeManual, which keeps Manual XRs and renders +// them against the diffed composition. A Manual XR whose revision calls no changed function stays dropped. +// A failed lookup is that XR's own error (a kept XR whose result is the error), not the run's. +func (p *DefaultCompDiffProcessor) pinnedFunctionChangeXRs(ctx context.Context, kept []*un.Unstructured, dropped []filteredXR) (map[string]pinnedResolution, []*un.Unstructured, []filteredXR) { + if len(p.config.ChangedFunctions) == 0 || p.config.IncludeManual { + return nil, kept, dropped + } + + var ( + pinned map[string]pinnedResolution + remaining []filteredXR + ) + + for _, d := range dropped { + if d.reason != dt.FilterReasonManualPolicy { + remaining = append(remaining, d) + continue + } + + resolved, err := p.compositionClient.FindMatchingComposition(ctx, d.xr) + if err == nil && !pinnedRevisionCallsChangedFunction(d.xr, resolved, p.config.ChangedFunctions) { + remaining = append(remaining, d) + continue + } + + if pinned == nil { + pinned = make(map[string]pinnedResolution) + } + + pinned[dt.MakeDiffKeyFromResource(d.xr)] = pinnedResolution{resolved: resolved, err: errors.Wrapf(err, "cannot resolve the revision %s is pinned to", d.xr.GetName())} + kept = append(kept, d.xr) + } + + return pinned, kept, remaining +} + +// pinnedRevisionCallsChangedFunction reports whether resolved is the pinned revision of xr and calls a +// changed function. A resolution with no revision name (the composition itself) or for another type +// (a claim, whose backing XR the render looks up under a different key) is not. +// ponytail: a pinned claim stays deferred; route the backing XR's key to support it. +func pinnedRevisionCallsChangedFunction(xr *un.Unstructured, resolved dtypes.ResolvedComposition, changed map[string]bool) bool { + if resolved.Composition == nil || resolved.RevisionName == "" { + return false + } + + // Group and kind only: the XR may use a served version that is not the referenceable one. + ref := resolved.Composition.Spec.CompositeTypeRef + + refGV, err := schema.ParseGroupVersion(ref.APIVersion) + if err != nil || refGV.Group != xr.GroupVersionKind().Group || ref.Kind != xr.GetKind() { + return false + } + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(resolved.Composition) + + return err == nil && callsChangedFunction(&un.Unstructured{Object: obj}, changed) +} + // cliCompositionRevision resolves a composite that is rendered against cliComp, the diffed composition. // Its revision is the one applying that composition leaves it on, named revisionName, for a composite // that would re-point at it as classifyXR decides; revisionLabels is the label set that revision would diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index 9d68bc4a..b0ee5ec3 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -1253,7 +1253,7 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_RevisionName(t *testing.T) { } comp := tu.NewComposition("comp").WithCompositeTypeRef("example.org/v1", "XResource").WithPipelineMode().BuildAsUnstructured() - results := processor.collectXRDiffs(t.Context(), []*un.Unstructured{tt.xr}, comp, tt.revisionName, revisionLabels) + results := processor.collectXRDiffs(t.Context(), []*un.Unstructured{tt.xr}, comp, tt.revisionName, revisionLabels, nil) if err := results[dt.MakeDiffKeyFromResource(tt.xr)].Error; err != nil { t.Fatalf("%s\ncollectXRDiffs(): unexpected error: %v", tt.reason, err) @@ -1310,7 +1310,7 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_PerXR(t *testing.T) { } comp := tu.NewComposition("comp").WithCompositeTypeRef("example.org/v1", "XResource").WithPipelineMode().BuildAsUnstructured() - results := processor.collectXRDiffs(t.Context(), []*un.Unstructured{slow, fast, failing}, comp, "", nil) + results := processor.collectXRDiffs(t.Context(), []*un.Unstructured{slow, fast, failing}, comp, "", nil, nil) got := map[string]string{} for _, xr := range []*un.Unstructured{slow, fast, failing} { @@ -1648,7 +1648,7 @@ func TestDefaultCompDiffProcessor_collectXRDiffs_NestedXRCompositionLookup(t *te }, } - _ = processor.collectXRDiffs(ctx, tt.xrs, tt.cliComposition, "", nil) + _ = processor.collectXRDiffs(ctx, tt.xrs, tt.cliComposition, "", nil, nil) // Verify the composition requests if len(*compositionRequests) < 2 { @@ -2300,3 +2300,138 @@ func TestDefaultCompDiffProcessor_processSingleComposition_ChangedFunctions(t *t }) } } + +// A Manual XR pinned to a revision whose pipeline calls a changed function is rendered against that +// revision rather than deferred, unless --include-pinned keeps it for the revision-advance preview, and +// a revision calling no changed function stays deferred. +func TestDefaultCompDiffProcessor_processSingleComposition_PinnedXRWithChangedFunction(t *testing.T) { + cliBuilder := func() *tu.CompositionBuilder { + return tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step-a", "function-a", nil) + } + + pinnedXR := func(name string) *un.Unstructured { + return tu.NewResource("example.org/v1", "XR", name). + InNamespace("ns"). + WithSpecField("compositionRef", map[string]any{"name": "test-comp"}). + WithSpecField("crossplane", map[string]any{ + "compositionUpdatePolicy": "Manual", + "compositionRevisionRef": map[string]any{"name": "test-comp-rev1"}, + }). + Build() + } + + // The pinned revision's pipeline has a step the CLI composition lacks, so a render that saw the + // CLI composition instead is detectable. + revisionWith := func(fn string) *apiextensionsv1.Composition { + return tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step-old", fn, nil). + Build() + } + + type want struct { + Status dt.XRStatus + FilterReason dt.FilterReason + Pinned bool + RenderedRevision string + // Pipeline is the function names of the composition the render was given; nil when no render. + Pipeline []string + Error bool + } + + cases := map[string]struct { + revision *apiextensionsv1.Composition + lookupErr error + includeManual bool + want want + }{ + "PinnedXRWithChangedFunctionRendersPinnedRevision": { + revision: revisionWith("function-a"), + want: want{Status: dt.XRStatusUnchanged, Pinned: true, RenderedRevision: "test-comp-rev1", Pipeline: []string{"function-a"}}, + }, + "PinnedXRWithUnrelatedFunctionChangeStaysDeferred": { + revision: revisionWith("function-other"), + want: want{Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonManualPolicy}, + }, + "IncludePinnedWithChangedFunctionPreviewsAdvance": { + revision: revisionWith("function-a"), + includeManual: true, + want: want{Status: dt.XRStatusUnchanged, Pinned: true, Pipeline: []string{"function-a"}}, + }, + "PinnedRevisionLookupErrorIsPerXRError": { + lookupErr: errors.New("revision gone"), + want: want{Status: dt.XRStatusError, Pinned: true, Error: true}, + }, + } + + for name, tt := range cases { + t.Run(name, func(t *testing.T) { + client := tu.NewMockCompositionClient(). + WithSuccessfulCompositionFetch(cliBuilder().Build()). + WithFindMatchingComposition(func(context.Context, *un.Unstructured) (types.ResolvedComposition, error) { + if tt.lookupErr != nil { + return types.ResolvedComposition{}, tt.lookupErr + } + + return types.ResolvedComposition{Composition: tt.revision, RevisionName: "test-comp-rev1"}, nil + }). + Build() + + proc := newCompProcessorForTest(t, client, tt.includeManual) + WithChangedFunctions("function-a")(&proc.config) + + var ( + gotPipeline []string + gotRevision string + ) + + proc.xrProc = &tu.MockDiffProcessor{ + DiffSingleResourceFn: func(ctx context.Context, xr *un.Unstructured, provider types.CompositionProvider) (map[string]*dt.ResourceDiff, error) { + resolved, err := provider(ctx, xr) + if err != nil { + return nil, err + } + + gotRevision = resolved.RevisionName + for _, step := range resolved.Composition.Spec.Pipeline { + gotPipeline = append(gotPipeline, step.FunctionRef.Name) + } + + return map[string]*dt.ResourceDiff{}, nil + }, + } + + got, err := proc.processSingleComposition(t.Context(), cliBuilder().BuildAsUnstructured(), []*un.Unstructured{pinnedXR("pinned")}, false) + if err != nil { + t.Fatalf("processSingleComposition: %v", err) + } + + if len(got.ImpactAnalysis) != 1 { + t.Fatalf("ImpactAnalysis: got %d entries, want 1 (%+v)", len(got.ImpactAnalysis), got.ImpactAnalysis) + } + + impact := got.ImpactAnalysis[0] + gotWant := want{ + Status: impact.Status, + FilterReason: impact.FilterReason, + Pinned: impact.Pinned, + RenderedRevision: impact.RenderedRevision, + Pipeline: gotPipeline, + Error: impact.Error != nil, + } + + // A lookup failure is reported on the XR, and no render is attempted for it. + if diff := gcmp.Diff(tt.want, gotWant); diff != "" { + t.Errorf("processSingleComposition() mismatch (-want +got):\n%s", diff) + } + + if tt.want.RenderedRevision != "" && gotRevision != tt.want.RenderedRevision { + t.Errorf("render revision: got %q, want %q", gotRevision, tt.want.RenderedRevision) + } + }) + } +} diff --git a/internal/diff/result/comp.go b/internal/diff/result/comp.go index ad94cf74..8419a110 100644 --- a/internal/diff/result/comp.go +++ b/internal/diff/result/comp.go @@ -237,6 +237,10 @@ type XRImpact struct { // because its Manual compositionUpdatePolicy pins it to its compositionRevisionRef. Only // --include-manual evaluates such a composite; without it, the composite is XRStatusFiltered. Pinned bool + // RenderedRevision names the CompositionRevision a Pinned composite was rendered against, when that + // is its own pinned revision rather than the diffed composition: the revision calls a function that + // changed. Empty otherwise. + RenderedRevision string // Error is the structured processing error for an XRStatusError impact (nil otherwise), the same // OutputError the command lists in its top-level errors. Error *OutputError From 4c98f337c2a36b69f77938ace9742918ca44e8ea Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 12:18:00 -0400 Subject: [PATCH 093/126] feat(simulate): report pinned XRs rendered against their revision Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 15 ++- internal/diff/integration/plan_output_test.go | 113 ++++++++++++++++++ .../diff/integration/simulate_harness_test.go | 25 +++- ...-downstream-pinned-revision-ref-stale.yaml | 14 +++ internal/plan/output.go | 4 +- internal/plan/output_test.go | 18 +++ internal/simulate/report.go | 7 +- internal/simulate/report_test.go | 14 +++ 8 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref-stale.yaml diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index fa8cd8c4..c75218c2 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -143,6 +143,19 @@ type SimulateHooks struct { // Router, when set, routes provider plans through it instead of starting // diff servers. Router simulate.Router + + // ChangedFunctions marks these functions as changed in any mode, as + // project mode's digest detection would. Test-only. + ChangedFunctions []string +} + +// hookChangedFunctions returns the functions a test marked as changed. +func (c *simulateCmd) hookChangedFunctions() []string { + if c.Hooks == nil { + return nil + } + + return c.Hooks.ChangedFunctions } // FunctionCredentials holds Secret credentials loaded from a file path. It @@ -282,7 +295,7 @@ func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger loggin return err } - opts := append(c.processorOptions(k, logger, fns), dp.WithChangedFunctions(changed...), dp.WithXRTimeout(c.Timeout)) + opts := append(c.processorOptions(k, logger, fns), dp.WithChangedFunctions(append(changed, c.hookChangedFunctions()...)...), dp.WithXRTimeout(c.Timeout)) // xrPlanner and servers are for rendering the plans; they stay nil with // --skip-plan, and servers also when Hooks.Router supplies the router. diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index cdc4dbda..73553a4b 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -22,6 +22,7 @@ import ( "bytes" "encoding/json" "os" + "slices" "strings" "testing" @@ -328,6 +329,118 @@ func integrationEnvGap() string { // TestSimulateNameRejected runs the --name scopes simulate refuses: one // naming a composite no supplied Composition reaches, and a namespace/name // combined with --namespace. +// TestSimulatePinnedChangedFunction runs a Composition simulate whose Manual +// XR is pinned to a revision. Marking a function that revision calls as changed +// renders the XR against that revision; any other function leaves it deferred. +// The Hooks seam stands in for project mode's digest detection. +func TestSimulatePinnedChangedFunction(t *testing.T) { + t.Parallel() + + tu.SetupKubeTestLogger(t) + + tt := IntegrationTestCase{ + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/comp/resources/revision-templating-composition-older-revision.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-pinned-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-pinned-revision-ref-stale.yaml", + ), + }, + } + const rev = "xrevisionrefs.diff.example.org-0d65896" + + cases := map[string]struct { + reason string + changed []string + // wantDeferred is whether the XR stays deferred; otherwise it is + // rendered against its pinned revision. + wantDeferred bool + }{ + "PinnedXRWithChangedFunctionRendersPinnedRevision": { + reason: "A Manual XR whose pinned revision calls a changed function is rendered against that revision, not deferred.", + changed: []string{"function-go-templating"}, + }, + "PinnedXRWithUnchangedFunctionDeferred": { + reason: "A Manual XR whose pinned revision calls no changed function stays deferred.", + changed: []string{"function-extra-resources"}, + wantDeferred: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + cfg := startIntegrationCluster(t, CompositionDiffTest, tt, defaultTimeout).cfg + base := []string{"project", "simulate", "--timeout=" + defaultTimeout.String(), "-f", "testdata/comp/revision-templating-updated-composition.yaml", "-n", "default"} + + var stdout, stderr bytes.Buffer + code, err := runSimulateArgvHooked(t, cfg, append(slices.Clip(base), "-o", "json"), tc.changed, &stdout, &stderr) + if err != nil { + t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) + } + var res plan.Result + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("\n%s\nsimulate -o json: output is not a plan.Result: %v\n%s", tc.reason, err, stdout.String()) + } + if len(res.Composites) != 1 { + t.Fatalf("\n%s\nsimulate -o json: want 1 composite, got %+v", tc.reason, res.Composites) + } + c := res.Composites[0] + + if tc.wantDeferred { + if c.Status != plan.CompositeDeferred || strings.HasPrefix(c.Detail, "rendered against") { + t.Errorf("\n%s\nsimulate -o json: want a deferred pinned composite, got %+v", tc.reason, c) + } + return + } + + if code != dp.ExitCodeDiffDetected { + t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, dp.ExitCodeDiffDetected, code) + } + if c.Status != plan.CompositeChanged || !c.Pinned || c.Detail != "rendered against pinned revision "+rev+" with the project's functions" { + t.Errorf("\n%s\nsimulate -o json: want a changed pinned composite rendered against %s, got %+v", tc.reason, rev, c) + } + // The plan is rendered from the pinned revision's pipeline: its + // configData is the pinned revision's name, which the current + // composition's predicted revision name would not be. + var found bool + for _, p := range res.Plans { + for _, ch := range p.Changes { + if ch.Path == "spec.forProvider.configData" && ch.Planned != nil && ch.Planned.Value == rev { + found = true + } + } + } + if !found { + t.Errorf("\n%s\nsimulate -o json: want a configData change to %q, got plans %+v", tc.reason, rev, res.Plans) + } + + var text bytes.Buffer + if _, err := runSimulateArgvHooked(t, cfg, base, tc.changed, &text, &stderr); err != nil { + t.Fatalf("simulate -o plan: unexpected error: %v", err) + } + if os.Getenv("SIMULATE_DEMO") != "" { + j, _ := json.MarshalIndent(c, "", " ") + t.Logf("-o plan:\n%s\n-o json composite:\n%s", text.String(), j) + } + if !strings.Contains(text.String(), "rendered against pinned revision "+rev) || strings.Contains(text.String(), "(deferred)") { + t.Errorf("\n%s\nsimulate -o plan: want the rendered-against note and no deferral, got:\n%s", tc.reason, text.String()) + } + }) + } +} + func TestSimulateNameRejected(t *testing.T) { t.Parallel() diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 69f340e7..0d1c9ab9 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -195,6 +195,22 @@ func runSimulateArgv(t *testing.T, cfg *rest.Config, argv []string, renderBinary func runSimulateArgvRouted(t *testing.T, cfg *rest.Config, argv []string, renderBinary string, router simulate.Router, stdout, stderr io.Writer) (int, error) { t.Helper() + return runSimulateArgvWith(t, cfg, argv, &project.SimulateHooks{RenderBinary: renderBinary, Router: router}, stdout, stderr) +} + +// runSimulateArgvHooked is runSimulateArgv with changed marked as changed +// functions, as project mode's digest detection would. +func runSimulateArgvHooked(t *testing.T, cfg *rest.Config, argv, changed []string, stdout, stderr io.Writer) (int, error) { + t.Helper() + + return runSimulateArgvWith(t, cfg, argv, &project.SimulateHooks{RenderBinary: localCrossplaneBinary(t), ChangedFunctions: changed}, stdout, stderr) +} + +// runSimulateArgvWith runs `crossplane project simulate` with argv against +// cfg and hooks, whose RESTConfig and Terminal it sets. +func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *project.SimulateHooks, stdout, stderr io.Writer) (int, error) { + t.Helper() + var cli struct { Project project.Cmd `cmd:""` } @@ -222,12 +238,9 @@ func runSimulateArgvRouted(t *testing.T, cfg *rest.Config, argv []string, render // Inject the case's cluster in place of the kubeconfig, and render the // way crossplane-diff's tests did: with the local crossplane binary // when there is one, and colorized unless --no-color. - cli.Project.Simulate.Hooks = &project.SimulateHooks{ - RESTConfig: cfg, - RenderBinary: renderBinary, - Terminal: true, - Router: router, - } + hooks.RESTConfig = cfg + hooks.Terminal = true + cli.Project.Simulate.Hooks = hooks err = kctx.Run() // Exit as main does: print an error the way FatalIfErrorf does, the only diff --git a/internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref-stale.yaml b/internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref-stale.yaml new file mode 100644 index 00000000..44ba3af5 --- /dev/null +++ b/internal/diff/integration/testdata/comp/resources/existing-downstream-pinned-revision-ref-stale.yaml @@ -0,0 +1,14 @@ +# The composed resource of existing-xr-pinned-revision-ref.yaml with a configData that is neither its pinned +# revision's name nor the predicted revision's, so a render of the pinned revision shows a visible change +# to that revision's name. See existing-downstream-pinned-revision-ref.yaml for the annotation. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: pinned-revision-ref-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stale-value + resourceTier: basic diff --git a/internal/plan/output.go b/internal/plan/output.go index 064cd5d5..04756173 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -346,11 +346,11 @@ func renderComposite(w io.Writer, c CompositeResult, ref ResourceRef, plans []*R } _, _ = fmt.Fprintf(w, " %s; apply changes nothing until the\n", pinned) _, _ = fmt.Fprintln(w, " revision advances. Rerun with --include-pinned to preview that moment.") - case CompositeSkipped: + case CompositeSkipped, CompositeChanged, CompositeUnchanged: if c.Detail != "" { _, _ = fmt.Fprintf(w, " %s\n", c.Detail) } - case CompositeChanged, CompositeUnchanged, CompositeError: + case CompositeError: } var block strings.Builder for _, p := range plans { diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 01320853..24ef787d 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -276,6 +276,24 @@ Plan: 0 to add, 1 to change, 1 to destroy. } } +func TestRenderPlanCompositeDetail(t *testing.T) { + xr := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} + r := Result{Composites: []CompositeResult{{Ref: xr, Status: CompositeUnchanged, Pinned: true, Detail: "rendered against pinned revision net-6 with the project's functions"}}} + want := `XR: XNetwork.example.org prod-network + rendered against pinned revision net-6 with the project's functions + +Plan: 0 to add, 0 to change, 0 to destroy. +` + var buf bytes.Buffer + r2, _ := NewRenderer(FormatPlan) + if err := r2.Render(&buf, r); err != nil { + t.Fatalf("Render(plan): unexpected error: %v", err) + } + if diff := cmp.Diff(want, buf.String()); diff != "" { + t.Errorf("Render(plan): -want, +got:\n%s", diff) + } +} + func TestRenderPlanCompositions(t *testing.T) { comp := ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"} same := ResourceRef{APIVersion: comp.APIVersion, Kind: comp.Kind, Name: "same"} diff --git a/internal/simulate/report.go b/internal/simulate/report.go index bbc2362a..225241c8 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -160,10 +160,15 @@ func FromCompReport(o *dt.CompDiffOutput, plans map[string]*plan.ResourcePlan) p // compositeResult maps an affected composite's impact onto its status. A // Manual-policy composite is deferred: applying changes nothing for it until -// its revision advances. Other filtered composites are skipped. +// its revision advances, unless it was rendered against its pinned revision +// because that calls a changed function. Other filtered composites are skipped. func compositeResult(impact dt.XRImpact) plan.CompositeResult { c := plan.CompositeResult{Ref: resourceRef(impact.ObjectReference), Pinned: impact.Pinned} + if impact.Pinned && impact.RenderedRevision != "" { + c.Detail = "rendered against pinned revision " + impact.RenderedRevision + " with the project's functions" + } + switch impact.Status { case dt.XRStatusChanged: c.Status = plan.CompositeChanged diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index a91d1ccf..cedfdf19 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -391,6 +391,20 @@ func TestFromCompReport(t *testing.T) { Notes: notes(), }, }, + "PinnedRenderedRevision": { + reason: "A pinned XR rendered against its revision maps to its changed status, with a detail naming the revision, not deferred.", + in: composition(dt.CompositionDiff{ + ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusChanged, Pinned: true, RenderedRevision: "net-6"}}, + }), + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{NoOp: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged, Composition: &compPlan, Pinned: true, Detail: "rendered against pinned revision net-6 with the project's functions"}}, + Compositions: []plan.CompositionResult{{Ref: compPlan}}, + Plans: []*plan.ResourcePlan{compNoOp}, + Notes: notes(), + }, + }, "AnalysisSkippedMaskedOnly": { reason: "A masked-only Composition is a no-op plan; a skipped analysis is flagged on its Compositions entry.", in: composition(dt.CompositionDiff{ From e49fc9c2707ebbc6eeca8d8d936696ae9980b1bf Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 13:26:56 -0400 Subject: [PATCH 094/126] fix(simulate): keep cluster-only changes beside a provider plan's Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/report.go | 22 +++++++++++++++++++++- internal/simulate/report_test.go | 19 +++++++++++++++---- 2 files changed, 36 insertions(+), 5 deletions(-) diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 225241c8..fc1f825c 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -280,7 +280,7 @@ func merge(d *dt.ResourceDiff, p, pp *plan.ResourcePlan) *plan.ResourcePlan { p.FallbackReason = pp.FallbackReason case pp.Action == plan.ActionCreate, pp.Action == plan.ActionUpdate, pp.Action == plan.ActionReplace: p.Action = pp.Action - p.Changes = pp.Changes + p.Changes = unionChanges(pp.Changes, p.Changes) p.ReplacePaths = pp.ReplacePaths } @@ -293,6 +293,26 @@ func merge(d *dt.ResourceDiff, p, pp *plan.ResourcePlan) *plan.ResourcePlan { return p } +// unionChanges returns the provider's changes plus each engine change the +// provider did not report. The provider only diffs the external resource's +// parameters, so cluster-only changes (deletionPolicy, labels) come from the +// engine. A provider change at or under an engine change's path, or the +// reverse, reports it. +func unionChanges(provider, engine []plan.FieldChange) []plan.FieldChange { + out := slices.Clone(provider) + for _, e := range engine { + if !slices.ContainsFunc(provider, func(c plan.FieldChange) bool { return pathCovers(c.Path, e.Path) || pathCovers(e.Path, c.Path) }) { + out = append(out, e) + } + } + return out +} + +// pathCovers reports whether child is parent or lies under it. +func pathCovers(parent, child string) bool { + return child == parent || strings.HasPrefix(child, parent+".") || strings.HasPrefix(child, parent+"[") +} + // addError records e, failing the plan for ref. composite is the composite // the plan groups under, nil for a Composition. func (a *assembler) addError(ref plan.ResourceRef, e dt.OutputError, composite *plan.ResourceRef) { diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index cedfdf19..c8a56e0c 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -508,6 +508,10 @@ func TestProviderPlanMerge(t *testing.T) { equal := rdiff(cmGVK, "cm", dt.DiffTypeEqual, nil, nil) key := added.GetDiffKey() + policy := rdiff(cmGVK, "cm", dt.DiffTypeModified, + map[string]any{"spec": map[string]any{"deletionPolicy": "Orphan", "forProvider": map[string]any{"size": "1"}}}, + map[string]any{"spec": map[string]any{"deletionPolicy": "Delete", "forProvider": map[string]any{"size": "2"}}}) + policyChange := plan.FieldChange{Path: "spec.deletionPolicy", Actual: &plan.Value{Value: "Orphan"}, Planned: &plan.Value{Value: "Delete"}} labelChange := []plan.FieldChange{{Path: "metadata.labels.a", Actual: &plan.Value{Value: "1"}, Planned: &plan.Value{Value: "2"}}} providerChange := []plan.FieldChange{{Path: "spec.forProvider.size", Actual: &plan.Value{Value: "1"}, Planned: &plan.Value{Value: "2"}}} forbidden := &plan.Verification{SkipReason: "forbidden", Detail: "no create"} @@ -549,23 +553,30 @@ func TestProviderPlanMerge(t *testing.T) { wantStatus: plan.CompositeChanged, }, "AddedProviderCreate": { - reason: "A provider create replaces the engine's changes.", + reason: "A provider create keeps the engine's changes it does not report.", diff: added, plans: map[string]*plan.ResourcePlan{key: {Action: plan.ActionCreate, Changes: providerChange}}, - wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionCreate, Changes: providerChange})}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionCreate, Changes: append(slices.Clone(providerChange), plan.FieldChange{Path: "data.k", Planned: &plan.Value{Value: "v"}})})}, wantStatus: plan.CompositeChanged, }, "ModifiedProviderReplace": { - reason: "A provider replace takes the action, changes and replace paths, keeping the engine's DryRun.", + reason: "A provider replace takes the action and replace paths, adds the engine's other changes, keeping the engine's DryRun.", diff: modified, plans: map[string]*plan.ResourcePlan{key: { Action: plan.ActionReplace, Changes: providerChange, ReplacePaths: []string{"spec.forProvider.size"}, }}, wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{ - Action: plan.ActionReplace, Changes: providerChange, ReplacePaths: []string{"spec.forProvider.size"}, DryRun: forbidden, + Action: plan.ActionReplace, Changes: append(slices.Clone(providerChange), labelChange...), ReplacePaths: []string{"spec.forProvider.size"}, DryRun: forbidden, })}, wantStatus: plan.CompositeChanged, }, + "ModifiedProviderUpdateKeepsClusterChange": { + reason: "A deletionPolicy change the provider does not report stays beside its update; the forProvider change it does report is listed once.", + diff: policy, + plans: map[string]*plan.ResourcePlan{key: {Action: plan.ActionUpdate, Changes: providerChange}}, + wantPlans: []*plan.ResourcePlan{cm(plan.ResourcePlan{Action: plan.ActionUpdate, Changes: append(slices.Clone(providerChange), policyChange)})}, + wantStatus: plan.CompositeChanged, + }, "ModifiedProviderNoOp": { reason: "The cluster object still changes when the provider finds nothing, so the engine's update stands.", diff: modified, From d7d346a43bd7399488f0246b3a82e8d2a29df83f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 13:26:56 -0400 Subject: [PATCH 095/126] fix(plan): tolerate empty replace paths and mark pinned composites in -o plan Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/plan/output.go | 6 ++++++ internal/plan/output_test.go | 22 ++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/internal/plan/output.go b/internal/plan/output.go index 04756173..86f602b5 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -389,6 +389,9 @@ func compositeHeader(c CompositeResult, ref ResourceRef) string { h += ")" case CompositeChanged, CompositeUnchanged, CompositeError: } + if c.Pinned && c.Detail == "" && c.Status != CompositeDeferred { + h += " (pinned)" + } return h } @@ -569,6 +572,9 @@ func lastSegments(paths []string) []string { out := make([]string, 0, len(paths)) for _, p := range paths { segs := PathSegments(p) + if len(segs) == 0 { + continue + } out = append(out, segs[len(segs)-1]) } return out diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 24ef787d..174a0f47 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -19,6 +19,7 @@ package plan import ( "bytes" "encoding/json" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -294,6 +295,27 @@ Plan: 0 to add, 0 to change, 0 to destroy. } } +func TestRenderPlanPinnedHeader(t *testing.T) { + xr := ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} + var buf bytes.Buffer + r, _ := NewRenderer(FormatPlan) + if err := r.Render(&buf, Result{Composites: []CompositeResult{{Ref: xr, Status: CompositeUnchanged, Pinned: true}}}); err != nil { + t.Fatalf("Render(plan): unexpected error: %v", err) + } + if want := "XR: XNetwork.example.org prod-network (pinned)\n"; !strings.HasPrefix(buf.String(), want) { + t.Errorf("Render(plan): want prefix %q, got %q", want, buf.String()) + } +} + +func TestRenderPlanEmptyReplacePath(t *testing.T) { + plans := []*ResourcePlan{{APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Action: ActionReplace, ReplacePaths: []string{"", "..", "spec.x"}}} + var buf bytes.Buffer + r, _ := NewRenderer(FormatPlan) + if err := r.Render(&buf, Result{Plans: plans}); err != nil { + t.Fatalf("Render(plan): unexpected error: %v", err) + } +} + func TestRenderPlanCompositions(t *testing.T) { comp := ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"} same := ResourceRef{APIVersion: comp.APIVersion, Kind: comp.Kind, Name: "same"} From bc6ebae732583c98b37c86a40db360dcffd4eb8c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 13:26:56 -0400 Subject: [PATCH 096/126] fix(simulate): cache only definite CRD answers, show provider-only replaces in -o diff, stop servers on Ensure error Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/annotate.go | 8 +++++++- internal/simulate/annotate_test.go | 16 ++++++++++++++++ internal/simulate/defaults.go | 24 +++++++++++++++--------- internal/simulate/servers.go | 1 + internal/simulate/servers_test.go | 19 ++++++++++++++----- internal/simulate/xrplan.go | 3 +++ 6 files changed, 56 insertions(+), 15 deletions(-) diff --git a/internal/simulate/annotate.go b/internal/simulate/annotate.go index adc4988d..e015cfe3 100644 --- a/internal/simulate/annotate.go +++ b/internal/simulate/annotate.go @@ -127,7 +127,10 @@ func annotateDiffs(diffs map[string]*dt.ResourceDiff, plans map[string]*plan.Res // " # forces replacement". Other provider changes, such as defaults, show // only in -o plan. func annotateDiff(d *dt.ResourceDiff, pp *plan.ResourcePlan) *dt.ResourceDiff { - if pp == nil || pp.Error != "" || pp.Approximate || len(pp.Changes) == 0 { + if pp == nil || pp.Error != "" || pp.Approximate { + return d + } + if len(pp.Changes) == 0 && (d.DiffType != dt.DiffTypeEqual || !isChange(pp.Action)) { return d } @@ -198,6 +201,9 @@ func driftDiff(d *dt.ResourceDiff, pp *plan.ResourcePlan) *dt.ResourceDiff { lines = append(lines, line{op: opInsert, text: c.Path + ": " + plan.FormatValue(c.Planned) + note}) } } + if len(lines) == 0 && pp.Action == plan.ActionReplace { + lines = append(lines, line{op: opInsert, text: "# replaced by the provider; no field reported"}) + } if len(lines) == 0 { return d } diff --git a/internal/simulate/annotate_test.go b/internal/simulate/annotate_test.go index 84b23923..b190bb1a 100644 --- a/internal/simulate/annotate_test.go +++ b/internal/simulate/annotate_test.go @@ -438,3 +438,19 @@ func cloneDiffs(m map[string]*dt.ResourceDiff) map[string]*dt.ResourceDiff { } return out } + +// TestAnnotateDiffDriftReplaceNoFields pins that a kept-equal resource the +// provider would replace, without reporting a field, still renders. +func TestAnnotateDiffDriftReplaceNoFields(t *testing.T) { + eq := lineDiff(t, "db", dt.DiffTypeEqual, map[string]any{"region": "a"}, map[string]any{"region": "a"}) + got := annotateDiff(eq, planFor(eq, plan.ActionReplace)[eq.GetDiffKey()]) + if got.DiffType != dt.DiffTypeModified { + t.Fatalf("annotateDiff(...).DiffType = %q, want modified", got.DiffType) + } + if text := flatten(got.LineDiffs)[0].text; text != "# replaced by the provider; no field reported" { + t.Errorf("annotateDiff(...) line = %q, want the replace comment", text) + } + if same := annotateDiff(eq, planFor(eq, plan.ActionUpdate)[eq.GetDiffKey()]); same != eq { + t.Error("annotateDiff(...) with an update and no fields changed the diff, want it returned as is") + } +} diff --git a/internal/simulate/defaults.go b/internal/simulate/defaults.go index 0c69f6ed..696688ee 100644 --- a/internal/simulate/defaults.go +++ b/internal/simulate/defaults.go @@ -20,6 +20,7 @@ import ( "context" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" @@ -53,8 +54,12 @@ func (c *crdDefaults) apply(ctx context.Context, kubeClient client.Client, rmapp gvk := desired.GroupVersionKind() crd, ok := c.cache[gvk.GroupKind()] if !ok { - crd = fetchCRD(ctx, kubeClient, rmapper, gvk) - c.cache[gvk.GroupKind()] = crd + var definite bool + crd, definite = fetchCRD(ctx, kubeClient, rmapper, gvk) + // A transient failure is not cached, so a later resource retries. + if definite { + c.cache[gvk.GroupKind()] = crd + } } if crd == nil { return desired @@ -68,21 +73,22 @@ func (c *crdDefaults) apply(ctx context.Context, kubeClient client.Client, rmapp } // fetchCRD fetches the CRD serving gvk. nil means no defaulting is possible: -// the kind is not served or its CRD is unreadable. -func fetchCRD(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, gvk schema.GroupVersionKind) *extv1.CustomResourceDefinition { +// the kind is not served or its CRD is unreadable. definite is false when the +// fetch failed in a way a retry may fix, so the answer must not be cached. +func fetchCRD(ctx context.Context, kubeClient client.Client, rmapper meta.RESTMapper, gvk schema.GroupVersionKind) (crd *extv1.CustomResourceDefinition, definite bool) { mapping, err := rmapper.RESTMapping(gvk.GroupKind(), gvk.Version) if err != nil { - return nil + return nil, meta.IsNoMatchError(err) } u := &unstructured.Unstructured{} u.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) if err := kubeClient.Get(ctx, client.ObjectKey{Name: mapping.Resource.Resource + "." + gvk.Group}, u); err != nil { - return nil + return nil, apierrors.IsNotFound(err) } - crd := &extv1.CustomResourceDefinition{} + crd = &extv1.CustomResourceDefinition{} if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, crd); err != nil { - return nil + return nil, true } - return crd + return crd, true } diff --git a/internal/simulate/servers.go b/internal/simulate/servers.go index f52dcf10..8c6cd9fc 100644 --- a/internal/simulate/servers.go +++ b/internal/simulate/servers.go @@ -182,6 +182,7 @@ func StartDiffServers(ctx context.Context, sp terminal.SpinnerPrinter, log loggi } set := NewServerSet(sp, log, stderr, pkgs, session) if err := set.Ensure(ctx, inputs); err != nil { + set.Stop(ctx) return nil, nil, err } return set, func() { set.Stop(ctx) }, nil diff --git a/internal/simulate/servers_test.go b/internal/simulate/servers_test.go index f6604833..7ebd6d9c 100644 --- a/internal/simulate/servers_test.go +++ b/internal/simulate/servers_test.go @@ -275,14 +275,23 @@ func TestServerSetEnsure(t *testing.T) { } func TestServerSetEnsureDevelopmentKeys(t *testing.T) { - s := NewServerSet(terminal.NewNopSpinnerPrinter(), logging.NewNopLogger(), &bytes.Buffer{}, &failingPkgs{}, "s") + var out bytes.Buffer + s := NewServerSet(terminal.NewNopSpinnerPrinter(), logging.NewNopLogger(), &out, &failingPkgs{}, "s") // Development inputs have no image, so each is keyed by its own name. - _ = s.Ensure(context.Background(), []diffserver.Input{ + inputs := []diffserver.Input{ {Runtime: diffserver.RuntimeDevelopment, Name: "a", DevelopmentTarget: "localhost:1", APIGroups: []string{"a.example.org"}}, {Runtime: diffserver.RuntimeDevelopment, Name: "b", DevelopmentTarget: "localhost:2", APIGroups: []string{"b.example.org"}}, - }) - if len(s.handled) != 2 { - t.Errorf("handled %d Development providers, want 2: %v", len(s.handled), s.handled) + } + _ = s.Ensure(context.Background(), inputs) + for _, n := range []string{"a", "b"} { + if got := strings.Count(out.String(), "Not running a diff server for "+n+":"); got != 1 { + t.Errorf("skip message for %q printed %d times, want 1: %q", n, got, out.String()) + } + } + before := out.String() + _ = s.Ensure(context.Background(), inputs) + if out.String() != before { + t.Errorf("second Ensure printed more: %q", out.String()) } } diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go index 85d98231..d04af646 100644 --- a/internal/simulate/xrplan.go +++ b/internal/simulate/xrplan.go @@ -77,6 +77,9 @@ func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.Resour } // Server start and image pulls are run-wide work, not the XR's. err := diffprocessor.OutsideXRDeadline(ctx, func(run context.Context) error { return x.ensure(run, resources) }) + if errors.Is(err, diffprocessor.ErrXRTimedOut) { + return err + } if err != nil { return errors.Wrap(err, "cannot start provider diff servers") } From 0c6c114764944240440a4bec7f9646319bf1d68e Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 13:31:55 -0400 Subject: [PATCH 097/126] test(simulate): cover dotted Secret keys and CRD fetch caching Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/defaults_test.go | 84 ++++++++++++++++++++++++++++++ internal/simulate/report_test.go | 47 +++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 internal/simulate/defaults_test.go diff --git a/internal/simulate/defaults_test.go b/internal/simulate/defaults_test.go new file mode 100644 index 00000000..b5a4edec --- /dev/null +++ b/internal/simulate/defaults_test.go @@ -0,0 +1,84 @@ +package simulate + +import ( + "context" + "errors" + "testing" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" +) + +func TestCRDDefaultsCaching(t *testing.T) { + ctx := context.Background() + crdGVK := schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"} + sc := runtime.NewScheme() + sc.AddKnownTypeWithName(crdGVK, &unstructured.Unstructured{}) + sc.AddKnownTypeWithName(crdGVK.GroupVersion().WithKind("CustomResourceDefinitionList"), &unstructured.UnstructuredList{}) + + rm := meta.NewDefaultRESTMapper([]schema.GroupVersion{vpcGVK.GroupVersion()}) + rm.Add(vpcGVK, meta.RESTScopeRoot) + + newCRD := func() *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{"name": "vpcs.ec2.aws.upbound.io"}, + "spec": map[string]any{ + "group": "ec2.aws.upbound.io", + "names": map[string]any{"kind": "VPC", "listKind": "VPCList", "plural": "vpcs", "singular": "vpc"}, + "scope": "Cluster", + "versions": []any{map[string]any{ + "name": "v1beta1", "served": true, "storage": true, + "schema": map[string]any{"openAPIV3Schema": map[string]any{ + "type": "object", + "properties": map[string]any{"spec": map[string]any{ + "type": "object", + "properties": map[string]any{"deletionPolicy": map[string]any{"type": "string", "default": "Delete"}}, + }}, + }}, + }}, + }, + }} + } + policy := func(u *unstructured.Unstructured) string { + s, _, _ := unstructured.NestedString(u.Object, "spec", "deletionPolicy") + return s + } + + t.Run("TransientErrorNotCached", func(t *testing.T) { + failing := true + kube := fake.NewClientBuilder().WithScheme(sc).WithObjects(newCRD()). + WithInterceptorFuncs(interceptor.Funcs{Get: func(ctx context.Context, c client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if failing { + return errors.New("throttled") + } + return c.Get(ctx, key, obj, opts...) + }}).Build() + c := newCRDDefaults() + if got := policy(c.apply(ctx, kube, rm, vpc("a", "10.0.0.0/16"))); got != "" { + t.Errorf("apply(...) during the failure: deletionPolicy = %q, want undefaulted", got) + } + failing = false + if got := policy(c.apply(ctx, kube, rm, vpc("a", "10.0.0.0/16"))); got != "Delete" { + t.Errorf("apply(...) after recovery: deletionPolicy = %q, want Delete", got) + } + }) + + t.Run("NotFoundCached", func(t *testing.T) { + kube := fake.NewClientBuilder().WithScheme(sc).Build() + c := newCRDDefaults() + _ = c.apply(ctx, kube, rm, vpc("a", "10.0.0.0/16")) + if err := kube.Create(ctx, newCRD()); err != nil { + t.Fatalf("Create(CRD): %v", err) + } + if got := policy(c.apply(ctx, kube, rm, vpc("a", "10.0.0.0/16"))); got != "" { + t.Errorf("apply(...) after the CRD appeared: deletionPolicy = %q, want the cached NotFound (undefaulted)", got) + } + }) +} diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index c8a56e0c..b80c072f 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -237,6 +237,32 @@ func TestFromXRReport(t *testing.T) { Notes: notes(), }, }, + "SecretRedactionDottedKeys": { + reason: "Keys containing dots are written data[\"k\"] and are redacted too, in changes, before and after.", + in: &dt.XRReport{Groups: []dt.XRDiffGroup{{ + XR: xrRef, + Diffs: diffs(func() *dt.ResourceDiff { + d := rdiff(secGVK, "creds", dt.DiffTypeModified, + map[string]any{"data": map[string]any{"tls.crt": "b2xk", ".dockerconfigjson": "b2xk"}}, + map[string]any{"data": map[string]any{"tls.crt": "bmV3", ".dockerconfigjson": "bmV3"}}) + d.Current.Raw, d.Desired.Raw = d.Current.Clean, d.Desired.Clean + return d + }()), + }}}, + want: plan.Result{ + FormatVersion: plan.ResultFormatVersion, + Summary: plan.Summary{Update: 1}, + Composites: []plan.CompositeResult{{Ref: xrPlan, Status: plan.CompositeChanged}}, + Plans: []*plan.ResourcePlan{{ + APIVersion: "v1", Kind: "Secret", Name: "creds", Action: plan.ActionUpdate, Composite: &xrPlan, + Changes: []plan.FieldChange{ + {Path: `data[".dockerconfigjson"]`, Actual: &plan.Value{Sensitive: true}, Planned: &plan.Value{Sensitive: true}}, + {Path: `data["tls.crt"]`, Actual: &plan.Value{Sensitive: true}, Planned: &plan.Value{Sensitive: true}}, + }, + }}, + Notes: notes(), + }, + }, "Dedupe": { reason: "A change two inputs reach identically is listed once, under the first; a differing render of it is still listed.", in: &dt.XRReport{Groups: []dt.XRDiffGroup{ @@ -707,3 +733,24 @@ func TestPlanObjects(t *testing.T) { } }) } + +// TestSecretDottedKeysRedactedInObjects pins that before and after redact +// Secret keys containing dots. +func TestSecretDottedKeysRedactedInObjects(t *testing.T) { + d := rdiff(secGVK, "creds", dt.DiffTypeModified, + map[string]any{"data": map[string]any{"tls.crt": "b2xk", ".dockerconfigjson": "b2xk"}}, + map[string]any{"data": map[string]any{"tls.crt": "bmV3", ".dockerconfigjson": "bmV3"}}) + got := FromXRReport(&dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xrRef, Diffs: diffs(d)}}}, nil) + for _, p := range got.Plans { + if p.Kind != "Secret" { + continue + } + for _, o := range []*unstructured.Unstructured{p.Before, p.After} { + for _, k := range []string{"tls.crt", ".dockerconfigjson"} { + if v, _, _ := unstructured.NestedString(o.Object, "data", k); v != "(sensitive value)" { + t.Errorf("data[%q] = %q, want redacted", k, v) + } + } + } + } +} From 24d4d1bf87dab41fafe990ae99e83b592f2def12 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 13:57:07 -0400 Subject: [PATCH 098/126] feat(plan)!: always report deferred, name error refs ref, mark every Manual XR pinned Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/plan/output.go | 4 ++-- internal/plan/output_test.go | 7 +++++-- internal/simulate/report.go | 4 ++-- internal/simulate/report_test.go | 10 +++++----- internal/simulate/simtest/plan_assertions.go | 8 ++++---- .../simulate/simtest/structured_assertions_test.go | 6 +++--- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/internal/plan/output.go b/internal/plan/output.go index 86f602b5..015500f3 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -53,7 +53,7 @@ type Summary struct { Errored int `json:"errored"` // Deferred counts composites pinned to a composition revision, which // applying changes nothing for. It does not count as a change. - Deferred int `json:"deferred,omitempty"` + Deferred int `json:"deferred"` } // Summarize counts the given plans. It cannot know about deferred @@ -123,7 +123,7 @@ type CompositeResult struct { // Error is an error from a simulate run, optionally anchored to a resource. type Error struct { - Resource *ResourceRef `json:"resource,omitempty"` + Ref *ResourceRef `json:"ref,omitempty"` Message string `json:"message"` ValidationFailures []ValidationFailure `json:"validationFailures,omitempty"` } diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 174a0f47..3520f742 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -392,7 +392,7 @@ func TestRenderJSONProjectFields(t *testing.T) { DryRun: &Verification{SkipReason: "forbidden", Detail: "no create permission"}, }}, Errors: []Error{{ - Resource: &xr, Message: "invalid", + Ref: &xr, Message: "invalid", ValidationFailures: []ValidationFailure{{ APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Status: "invalid", Errors: []FieldValidationError{{Type: "schema", Field: "data", Message: "bad"}}, @@ -420,9 +420,12 @@ func TestRenderJSONOmitsProjectFields(t *testing.T) { if err := r.Render(&buf, NewResult(testPlans())); err != nil { t.Fatalf("Render(json): unexpected error: %v", err) } - for _, k := range []string{`"composites"`, `"compositions"`, `"errors"`, `"warnings"`, `"deferred"`, `"composite"`, `"dryRun"`} { + for _, k := range []string{`"composites"`, `"compositions"`, `"errors"`, `"warnings"`, `"composite"`, `"dryRun"`} { if bytes.Contains(buf.Bytes(), []byte(k)) { t.Errorf("Render(json): resource simulate output should omit %s", k) } } + if !bytes.Contains(buf.Bytes(), []byte(`"deferred": 0`)) { + t.Errorf("Render(json): summary should always report deferred") + } } diff --git a/internal/simulate/report.go b/internal/simulate/report.go index fc1f825c..e37940f5 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -163,7 +163,7 @@ func FromCompReport(o *dt.CompDiffOutput, plans map[string]*plan.ResourcePlan) p // its revision advances, unless it was rendered against its pinned revision // because that calls a changed function. Other filtered composites are skipped. func compositeResult(impact dt.XRImpact) plan.CompositeResult { - c := plan.CompositeResult{Ref: resourceRef(impact.ObjectReference), Pinned: impact.Pinned} + c := plan.CompositeResult{Ref: resourceRef(impact.ObjectReference), Pinned: impact.Pinned || impact.FilterReason == dt.FilterReasonManualPolicy} if impact.Pinned && impact.RenderedRevision != "" { c.Detail = "rendered against pinned revision " + impact.RenderedRevision + " with the project's functions" @@ -491,7 +491,7 @@ func verification(d *dt.DryRunInfo) *plan.Verification { } func planError(e dt.OutputError, resource *plan.ResourceRef) plan.Error { - out := plan.Error{Resource: resource, Message: e.Message} + out := plan.Error{Ref: resource, Message: e.Message} for _, f := range e.ValidationFailures { vf := plan.ValidationFailure{ diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index b80c072f..0a7d3e78 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -166,8 +166,8 @@ func TestFromXRReport(t *testing.T) { {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Error: "schema validation failed", Composite: &xrPlan}, }, Errors: []plan.Error{{ - Resource: &xrPlan, - Message: "schema validation failed", + Ref: &xrPlan, + Message: "schema validation failed", ValidationFailures: []plan.ValidationFailure{{ APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Status: "invalid", Errors: []plan.FieldValidationError{{Type: "schema", Field: "data.k", Message: "must be a string", Value: 7.0}}, @@ -394,7 +394,7 @@ func TestFromCompReport(t *testing.T) { FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1, Deferred: 1}, Composites: []plan.CompositeResult{ - {Ref: named("pinned"), Status: plan.CompositeDeferred, Composition: &compPlan, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6"}, + {Ref: named("pinned"), Status: plan.CompositeDeferred, Composition: &compPlan, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6", Pinned: true}, {Ref: named("selector"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "revision_selector_mismatch", Detail: "channel=stable"}, {Ref: named("deleting"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "deleting"}, }, @@ -463,7 +463,7 @@ func TestFromCompReport(t *testing.T) { Plans: []*plan.ResourcePlan{ {APIVersion: compPlan.APIVersion, Kind: "Composition", Name: "net", Error: "cannot load"}, }, - Errors: []plan.Error{{Resource: &compPlan, Message: "cannot load"}}, + Errors: []plan.Error{{Ref: &compPlan, Message: "cannot load"}}, Notes: notes(), }, }, @@ -507,7 +507,7 @@ func TestFromCompReport(t *testing.T) { compNoOp, {APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod", Error: "render failed", Composite: &xrPlan}, }, - Errors: []plan.Error{{Resource: &xrPlan, Message: "render failed"}, {Message: "interrupted"}}, + Errors: []plan.Error{{Ref: &xrPlan, Message: "render failed"}, {Message: "interrupted"}}, Notes: notes(), }, }, diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 5f7b1322..8273a370 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -290,7 +290,7 @@ func assertXRImpact(t *testing.T, res plan.Result, label string, composites []pl t.Errorf("%s: expected filterReason %s, got %s", label, want.filterReason, got.Reason) } - errs := slices.DeleteFunc(slices.Clone(res.Errors), func(e plan.Error) bool { return e.Resource == nil || *e.Resource != got.Ref }) + errs := slices.DeleteFunc(slices.Clone(res.Errors), func(e plan.Error) bool { return e.Ref == nil || *e.Ref != got.Ref }) if want.errorContains != "" { msgs := make([]string, 0, len(errs)) @@ -400,7 +400,7 @@ func assertXRExpectations(t *testing.T, res plan.Result, expected []*XRExpectati for _, id := range want.errorIDs { e := findMatchingError(res.Errors, id) - if e == nil || e.Resource == nil || *e.Resource != got.Ref { + if e == nil || e.Ref == nil || *e.Ref != got.Ref { t.Errorf("xr %s: expected error with resourceID %q not found against it. Actual error IDs: %v", label, id, errorIDs(res.Errors)) } } @@ -752,11 +752,11 @@ func findMatchingError(errs []plan.Error, resourceID string) *plan.Error { // errorID is crossplane-diff's resourceID for an error: its resource's Kind/name, empty for a run // error no resource owns. func errorID(e plan.Error) string { - if e.Resource == nil { + if e.Ref == nil { return "" } - return e.Resource.Kind + "/" + e.Resource.Name + return e.Ref.Kind + "/" + e.Ref.Name } func errorIDs(errs []plan.Error) []string { diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 92c08bf6..15f8140b 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -88,7 +88,7 @@ func xrResult() plan.Result { {Ref: xrC, Status: plan.CompositeError}, }, Plans: plans, - Errors: []plan.Error{{Resource: &xrC, Message: "cannot get composition"}}, + Errors: []plan.Error{{Ref: &xrC, Message: "cannot get composition"}}, Warnings: []plan.Warning{{Message: "something advisory", Context: map[string]string{"composition": "c"}}}, } } @@ -253,8 +253,8 @@ func compResult() plan.Result { }, }, Errors: []plan.Error{ - {Resource: &compB, Message: "cannot convert composition"}, - {Resource: &xr1, Message: "maximum nesting depth exceeded"}, + {Ref: &compB, Message: "cannot convert composition"}, + {Ref: &xr1, Message: "maximum nesting depth exceeded"}, }, } } From 22305c5af4fb5fb243b9f969ace09024287e40be Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:11:41 -0400 Subject: [PATCH 099/126] feat(plan)!: report composite reasons as a typed camelCase enum Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .golangci.yml | 5 ++++ internal/diff/integration/integration_test.go | 4 ++-- internal/plan/output.go | 23 ++++++++++++++++--- internal/plan/output_test.go | 4 ++-- internal/simulate/report.go | 22 +++++++++++++++++- internal/simulate/report_test.go | 19 +++++++++++++-- internal/simulate/simtest/plan_assertions.go | 4 ++-- .../simulate/simtest/structured_assertions.go | 6 ++--- .../simtest/structured_assertions_test.go | 2 +- 9 files changed, 73 insertions(+), 16 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index c54f70d2..43644041 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -111,6 +111,11 @@ linters: # Deprecated and replaced by a new version, but still on by default. - gomodguard settings: + exhaustive: + # Only check switches marked //exhaustive:enforce, and don't let a + # default case satisfy them. + explicit-exhaustive-switch: true + default-signifies-exhaustive: false depguard: rules: no_third_party_test_libraries: diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 9c3164a2..581cd952 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -6063,7 +6063,7 @@ Summary: 2 modified`, WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "filtered"). - WithFilterReason("manual_policy"), + WithFilterReason("manualPolicy"), }, "ResourceFilterRespectsManualPolicy_WithIncludeManual": { reason: "--include-manual evaluates the Manual-policy composite normally instead of marking it filtered", @@ -6107,7 +6107,7 @@ Summary: 2 modified`, WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). - WithFilterReason("revision_selector_mismatch"), + WithFilterReason("revisionSelectorMismatch"), // Note: that --include-manual does NOT rescue a selector-mismatched Automatic XR is // proven at the unit level (TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy / // IncludeManualTrue_StillDropsSelectorMismatchedAutomaticXRs). A second slow diff --git a/internal/plan/output.go b/internal/plan/output.go index 015500f3..71090e97 100644 --- a/internal/plan/output.go +++ b/internal/plan/output.go @@ -104,13 +104,30 @@ const ( CompositeSkipped CompositeStatus = "skipped" ) +// CompositeReason is why a composite was deferred or skipped. +type CompositeReason string + +const ( + // CompositeReasonManualPolicy means the composite has a Manual + // compositionUpdatePolicy, so it is pinned to its current revision. + CompositeReasonManualPolicy CompositeReason = "manualPolicy" + // CompositeReasonRevisionSelectorMismatch means the composite's + // compositionRevisionSelector does not match the changed revision. + CompositeReasonRevisionSelectorMismatch CompositeReason = "revisionSelectorMismatch" + // CompositeReasonDeleting means the composite is being deleted. + CompositeReasonDeleting CompositeReason = "deleting" + // CompositeReasonUnknown means the engine filtered the composite for a + // reason this version does not recognize. + CompositeReasonUnknown CompositeReason = "unknown" +) + // CompositeResult is the per-composite outcome of a project simulate run. type CompositeResult struct { Ref ResourceRef `json:"ref"` Status CompositeStatus `json:"status"` // Reason is the machine-readable reason a composite was deferred or - // skipped. - Reason string `json:"reason,omitempty"` + // skipped: manualPolicy, revisionSelectorMismatch, deleting or unknown. + Reason CompositeReason `json:"reason,omitempty"` // Detail elaborates on Reason, e.g. the pinned revision. Detail string `json:"detail,omitempty"` // Pinned marks a composite simulated although it is pinned to a @@ -384,7 +401,7 @@ func compositeHeader(c CompositeResult, ref ResourceRef) string { case CompositeSkipped: h += " (skipped" if c.Reason != "" { - h += ": " + c.Reason + h += ": " + string(c.Reason) } h += ")" case CompositeChanged, CompositeUnchanged, CompositeError: diff --git a/internal/plan/output_test.go b/internal/plan/output_test.go index 3520f742..8b73eff5 100644 --- a/internal/plan/output_test.go +++ b/internal/plan/output_test.go @@ -235,7 +235,7 @@ func TestRenderPlanGrouped(t *testing.T) { Summary: Summary{Update: 1, Destroy: 1, NoOp: 1, Deferred: 1}, Composites: []CompositeResult{ {Ref: xr, Status: CompositeChanged}, - {Ref: pinned, Status: CompositeDeferred, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6"}, + {Ref: pinned, Status: CompositeDeferred, Reason: "manualPolicy", Detail: "compositionRevisionRef: net-6"}, }, Plans: []*ResourcePlan{ // The XR's own no-op plan is not printed. @@ -381,7 +381,7 @@ func TestRenderJSONProjectFields(t *testing.T) { FormatVersion: ResultFormatVersion, Errored: true, Summary: Summary{Destroy: 1, Deferred: 1}, - Composites: []CompositeResult{{Ref: xr, Status: CompositeDeferred, Reason: "manual_policy"}}, + Composites: []CompositeResult{{Ref: xr, Status: CompositeDeferred, Reason: "manualPolicy"}}, Compositions: []CompositionResult{{ Ref: ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"}, RevisionImpact: &RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2, PredictedRevision: "net-abc1234"}, diff --git a/internal/simulate/report.go b/internal/simulate/report.go index e37940f5..289e6fc9 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -181,7 +181,7 @@ func compositeResult(impact dt.XRImpact) plan.CompositeResult { if impact.FilterReason == dt.FilterReasonManualPolicy { c.Status = plan.CompositeDeferred } - c.Reason = string(impact.FilterReason) + c.Reason = compositeReason(impact.FilterReason) c.Detail = impact.FilterDetail default: c.Status = plan.CompositeStatus(impact.Status) @@ -190,6 +190,26 @@ func compositeResult(impact dt.XRImpact) plan.CompositeResult { return c } +// compositeReason converts the engine's filter reason to the public enum. An +// empty reason stays empty. +func compositeReason(r dt.FilterReason) plan.CompositeReason { + if r == "" { + return "" + } + + //exhaustive:enforce + switch r { + case dt.FilterReasonManualPolicy: + return plan.CompositeReasonManualPolicy + case dt.FilterReasonRevisionSelectorMismatch: + return plan.CompositeReasonRevisionSelectorMismatch + case dt.FilterReasonDeleting: + return plan.CompositeReasonDeleting + default: + return plan.CompositeReasonUnknown + } +} + // assembler accumulates one run's plans, composites and errors. type assembler struct { // provider holds the provider plans to merge, by diff key; nil when none diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index 0a7d3e78..632af828 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -394,8 +394,8 @@ func TestFromCompReport(t *testing.T) { FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1, Deferred: 1}, Composites: []plan.CompositeResult{ - {Ref: named("pinned"), Status: plan.CompositeDeferred, Composition: &compPlan, Reason: "manual_policy", Detail: "compositionRevisionRef: net-6", Pinned: true}, - {Ref: named("selector"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "revision_selector_mismatch", Detail: "channel=stable"}, + {Ref: named("pinned"), Status: plan.CompositeDeferred, Composition: &compPlan, Reason: "manualPolicy", Detail: "compositionRevisionRef: net-6", Pinned: true}, + {Ref: named("selector"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "revisionSelectorMismatch", Detail: "channel=stable"}, {Ref: named("deleting"), Status: plan.CompositeSkipped, Composition: &compPlan, Reason: "deleting"}, }, Compositions: []plan.CompositionResult{{Ref: compPlan}}, @@ -754,3 +754,18 @@ func TestSecretDottedKeysRedactedInObjects(t *testing.T) { } } } + +func TestCompositeReason(t *testing.T) { + cases := map[dt.FilterReason]plan.CompositeReason{ + dt.FilterReasonManualPolicy: plan.CompositeReasonManualPolicy, + dt.FilterReasonRevisionSelectorMismatch: plan.CompositeReasonRevisionSelectorMismatch, + dt.FilterReasonDeleting: plan.CompositeReasonDeleting, + "": "", + "something_new": plan.CompositeReasonUnknown, + } + for in, want := range cases { + if got := compositeReason(in); got != want { + t.Errorf("compositeReason(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 8273a370..0d46b132 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -214,7 +214,7 @@ func assertAffected(t *testing.T, label string, composites []plan.CompositeResul check("Unchanged", want.affectedUnchanged, count(status(plan.CompositeUnchanged))) check("WithErrors", want.affectedWithErrors, count(status(plan.CompositeError))) check("FilteredByDeletion", want.affectedFilteredByDeletion, count(func(c plan.CompositeResult) bool { - return c.Status == plan.CompositeSkipped && c.Reason == string(dt.FilterReasonDeleting) + return c.Status == plan.CompositeSkipped && c.Reason == plan.CompositeReasonDeleting })) } @@ -286,7 +286,7 @@ func assertXRImpact(t *testing.T, res plan.Result, label string, composites []pl t.Errorf("%s: expected status %s, got %s", label, want.status, got.Status) } - if want.filterReason != "" && got.Reason != want.filterReason { + if want.filterReason != "" && string(got.Reason) != want.filterReason { t.Errorf("%s: expected filterReason %s, got %s", label, want.filterReason, got.Reason) } diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index ba47bb73..6fae1ab8 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -657,7 +657,7 @@ type XRImpactExpectation struct { namespace string anyNameAllowed bool status string // "changed", "unchanged", "error", "filtered" - filterReason string // "manual_policy", "revision_selector_mismatch", "deleting"; only checked when set + filterReason string // "manualPolicy", "revisionSelectorMismatch", "deleting"; only checked when set errorContains string // substring of an "error" impact's Error; only checked when set errorIDs []string // resourceIDs the plan.Result errors[] attributed to the XR must contain downstreamSummary *expectedSummary @@ -791,8 +791,8 @@ func (x *XRImpactExpectation) WithAnyName() *XRImpactExpectation { return x } -// WithFilterReason pins the expected FilterReason on a filtered XR impact (e.g. "manual_policy", -// "revision_selector_mismatch", or "deleting"). Only asserted when set. +// WithFilterReason pins the expected FilterReason on a filtered XR impact (e.g. "manualPolicy", +// "revisionSelectorMismatch", or "deleting"). Only asserted when set. func (x *XRImpactExpectation) WithFilterReason(reason string) *XRImpactExpectation { x.filterReason = reason return x diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 15f8140b..1eb5b150 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -237,7 +237,7 @@ func compResult() plan.Result { Composites: []plan.CompositeResult{ {Ref: xr1, Status: plan.CompositeChanged, Composition: &compA}, {Ref: xr2, Status: plan.CompositeUnchanged, Composition: &compA}, - {Ref: xr3, Status: plan.CompositeSkipped, Reason: string(dt.FilterReasonDeleting), Composition: &compA}, + {Ref: xr3, Status: plan.CompositeSkipped, Reason: plan.CompositeReasonDeleting, Composition: &compA}, {Ref: xr1, Status: plan.CompositeError, Composition: &compB}, }, Plans: []*plan.ResourcePlan{ From 2d38e407c28d52e7dee2283f8d68a0b8e6d20463 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:19:15 -0400 Subject: [PATCH 100/126] build(nix): keep envtest assets Linux-only and out of the unit-test check Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- nix/checks.nix | 10 +++------- nix/envtest-assets.nix | 2 +- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/nix/checks.nix b/nix/checks.nix index 827dd1c9..880acc74 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -17,9 +17,6 @@ let # See nix/go-builders.nix. inherit (import ./go-builders.nix { inherit pkgs self; }) buildRoot; crossplaneCRDs = import ./crossplane-crds.nix { inherit pkgs; }; - # envtest binaries are Linux-only; on other systems the integration tests - # skip through their env: guards instead. - envtestAssets = import ./envtest-assets.nix { inherit pkgs; }; in { # Run Go unit tests with coverage @@ -30,13 +27,12 @@ in inherit version; src = self; - # The sandbox has no Docker daemon, so the integration tests' render - # engine cases still skip there (env: guards); the envtest cluster - # cases that do not need Docker can run. + # Runs untagged unit tests only; the integration tests (envtest, Docker) + # run through the integration-test app. CROSSPLANE_CRDS_DIR serves the + # untagged testutils CRD tests. env = { CGO_ENABLED = "0"; CROSSPLANE_CRDS_DIR = crossplaneCRDs; - KUBEBUILDER_ASSETS = pkgs.lib.optionalString (envtestAssets != null) envtestAssets; }; dontBuild = true; diff --git a/nix/envtest-assets.nix b/nix/envtest-assets.nix index 711a0ef5..9be8bcf2 100644 --- a/nix/envtest-assets.nix +++ b/nix/envtest-assets.nix @@ -17,7 +17,7 @@ let } .${pkgs.stdenv.hostPlatform.uname.processor} or null; in -if arch == null then +if !pkgs.stdenv.hostPlatform.isLinux || arch == null then null else pkgs.runCommand "envtest-assets-${version}" From a87fb15e55f576da4c85b894216da732e0ba1985 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:19:15 -0400 Subject: [PATCH 101/126] ci: cache Go modules in the integration-tests job Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4402da7b..aab6db97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -130,6 +130,16 @@ jobs: name: crossplane authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + - name: Cache Go modules and build cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-integration-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-integration- + - name: Run Integration Tests run: nix run .#integration-test --option warn-dirty false From ce11095448721d135c26e5cf3684f325fe2daecc Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:39:36 -0400 Subject: [PATCH 102/126] fix(diff): resolve pinned XRs on changed functions before skipping a composition Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/comp_processor.go | 16 +++++++++++++--- .../diff/diffprocessor/comp_processor_test.go | 19 +++++++++++++++++-- .../diff/diffprocessor/processor_config.go | 6 ++++-- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index 30128471..a358b563 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -437,9 +437,17 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, // A Manual XR pinned to a revision that calls a changed function is not deferred when the analysis // runs: it is rendered against that revision. See pinnedFunctionChangeXRs. - var pinnedXRs map[string]pinnedResolution + // This holds even when the diffed composition itself calls no changed function and matches the + // cluster: an XR pinned to an older revision that does is analysed as a metadata-scope change. + pinnedXRs, pinnedKept, pinnedDropped := p.pinnedFunctionChangeXRs(ctx, keptXRs, droppedXRs) + if len(pinnedXRs) > 0 && analysisScope == ChangeScopeNone { + analysisScope = ChangeScopeMetadata + } + if analysisScope.triggersAnalysis(p.config.AnalyzeOn) { - pinnedXRs, keptXRs, droppedXRs = p.pinnedFunctionChangeXRs(ctx, keptXRs, droppedXRs) + keptXRs, droppedXRs = pinnedKept, pinnedDropped + } else { + pinnedXRs = nil } counts := countFilterReasons(droppedXRs) @@ -706,7 +714,9 @@ type pinnedResolution struct { // // It does nothing when no function changed or under IncludeManual, which keeps Manual XRs and renders // them against the diffed composition. A Manual XR whose revision calls no changed function stays dropped. -// A failed lookup is that XR's own error (a kept XR whose result is the error), not the run's. +// A failed lookup is that XR's own error (a kept XR whose result is the error), not the run's. So with +// changed functions set, a failed revision lookup makes every Manual XR, claims included, a per-XR error +// where upstream would filter it. func (p *DefaultCompDiffProcessor) pinnedFunctionChangeXRs(ctx context.Context, kept []*un.Unstructured, dropped []filteredXR) (map[string]pinnedResolution, []*un.Unstructured, []filteredXR) { if len(p.config.ChangedFunctions) == 0 || p.config.IncludeManual { return nil, kept, dropped diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index b0ee5ec3..d898db6e 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -2347,6 +2347,7 @@ func TestDefaultCompDiffProcessor_processSingleComposition_PinnedXRWithChangedFu revision *apiextensionsv1.Composition lookupErr error includeManual bool + unrelated bool want want }{ "PinnedXRWithChangedFunctionRendersPinnedRevision": { @@ -2357,6 +2358,11 @@ func TestDefaultCompDiffProcessor_processSingleComposition_PinnedXRWithChangedFu revision: revisionWith("function-other"), want: want{Status: dt.XRStatusFiltered, FilterReason: dt.FilterReasonManualPolicy}, }, + "PinnedXRWithChangedFunctionRendersWhenCompositionCallsNone": { + revision: revisionWith("function-a"), + unrelated: true, + want: want{Status: dt.XRStatusUnchanged, Pinned: true, RenderedRevision: "test-comp-rev1", Pipeline: []string{"function-a"}}, + }, "IncludePinnedWithChangedFunctionPreviewsAdvance": { revision: revisionWith("function-a"), includeManual: true, @@ -2370,8 +2376,17 @@ func TestDefaultCompDiffProcessor_processSingleComposition_PinnedXRWithChangedFu for name, tt := range cases { t.Run(name, func(t *testing.T) { + // unrelated: the diffed composition matches the cluster and calls no changed function. + cliComp := cliBuilder() + if tt.unrelated { + cliComp = tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR"). + WithPipelineMode(). + WithPipelineStep("step-a", "function-other", nil) + } + client := tu.NewMockCompositionClient(). - WithSuccessfulCompositionFetch(cliBuilder().Build()). + WithSuccessfulCompositionFetch(cliComp.Build()). WithFindMatchingComposition(func(context.Context, *un.Unstructured) (types.ResolvedComposition, error) { if tt.lookupErr != nil { return types.ResolvedComposition{}, tt.lookupErr @@ -2405,7 +2420,7 @@ func TestDefaultCompDiffProcessor_processSingleComposition_PinnedXRWithChangedFu }, } - got, err := proc.processSingleComposition(t.Context(), cliBuilder().BuildAsUnstructured(), []*un.Unstructured{pinnedXR("pinned")}, false) + got, err := proc.processSingleComposition(t.Context(), cliComp.BuildAsUnstructured(), []*un.Unstructured{pinnedXR("pinned")}, false) if err != nil { t.Fatalf("processSingleComposition: %v", err) } diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index 8ad2b8be..8ecfeb0a 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -460,7 +460,8 @@ func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) var ErrXRTimedOut = errors.New("composite resource timed out") // xrContext returns the context one composite resource's diff runs under: ctx bounded by XRTimeout -// when it is set. The budget can be paused with OutsideXRDeadline. +// when it is set. The budget can be paused with OutsideXRDeadline. It is a cancellation, not a context +// deadline, so ctx.Deadline() on the returned context does not report it. func (c *ProcessorConfig) xrContext(ctx context.Context) (context.Context, context.CancelFunc) { if c.XRTimeout <= 0 { return ctx, func() {} @@ -571,7 +572,8 @@ func (c *ProcessorConfig) afterXR(ctx, xrCtx context.Context, diffs map[string]* err = c.AfterXRDiff(xrCtx, diffs) } - // Only the composite's own deadline is named; the caller's ending reads as it always has. + // Only the composite's own deadline is named; the caller's ending reads as it always has. An + // unrelated error returned just after the budget expired is also labelled a timeout. if err != nil && ctx.Err() == nil && errors.Is(context.Cause(xrCtx), ErrXRTimedOut) { return errors.Wrapf(err, "timed out after %s", c.XRTimeout) } From b56c115304080a04c1fede43ecc4a3762eeb5da2 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:39:36 -0400 Subject: [PATCH 103/126] fix(diff): drop the --resource flag name from the preflight error Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/diffprocessor/comp_processor.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index a358b563..eda6186b 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -327,7 +327,7 @@ func (p *DefaultCompDiffProcessor) preflightResourceRefs(ctx context.Context, co // in refs mode (resolveCompositeTypes needs spec.compositeTypeRef). matched, err := p.compositionClient.FindComposites(ctx, comp, dtypes.FindCompositesOptions{Refs: refs}) if err != nil { - return nil, errors.Wrapf(err, "preflight: cannot resolve --resource refs for composition %s", comp.GetName()) + return nil, errors.Wrapf(err, "preflight: cannot resolve resource refs for composition %s", comp.GetName()) } perComp[comp.GetName()] = matched From 15e05a467d45b4599b298cbd7c6d7ef641655bc4 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:40:53 -0400 Subject: [PATCH 104/126] build(nix): provide envtest binaries on macOS Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- nix/apps.nix | 4 ++-- nix/envtest-assets.nix | 43 +++++++++++++++++++++++++++--------------- 2 files changed, 30 insertions(+), 17 deletions(-) diff --git a/nix/apps.nix b/nix/apps.nix index b2d2f4ff..8fe86344 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -14,8 +14,8 @@ # set to false. This ensures apps only use explicitly declared tools. { pkgs }: let - # envtest binaries for the integration tests; null on non-Linux systems, - # where the tests skip through their env: guards instead. + # envtest binaries for the integration tests; null only on systems + # without an envtest build, where the tests skip through their env: guards. envtestAssets = import ./envtest-assets.nix { inherit pkgs; }; in { diff --git a/nix/envtest-assets.nix b/nix/envtest-assets.nix index 9be8bcf2..8dd70fed 100644 --- a/nix/envtest-assets.nix +++ b/nix/envtest-assets.nix @@ -1,23 +1,36 @@ # envtest control-plane binaries (kube-apiserver, etcd, kubectl) for the diff # integration tests, from the controller-tools envtest release. Pinned to the # Kubernetes version crossplane-diff's Earthfile targets (KUBE_VERSION). -# Tests find them through KUBEBUILDER_ASSETS; on non-Linux systems there is -# nothing to provide and null is returned so callers can skip wiring. +# Tests find them through KUBEBUILDER_ASSETS; null is returned +# only for systems without an envtest build, so callers can skip wiring. { pkgs }: let version = "1.30.3"; - hashes = { - amd64 = "sha256-DDwRyCoyUfBEaw5WPmYRmfrVEhDpmscC1MIroilgo9I="; - arm64 = "sha256-Z26hrMSpoO04w9dr7CH2/b3v6U6QyunAj6vqD+opk5M="; + platforms = { + x86_64-linux = { + os = "linux"; + arch = "amd64"; + hash = "sha256-DDwRyCoyUfBEaw5WPmYRmfrVEhDpmscC1MIroilgo9I="; + }; + aarch64-linux = { + os = "linux"; + arch = "arm64"; + hash = "sha256-Z26hrMSpoO04w9dr7CH2/b3v6U6QyunAj6vqD+opk5M="; + }; + x86_64-darwin = { + os = "darwin"; + arch = "amd64"; + hash = "sha256-hixH51R+1/LyMOkABsvdtSa/Os6mNhruixg4NzlSdJ4="; + }; + aarch64-darwin = { + os = "darwin"; + arch = "arm64"; + hash = "sha256-sRcsK819TPzkb2guCqw/neEei7oSoWPe2maYEJY1myQ="; + }; }; - arch = - { - x86_64 = "amd64"; - aarch64 = "arm64"; - } - .${pkgs.stdenv.hostPlatform.uname.processor} or null; + target = platforms.${pkgs.stdenv.hostPlatform.system} or null; in -if !pkgs.stdenv.hostPlatform.isLinux || arch == null then +if target == null then null else pkgs.runCommand "envtest-assets-${version}" @@ -25,15 +38,15 @@ else nativeBuildInputs = [ pkgs.gnutar ]; meta = { description = "envtest control-plane binaries (Kubernetes v${version})"; - platforms = pkgs.lib.platforms.linux; + platforms = builtins.attrNames platforms; }; } '' mkdir -p $out tar -xzf ${ pkgs.fetchurl { - url = "https://github.com/kubernetes-sigs/controller-tools/releases/download/envtest-v${version}/envtest-v${version}-linux-${arch}.tar.gz"; - hash = hashes.${arch}; + url = "https://github.com/kubernetes-sigs/controller-tools/releases/download/envtest-v${version}/envtest-v${version}-${target.os}-${target.arch}.tar.gz"; + hash = target.hash; } } --strip-components=2 -C $out controller-tools/envtest '' From 42e0e3c2777abc9256428b402139d0004bc4342a Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 14:47:27 -0400 Subject: [PATCH 105/126] build(nix): inherit envtest hash for statix Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- nix/envtest-assets.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/envtest-assets.nix b/nix/envtest-assets.nix index 8dd70fed..f90b4049 100644 --- a/nix/envtest-assets.nix +++ b/nix/envtest-assets.nix @@ -46,7 +46,7 @@ else tar -xzf ${ pkgs.fetchurl { url = "https://github.com/kubernetes-sigs/controller-tools/releases/download/envtest-v${version}/envtest-v${version}-${target.os}-${target.arch}.tar.gz"; - hash = target.hash; + inherit (target) hash; } } --strip-components=2 -C $out controller-tools/envtest '' From 2a59face08cb70d6bf3f44ea15cd42f21ccf54c0 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 15:35:12 -0400 Subject: [PATCH 106/126] fix(load): add a loader constructor that doesn't split paths on commas Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/common/load/loader.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/cmd/crossplane/common/load/loader.go b/cmd/crossplane/common/load/loader.go index b7a25ab3..35ee20d9 100644 --- a/cmd/crossplane/common/load/loader.go +++ b/cmd/crossplane/common/load/loader.go @@ -259,6 +259,16 @@ type CompositeLoader struct { // Sources can be files, directories, or "-" for stdin. // If sources is empty, stdin is used by default. func NewCompositeLoader(sources []string) (Loader, error) { + return newCompositeLoader(sources, NewLoader) +} + +// NewExactCompositeLoader is NewCompositeLoader, except each source is exactly +// one path or "-": commas are part of the path, not separators. +func NewExactCompositeLoader(sources []string) (Loader, error) { + return newCompositeLoader(sources, newLoader) +} + +func newCompositeLoader(sources []string, newSource func(string) (Loader, error)) (Loader, error) { if len(sources) == 0 { // In unit tests, this will cause an error when Load() is called // which is the expected behavior for NoSources test case @@ -280,7 +290,7 @@ func NewCompositeLoader(sources []string) (Loader, error) { stdinUsed = true } - loader, err := NewLoader(source) + loader, err := newSource(source) if err != nil { return nil, errors.Wrapf(err, "cannot create loader for %q", source) } From b7453764f4f6edf6a84367458e3cc0e459e2f03f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 15:35:12 -0400 Subject: [PATCH 107/126] fix(project): validate simulate's flags before building and keep both 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) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 5 +- cmd/crossplane/project/simulate.go | 50 ++++++++++------ cmd/crossplane/project/simulate_test.go | 59 +++++++++++++++++-- internal/diff/integration/plan_output_test.go | 5 -- internal/simulate/xrplan.go | 21 +++++++ 5 files changed, 110 insertions(+), 30 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 86319e20..48d7fce6 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -34,7 +34,10 @@ them. Use `--as`, `--as-group`, and `--as-uid` to simulate as another user. By default, `simulate` skips composite resources with a `Manual` composition update policy, because they don't adopt a new Composition revision on their -own. Pass `--include-pinned` to simulate them too. +own. A pinned composite whose revision calls a function the project changes is +still simulated, rendered against the revision it's pinned to. Pass +`--include-pinned` to render every pinned composite against the new Composition +instead. Some flags apply only to some inputs. `--namespace`, `--name`, `--include-pinned`, `--analyze-on`, and `--minimize-composition` choose which diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index c75218c2..039939c7 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -84,17 +84,17 @@ type simulateCmd struct { // noServersNoted is set once the "no diff servers" line is printed. noServersNoted bool - Input []string `default:"${project_file}" help:"Project file (default), or changed Composition or composite resource files. Repeatable; - reads standard input." placeholder:"PATH" sep:"none" short:"f"` + Input []string `default:"${project_file}" help:"Project file (default), or changed Composition or composite resource files. Takes one path per flag; repeat it for more. - reads standard input." placeholder:"PATH" sep:"none" short:"f"` MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` - CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents (functions and providers)." name:"cache-dir"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents (functions and providers)." name:"cache-dir"` - Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` - Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` - Name []string `help:"Only simulate the composite resource with this [namespace/]name. Repeatable or comma-separated. A bare name pairs with --namespace, or is cluster scoped without it." sep:","` - IncludePinned bool `help:"Also simulate composite resources with a Manual composition update policy, which don't adopt the new revision."` - MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` - Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count, but the render image pull counts against the first composite."` - Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` + Context string `help:"Kubernetes context to simulate against. Defaults to the current context." predictor:"context" short:"c"` + Namespace string `help:"Only simulate composite resources in this namespace. Defaults to all namespaces." predictor:"namespace" short:"n"` + Name []string `help:"Only simulate the composite resource with this [namespace/]name. Repeatable or comma-separated. A bare name pairs with --namespace, or is cluster scoped without it." sep:","` + IncludePinned bool `help:"Render every composite resource with a Manual composition update policy against the new Composition, instead of only those pinned to a changed function, rendered against their pinned revision."` + MaxIterations int `default:"20" help:"Max render iterations per composite resource to resolve requirements."` + Timeout time.Duration `default:"2m" help:"Max time to simulate one composite resource: its render and its resources' provider plans. Function and provider image pulls don't count, but the render image pull counts against the first composite."` + Output string `default:"plan" enum:"plan,diff,json,yaml" help:"Output format. One of: plan, diff, json, yaml." short:"o"` NoColor bool `help:"Disable colorized output."` Compact bool `help:"Show compact diffs with minimal context."` @@ -227,6 +227,15 @@ func (c *simulateCmd) Validate() error { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } + if c.MaxConcurrency == 0 { + return errors.Errorf("--max-concurrency must be at least 1, got %d", c.MaxConcurrency) + } + + // Bad --name/--namespace values fail here, before any build. + if _, _, err := c.scope(); err != nil { + return err + } + if c.Timeout <= 0 { return errors.Errorf("--timeout must be greater than 0, got %s", c.Timeout) } @@ -445,7 +454,7 @@ func selectServers(providers string, projectMode bool, loadInputs func(string) ( // noteNoServers prints once, when planning is on and no diff server ever // ran, that the diff shows only the cluster layer. -o diff has no notes. func (c *simulateCmd) noteNoServers(k *kong.Context) { - if c.servers == nil || c.servers.Ran() || c.noServersNoted { + if c.servers == nil || c.servers.Ran() || c.noServersNoted || c.xrPlanner == nil || c.xrPlanner.Considered() == 0 { return } c.noServersNoted = true @@ -534,7 +543,7 @@ func (c *simulateCmd) diffCompositions(k *kong.Context, logger logging.Logger, p // Render the report even when there are errors (e.g. every // composition failed), so structured output stays valid. A render - // failure takes precedence over the processing error. + // failure is joined with the processing error, so neither is lost. changed := output != nil && output.HasChanges() if output != nil { rendered, renderErr := c.renderCompReport(k, logger, output) @@ -593,7 +602,7 @@ func loadInputs(paths []string) (*inputs, error) { } } - loader, err := ld.NewCompositeLoader(paths) + loader, err := ld.NewExactCompositeLoader(paths) if err != nil { return nil, errors.Wrap(err, "cannot load -f inputs") } @@ -682,7 +691,12 @@ func pipelineInputs(resources []*un.Unstructured) (map[string]int, error) { } steps, _, _ := un.NestedSlice(r.Object, "spec", "pipeline") for _, step := range steps { - in, ok := step.(map[string]any)["input"] + m, ok := step.(map[string]any) + if !ok { + // Let the engine report the malformed Composition. + continue + } + in, ok := m["input"] if !ok || in == nil { continue } @@ -887,12 +901,12 @@ func (c *simulateCmd) providerPlans() map[string]*plan.ResourcePlan { // planNotes returns the notes that replace the result's own, or nil to keep // them. planned is true when planning ran; routed counts the plans a diff -// server served. -func planNotes(skipPlan, planned bool, routed int) []string { +// server served, and considered the resources the planner was asked to route. +func planNotes(skipPlan, planned bool, routed, considered int) []string { switch { case skipPlan: return []string{simulate.NoteProviderPlanSkipped} - case planned && routed == 0: + case planned && considered > 0 && routed == 0: return []string{simulate.NoteNoDiffServers} } return nil @@ -900,9 +914,9 @@ func planNotes(skipPlan, planned bool, routed int) []string { func (c *simulateCmd) notes() []string { if c.xrPlanner == nil { - return planNotes(c.SkipPlan, false, 0) + return planNotes(c.SkipPlan, false, 0, 0) } - return planNotes(c.SkipPlan, true, c.xrPlanner.Routed()) + return planNotes(c.SkipPlan, true, c.xrPlanner.Routed(), c.xrPlanner.Considered()) } // renderXRReport writes an xr or project run's report in the format -o diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 8024ca0a..5a8ffc53 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -249,6 +249,31 @@ func TestSimulateFlags(t *testing.T) { args: []string{"-o", "xml"}, want: want{err: true}, }, + "ZeroConcurrency": { + reason: "Zero build slots would hang a project build forever.", + args: []string{"--max-concurrency", "0"}, + want: want{err: true}, + }, + "EmptyName": { + reason: "An empty --name should fail at parse time.", + args: []string{"--name=,"}, + want: want{err: true}, + }, + "NamespacedNameWithNamespace": { + reason: "ns/name with -n should fail at parse time.", + args: []string{"-n", "a", "--name", "b/c"}, + want: want{err: true}, + }, + "TooManySlashes": { + reason: "a/b/c should fail at parse time.", + args: []string{"--name", "a/b/c"}, + want: want{err: true}, + }, + "EmptyNamespaceInName": { + reason: "/name should fail at parse time.", + args: []string{"--name", "/name"}, + want: want{err: true}, + }, "ZeroIterations": { reason: "A render needs at least one iteration.", args: []string{"--max-iterations", "0"}, @@ -865,6 +890,8 @@ func TestLoadInputs(t *testing.T) { return path } + commaXR := write("a,b.yaml", "apiVersion: example.org/v1\nkind: XThing\nmetadata:\n name: comma\n") + proj := write("crossplane-project.yaml", "apiVersion: dev.crossplane.io/v1alpha1\nkind: Project\nmetadata:\n name: p\n") comp := write("comp.yaml", `apiVersion: apiextensions.crossplane.io/v1 kind: Composition @@ -929,6 +956,11 @@ spec: paths: []string{xr, claim}, want: want{mode: xrMode, names: []string{"x", "t"}}, }, + "CommaInPath": { + reason: "-f never splits on commas, so a,b.yaml is one path.", + paths: []string{commaXR}, + want: want{mode: xrMode, names: []string{"comma"}}, + }, "NonProjectSingleFile": { reason: "A single file that isn't a project file should be loaded as resources; checkComposites rejects it once XRDs are known.", paths: []string{notProject}, @@ -1149,21 +1181,23 @@ func TestCheckComposites(t *testing.T) { func TestPlanNotes(t *testing.T) { cases := map[string]struct { - skipPlan bool - planned bool - routed int - want []string + skipPlan bool + planned bool + routed int + considered int + want []string }{ "SkipPlan": {skipPlan: true, want: []string{simulate.NoteProviderPlanSkipped}}, "NotPlanned": {}, - "NoneRouted": {planned: true, want: []string{simulate.NoteNoDiffServers}}, + "NoneRouted": {planned: true, considered: 1, want: []string{simulate.NoteNoDiffServers}}, + "NothingToPlan": {planned: true}, "SomeRouted": {planned: true, routed: 1}, "ManyRouted": {planned: true, routed: 5}, "SkipPlanRouted": {skipPlan: true, planned: true, routed: 2, want: []string{simulate.NoteProviderPlanSkipped}}, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - if diff := cmp.Diff(tc.want, planNotes(tc.skipPlan, tc.planned, tc.routed)); diff != "" { + if diff := cmp.Diff(tc.want, planNotes(tc.skipPlan, tc.planned, tc.routed, tc.considered)); diff != "" { t.Errorf("planNotes(...): -want, +got:\n%s", diff) } }) @@ -1240,3 +1274,16 @@ func TestSimulateNameFlagParse(t *testing.T) { }) } } + +func TestPipelineInputsMalformedStep(t *testing.T) { + c := &un.Unstructured{Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1", + "kind": "Composition", + "metadata": map[string]any{"name": "bad"}, + "spec": map[string]any{"pipeline": []any{"foo"}}, + }} + got, err := pipelineInputs([]*un.Unstructured{c}) + if err != nil || len(got) != 0 { + t.Errorf("pipelineInputs(...) = %v, %v; want no inputs and no error", got, err) + } +} diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index 73553a4b..c3eaeda5 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -469,11 +469,6 @@ func TestSimulateNameRejected(t *testing.T) { args: append([]string{"--name=default/test-resource", "--name=default/missing"}, base...), wantErr: "resource ref(s) not relevant to any supplied composition: [default/missing]", }, - "NameQualifiedWithNamespaceFlagRejected": { - reason: "namespace/name with --namespace is ambiguous and is rejected.", - args: append([]string{"--namespace=default", "--name=default/test-resource"}, base...), - wantErr: "can't be combined with --namespace", - }, } for name, tc := range cases { diff --git a/internal/simulate/xrplan.go b/internal/simulate/xrplan.go index d04af646..b20f1142 100644 --- a/internal/simulate/xrplan.go +++ b/internal/simulate/xrplan.go @@ -58,6 +58,8 @@ type XRPlanner struct { mu sync.Mutex plans map[string]*plan.ResourcePlan routed int + // considered counts the resources AfterXRDiff was asked to route. + considered int } // NewXRPlanner returns an XRPlanner that plans with p. keep and ensure may be nil. @@ -68,6 +70,16 @@ func NewXRPlanner(p *Planner, keep func(*unstructured.Unstructured) bool, ensure // AfterXRDiff is a diffprocessor.WithAfterXRDiff hook. It fails the XR if any // of its resources' plans errors, and merges none of them then. func (x *XRPlanner) AfterXRDiff(ctx context.Context, diffs map[string]*dt.ResourceDiff) error { + var considered int + for _, d := range diffs { + if d.DiffType != dt.DiffTypeRemoved && d.Desired.Raw != nil { + considered++ + } + } + x.mu.Lock() + x.considered += considered + x.mu.Unlock() + if x.ensure != nil { var resources []*unstructured.Unstructured for _, d := range diffs { @@ -134,3 +146,12 @@ func (x *XRPlanner) Routed() int { return x.routed } + +// Considered returns how many resources the planner was asked to route, so a +// run can tell having no diff servers from having nothing to plan. +func (x *XRPlanner) Considered() int { + x.mu.Lock() + defer x.mu.Unlock() + + return x.considered +} From 73d61ea7fb1a75c807713174e2a8610e6e2e5f4d Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 16:37:52 -0400 Subject: [PATCH 108/126] test(diff): report parse errors from the simulate harness as main does Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 1 + internal/diff/integration/plan_output_test.go | 33 ++++++++++++++----- .../diff/integration/simulate_harness_test.go | 12 +++++-- 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 48d7fce6..43f051cd 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -136,6 +136,7 @@ the result: - 2: only schema validation failed. - 3: the simulation succeeded and found changes, including drift a provider plan found that the cluster diff didn't. +- 80: the flags or arguments were invalid, so nothing was simulated. - 130: the simulation was interrupted. When more than one applies, the lowest of 1, 2 and 3 wins. diff --git a/internal/diff/integration/plan_output_test.go b/internal/diff/integration/plan_output_test.go index c3eaeda5..80da9608 100644 --- a/internal/diff/integration/plan_output_test.go +++ b/internal/diff/integration/plan_output_test.go @@ -459,15 +459,32 @@ func TestSimulateNameRejected(t *testing.T) { } base := []string{"-f", "testdata/comp/updated-composition.yaml"} + // exitUsageError is kong's exit status for parse and Validate errors. + const exitUsageError = 80 + cases := map[string]struct { - reason string - args []string - wantErr string + reason string + args []string + wantErr string + wantCode int }{ + "NameQualifiedWithNamespaceFlagRejected": { + reason: "namespace/name with --namespace is ambiguous and is rejected.", + args: append([]string{"--namespace=default", "--name=default/test-resource"}, base...), + wantErr: "can't be combined with --namespace", + wantCode: exitUsageError, + }, + "MaxConcurrencyZeroRejected": { + reason: "--max-concurrency=0 is rejected.", + args: append([]string{"--max-concurrency=0"}, base...), + wantErr: "--max-concurrency must be at least 1", + wantCode: exitUsageError, + }, "NamePartiallyUnmatchedFailsBeforeRendering": { - reason: "one --name no composite matches fails the run, and no plan is rendered.", - args: append([]string{"--name=default/test-resource", "--name=default/missing"}, base...), - wantErr: "resource ref(s) not relevant to any supplied composition: [default/missing]", + reason: "one --name no composite matches fails the run, and no plan is rendered.", + args: append([]string{"--name=default/test-resource", "--name=default/missing"}, base...), + wantErr: "resource ref(s) not relevant to any supplied composition: [default/missing]", + wantCode: dp.ExitCodeToolError, }, } @@ -487,8 +504,8 @@ func TestSimulateNameRejected(t *testing.T) { if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("\n%s\nsimulate: want error containing %q, got %v (exit %d)", tc.reason, tc.wantErr, err, code) } - if code != dp.ExitCodeToolError { - t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, dp.ExitCodeToolError, code) + if code != tc.wantCode { + t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, tc.wantCode, code) } if stdout.Len() != 0 { t.Errorf("\n%s\nsimulate: want no plan output, got:\n%s", tc.reason, stdout.String()) diff --git a/internal/diff/integration/simulate_harness_test.go b/internal/diff/integration/simulate_harness_test.go index 0d1c9ab9..09a9da8c 100644 --- a/internal/diff/integration/simulate_harness_test.go +++ b/internal/diff/integration/simulate_harness_test.go @@ -18,7 +18,7 @@ limitations under the License. // This file is the simulate harness: the one function every integration // case funnels through. The cases are crossplane-diff's (origin/main -// 6a140b6), written as its comp and xr commands' CLI args plus an expected +// 6a140b6, plus later upstream additions through d93eb87), written as its comp and xr commands' CLI args plus an expected // exit code and output. runSimulate translates those args into // `crossplane project simulate` args and runs the real command through // kong against the case's envtest cluster, so no case table changes. @@ -232,7 +232,15 @@ func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *p kctx, err := parser.Parse(argv) if err != nil { - t.Fatalf("failed to parse project simulate args %v: %v", argv, err) + // Exit as main does: print the error and use its ExitCoder's code or 1. + parser.Errorf("%s", err) + + var ec kong.ExitCoder + if errors.As(err, &ec) { + return ec.ExitCode(), err + } + + return dp.ExitCodeToolError, err } // Inject the case's cluster in place of the kubeconfig, and render the From 894cc3b9954a571e58752919d944f3efe6bd3d7b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 16:37:52 -0400 Subject: [PATCH 109/126] test(simulate): fail plan assertions that would pass vacuously Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 63 ++++++++++++++++---- 1 file changed, 53 insertions(+), 10 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 0d46b132..eeb3129c 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -69,7 +69,7 @@ func AssertStructuredDiff(t *testing.T, jsonOutput string, e DiffExpectation) { } if s := expected.summary; s != nil { - assertSummary(t, "Summary", s, res.Summary.Create, res.Summary.Update, res.Summary.Destroy) + assertSummary(t, "Summary", s, res.Summary.Create, res.Summary.Update, res.Summary.Destroy, res.Summary.Replace) } changes := changePlans(res.Plans, func(*plan.ResourcePlan) bool { return true }) @@ -312,8 +312,8 @@ func assertXRImpact(t *testing.T, res plan.Result, label string, composites []pl downstream := changePlans(res.Plans, groupedUnder(got.Ref)) if s := want.downstreamSummary; s != nil { - create, update, destroy := countActions(downstream) - assertSummary(t, label+": downstream summary", s, create, update, destroy) + create, update, destroy, replace := countActions(downstream) + assertSummary(t, label+": downstream summary", s, create, update, destroy, replace) } for _, wantRes := range want.downstreamResources { @@ -335,6 +335,7 @@ func assertXRImpact(t *testing.T, res plan.Result, label string, composites []pl // splits into deferred and skipped. func statusMatches(got plan.CompositeStatus, want string) bool { if want == statusFiltered { + // Assert filterReason when it matters which of deferred or skipped applies. return got == plan.CompositeDeferred || got == plan.CompositeSkipped } @@ -386,8 +387,8 @@ func assertXRExpectations(t *testing.T, res plan.Result, expected []*XRExpectati changes := changePlans(res.Plans, groupedUnder(got.Ref)) if s := want.summary; s != nil { - create, update, destroy := countActions(changes) - assertSummary(t, "xr "+label+": summary", s, create, update, destroy) + create, update, destroy, replace := countActions(changes) + assertSummary(t, "xr "+label+": summary", s, create, update, destroy, replace) } assertResourceExpectations(t, label, changes, want.changes) @@ -449,9 +450,17 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri if got := valueAt(t, p.Changes, planned, path); !reflect.DeepEqual(got, want) { t.Errorf("%s: field %s: expected %v, got %v", id, path, want, got) } + + if want == nil && planned { + assertAbsentInAfter(t, id, p, path) + } } for path, want := range changes { + if want[0] == nil && want[1] == nil { + t.Fatalf("%s: field %s: WithFieldChange(path, nil, nil) asserts nothing; use WithFieldAbsent", id, path) + } + // FieldChanges are leaf changes only, so a field rendered identically on both sides is read from // the plan's objects instead, and must have no change at or under it. if want[0] != nil && reflect.DeepEqual(want[0], want[1]) { @@ -477,14 +486,41 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri } } + assertPatterns(t, id, p, planned, patterns) +} + +// assertPatterns checks each field value against its pattern, failing on a missing field. +func assertPatterns(t *testing.T, id string, p *plan.ResourcePlan, planned bool, patterns map[string]*regexp.Regexp) { + t.Helper() + for path, pattern := range patterns { - got := fmt.Sprintf("%v", valueAt(t, p.Changes, planned, path)) + v := valueAt(t, p.Changes, planned, path) + if v == nil { + t.Errorf("%s: field %s: expected a value matching pattern %q, but the field is missing", id, path, pattern.String()) + continue + } + + got := fmt.Sprintf("%v", v) if !pattern.MatchString(got) { t.Errorf("%s: field %s value %q does not match pattern %q", id, path, got, pattern.String()) } } } +// assertAbsentInAfter requires path to be absent from the plan's After object, not only from its leaf +// changes, when After is set. +func assertAbsentInAfter(t *testing.T, id string, p *plan.ResourcePlan, path string) { + t.Helper() + + if p.After == nil { + return + } + + if got, ok := objectValueAt(t, p.After, path); ok { + t.Errorf("%s: field %s: expected absent, but after carries %v", id, path, got) + } +} + // assertDryRun checks a plan's dry-run verification: either that its desired state was reported as NOT // verified against the apiserver (for a reason, optionally with the cluster's own explanation), or that // no such report is present at all. @@ -650,9 +686,14 @@ func assertValidationFailureMatch(t *testing.T, got plan.ValidationFailure, want } } -func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy int) { +func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy, replace int) { t.Helper() + // expectedSummary has no replace bucket, so a replace would otherwise go uncounted. + if replace != 0 { + t.Errorf("%s: %d replace action(s) can't be pinned by a summary of added/modified/removed", label, replace) + } + if create != want.added || update != want.modified || destroy != want.removed { t.Errorf("%s: expected {added:%d modified:%d removed:%d}, got {added:%d modified:%d removed:%d}", label, want.added, want.modified, want.removed, create, update, destroy) @@ -676,7 +717,7 @@ func groupedUnder(ref plan.ResourceRef) func(*plan.ResourcePlan) bool { return func(p *plan.ResourcePlan) bool { return p.Composite != nil && *p.Composite == ref } } -func countActions(plans []*plan.ResourcePlan) (create, update, destroy int) { +func countActions(plans []*plan.ResourcePlan) (create, update, destroy, replace int) { for _, p := range plans { switch p.Action { case plan.ActionCreate: @@ -685,11 +726,13 @@ func countActions(plans []*plan.ResourcePlan) (create, update, destroy int) { update++ case plan.ActionDelete: destroy++ - case plan.ActionReplace, plan.ActionNoOp: + case plan.ActionReplace: + replace++ + case plan.ActionNoOp: } } - return create, update, destroy + return create, update, destroy, replace } // findPlan returns the plan for ref grouped under composite. From 1f323ae5d1b0f4aadb1264e16b0366788aa76910 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:08:25 -0400 Subject: [PATCH 110/126] test(simulate): unify plan expectations behind one builder Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 2 +- internal/simulate/simtest/plan_assertions.go | 33 ++-- .../simulate/simtest/structured_assertions.go | 179 +++++++----------- .../simtest/structured_assertions_test.go | 41 +++- 4 files changed, 128 insertions(+), 127 deletions(-) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 581cd952..9bd913d2 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -5549,7 +5549,7 @@ Summary: 1 modified`, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, expectedStructuredCompOutput: simtest.ExpectCompDiff(). - WithError("XCycleA/test-cycle"). + WithErrorID("XCycleA/test-cycle"). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). WithXRImpact("XCycleA", "test-cycle", "default", "error"). diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index eeb3129c..ff20c433 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -56,9 +56,9 @@ func ParseResult(jsonOutput string) (plan.Result, error) { return res, nil } -// AssertStructuredDiff checks an xr run's plan.Result against expected. Accepts any builder level, so -// callers don't need to call And() to return to the root. -func AssertStructuredDiff(t *testing.T, jsonOutput string, e DiffExpectation) { +// AssertStructured checks a plan.Result against expected, running each section only if it was +// expected. Accepts any builder level, so callers don't need And()/AndXR()/AndComp(). +func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { t.Helper() expected := e.expectation() @@ -99,21 +99,6 @@ func AssertStructuredDiff(t *testing.T, jsonOutput string, e DiffExpectation) { assertWarnings(t, res.Warnings, warningMatches(expected.warnings, func(w *WarningExpectation) warningMatch { return w.warningMatch }), expected.noWarnings) assertXRExpectations(t, res, expected.xrs) -} - -// AssertStructuredCompDiff checks a comp run's plan.Result against expected. Accepts any builder level, -// so callers don't need to call And()/AndXR()/AndComp(). -func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e CompDiffExpectation) { - t.Helper() - - expected := e.compExpectation() - - res, err := ParseResult(jsonOutput) - if err != nil { - t.Fatalf("%v\nOutput was:\n%s", err, jsonOutput) - } - - assertWarnings(t, res.Warnings, warningMatches(expected.warnings, func(w *CompWarningExpectation) warningMatch { return w.warningMatch }), expected.noWarnings) for _, id := range expected.errorIDs { if findMatchingError(res.Errors, id) == nil { @@ -126,6 +111,18 @@ func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e CompDiffExpecta } } +// AssertStructuredDiff is the pre-unification name of AssertStructured. +func AssertStructuredDiff(t *testing.T, jsonOutput string, e Expectation) { + t.Helper() + AssertStructured(t, jsonOutput, e) +} + +// AssertStructuredCompDiff is the pre-unification name of AssertStructured. +func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e Expectation) { + t.Helper() + AssertStructured(t, jsonOutput, e) +} + func assertComposition(t *testing.T, res plan.Result, want *CompositionExpectation) { t.Helper() diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index 6fae1ab8..51804920 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -8,14 +8,36 @@ import ( dt "github.com/crossplane/cli/v2/internal/diff/result" ) -// DiffExpectation is an interface that allows AssertStructuredDiff to accept -// either *ExpectedDiff or *ResourceExpectation, eliminating the need for And(). -type DiffExpectation interface { - expectation() *ExpectedDiff +// Expectation is implemented by every level of the builder hierarchy, so AssertStructured accepts +// any node and callers never need And()/AndXR()/AndComp() to climb back to the root. +type Expectation interface { + expectation() *Expected } -// ExpectedDiff is a fluent builder for test expectations on structured diff output. -type ExpectedDiff struct { +// DiffExpectation is the pre-unification name of Expectation. +type DiffExpectation = Expectation + +// CompDiffExpectation is the pre-unification name of Expectation. +type CompDiffExpectation = Expectation + +// ExpectedDiff is the pre-unification name of Expected. +type ExpectedDiff = Expected + +// ExpectedCompDiff is the pre-unification name of Expected. +type ExpectedCompDiff = Expected + +// CompWarningExpectation is the pre-unification name of WarningExpectation. +type CompWarningExpectation = WarningExpectation + +// Expected is a fluent builder for test expectations on a plan.Result, covering both xr-style sections +// (summary, flat resources, errors, XR groups) and composition-style sections. Each section is asserted +// only if it was expected. +type Expected struct { + // compositions are asserted by composition ref, with no count pinned. + compositions []*CompositionExpectation + // errorIDs are resourceIDs the top-level errors[] must contain (no count pinned); see WithErrorID. + errorIDs []string + summary *expectedSummary resources []*ResourceExpectation errors []*ErrorExpectation @@ -37,12 +59,12 @@ type warningMatch struct { type WarningExpectation struct { warningMatch - parent *ExpectedDiff + parent *Expected } -func (w *WarningExpectation) expectation() *ExpectedDiff { return w.parent } +func (w *WarningExpectation) expectation() *Expected { return w.parent } -func (e *ExpectedDiff) expectation() *ExpectedDiff { return e } +func (e *Expected) expectation() *Expected { return e } type expectedSummary struct { added int @@ -52,7 +74,7 @@ type expectedSummary struct { // ResourceExpectation defines expectations for a single resource change. type ResourceExpectation struct { - parent *ExpectedDiff + parent *Expected xrParent *XRExpectation // set when this change is scoped to an xrs[] entry changeType string // "added", "modified", "removed" kind string @@ -89,19 +111,26 @@ type dryRunExpectation struct { // change (r.parent set); an XR-scoped change (r.xrParent set) is built as a // standalone sub-expression passed to WithXRs and is never walked back to a // root, so r.parent is nil there and that is fine. -func (r *ResourceExpectation) expectation() *ExpectedDiff { +func (r *ResourceExpectation) expectation() *Expected { return r.parent } -// ExpectDiff creates a new ExpectedDiff builder. -func ExpectDiff() *ExpectedDiff { - return &ExpectedDiff{ - resources: make([]*ResourceExpectation, 0), +// Expect creates a new Expected builder. +func Expect() *Expected { + return &Expected{ + resources: make([]*ResourceExpectation, 0), + compositions: make([]*CompositionExpectation, 0), } } +// ExpectDiff is the pre-unification name of Expect. +func ExpectDiff() *Expected { return Expect() } + +// ExpectCompDiff is the pre-unification name of Expect. +func ExpectCompDiff() *Expected { return Expect() } + // WithSummary sets the expected summary counts. -func (e *ExpectedDiff) WithSummary(added, modified, removed int) *ExpectedDiff { +func (e *Expected) WithSummary(added, modified, removed int) *Expected { e.summary = &expectedSummary{ added: added, modified: modified, @@ -112,7 +141,7 @@ func (e *ExpectedDiff) WithSummary(added, modified, removed int) *ExpectedDiff { } // WithAddedResource adds an expectation for an added resource. -func (e *ExpectedDiff) WithAddedResource(kind, name, namespace string) *ResourceExpectation { +func (e *Expected) WithAddedResource(kind, name, namespace string) *ResourceExpectation { r := &ResourceExpectation{ parent: e, changeType: dt.DiffTypeWordAdded, @@ -129,7 +158,7 @@ func (e *ExpectedDiff) WithAddedResource(kind, name, namespace string) *Resource } // WithModifiedResource adds an expectation for a modified resource. -func (e *ExpectedDiff) WithModifiedResource(kind, name, namespace string) *ResourceExpectation { +func (e *Expected) WithModifiedResource(kind, name, namespace string) *ResourceExpectation { r := &ResourceExpectation{ parent: e, changeType: dt.DiffTypeWordModified, @@ -149,13 +178,13 @@ func (e *ExpectedDiff) WithModifiedResource(kind, name, namespace string) *Resou // errors[] payload. Match is by ResourceID; ValidationFailures, when // non-nil, asserts the per-resource typed validation breakdown. type ErrorExpectation struct { - parent *ExpectedDiff + parent *Expected resourceID string messageContains string validationFailures []*ValidationFailureExpectation } -func (e *ErrorExpectation) expectation() *ExpectedDiff { return e.parent } +func (e *ErrorExpectation) expectation() *Expected { return e.parent } // ValidationFailureExpectation describes one expected entry in // errors[].validationFailures[]. Match is by APIVersion/Kind/Name; @@ -170,7 +199,7 @@ type ValidationFailureExpectation struct { errors []*FieldErrorExpectation // optional; nil = no assertion on per-field errors } -func (v *ValidationFailureExpectation) expectation() *ExpectedDiff { return v.parent.parent } +func (v *ValidationFailureExpectation) expectation() *Expected { return v.parent.parent } // FieldErrorExpectation describes one expected entry in // errors[].validationFailures[].errors[]. Match is by Type+Field @@ -185,11 +214,11 @@ type FieldErrorExpectation struct { value any } -func (f *FieldErrorExpectation) expectation() *ExpectedDiff { return f.parent.parent.parent } +func (f *FieldErrorExpectation) expectation() *Expected { return f.parent.parent.parent } // WithWarning asserts that structured output carries a warning whose message contains the supplied // substring. Chain WithWarningContext to pin the machine-readable context pairs. -func (e *ExpectedDiff) WithWarning(messageContains string) *WarningExpectation { +func (e *Expected) WithWarning(messageContains string) *WarningExpectation { w := &WarningExpectation{ parent: e, warningMatch: warningMatch{messageContains: messageContains}, @@ -202,7 +231,7 @@ func (e *ExpectedDiff) WithWarning(messageContains string) *WarningExpectation { // WithNoWarnings asserts that structured output carries no advisories at all. Distinct from simply // declaring no WithWarning expectations, which asserts nothing: this is how a test pins that a // condition the tool used to warn about is correctly silent. -func (e *ExpectedDiff) WithNoWarnings() *ExpectedDiff { +func (e *Expected) WithNoWarnings() *Expected { e.noWarnings = true return e } @@ -214,12 +243,12 @@ func (w *WarningExpectation) WithWarningContext(context map[string]string) *Warn } // And returns to the parent builder. -func (w *WarningExpectation) And() *ExpectedDiff { return w.parent } +func (w *WarningExpectation) And() *Expected { return w.parent } // WithError attaches an expectation for an entry in the structured // errors[] payload, matched by resourceID. Use the returned // ErrorExpectation to add validationFailures expectations. -func (e *ExpectedDiff) WithError(resourceID string) *ErrorExpectation { +func (e *Expected) WithError(resourceID string) *ErrorExpectation { er := &ErrorExpectation{ parent: e, resourceID: resourceID, @@ -257,7 +286,7 @@ func (e *ErrorExpectation) WithValidationFailure(apiVersion, kind, name, namespa // AndError returns to the parent ExpectedDiff to chain another // WithError call, mirroring the AndXR / AndComp helpers elsewhere in // this file. -func (e *ErrorExpectation) AndError() *ExpectedDiff { +func (e *ErrorExpectation) AndError() *Expected { return e.parent } @@ -314,7 +343,7 @@ func (f *FieldErrorExpectation) AndFieldError() *ValidationFailureExpectation { } // WithRemovedResource adds an expectation for a removed resource. -func (e *ExpectedDiff) WithRemovedResource(kind, name, namespace string) *ResourceExpectation { +func (e *Expected) WithRemovedResource(kind, name, namespace string) *ResourceExpectation { r := &ResourceExpectation{ parent: e, changeType: dt.DiffTypeWordRemoved, @@ -425,7 +454,7 @@ func (r *ResourceExpectation) WithAnyName() *ResourceExpectation { // And returns to the root ExpectedDiff to chain more expectations. // Not needed at the end of a chain - AssertStructuredDiff accepts a // ResourceExpectation directly. -func (r *ResourceExpectation) And() *ExpectedDiff { +func (r *ResourceExpectation) And() *Expected { return r.expectation() } @@ -478,7 +507,7 @@ func (r *ResourceExpectation) xrExpectation() *XRExpectation { return r.xrParent // WithXRs attaches per-input-XR expectations (built via XR(...)) to assert // against the xrs[] grouped view, in order. -func (e *ExpectedDiff) WithXRs(xrs ...xrSpec) *ExpectedDiff { +func (e *Expected) WithXRs(xrs ...xrSpec) *Expected { for _, x := range xrs { e.xrs = append(e.xrs, x.xrExpectation()) } @@ -487,9 +516,9 @@ func (e *ExpectedDiff) WithXRs(xrs ...xrSpec) *ExpectedDiff { } // Build terminates the chain and returns the root expectation. Optional — -// AssertStructuredDiff accepts an *ExpectedDiff directly — but it reads as an +// AssertStructuredDiff accepts an *Expected directly — but it reads as an // explicit "done building" marker. -func (e *ExpectedDiff) Build() *ExpectedDiff { return e } +func (e *Expected) Build() *Expected { return e } // Status asserts the entry's status (changed/unchanged/error). func (x *XRExpectation) Status(status string) *XRExpectation { @@ -542,71 +571,16 @@ func (x *XRExpectation) Change(changeType, kind, name, namespace string) *Resour return r } -// CompDiffExpectation is an interface that allows AssertStructuredCompDiff to accept -// any level of the builder hierarchy, eliminating the need for And()/AndXR()/AndComp(). -type CompDiffExpectation interface { - compExpectation() *ExpectedCompDiff -} - -// ExpectedCompDiff is a fluent builder for test expectations on composition diff output. -type ExpectedCompDiff struct { - compositions []*CompositionExpectation - warnings []*CompWarningExpectation - // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". - noWarnings bool - // errorIDs are resourceIDs the top-level errors[] must contain. - errorIDs []string -} - -func (e *ExpectedCompDiff) compExpectation() *ExpectedCompDiff { return e } - -// CompWarningExpectation describes one expected entry in a comp diff's warnings[]. -type CompWarningExpectation struct { - warningMatch - - parent *ExpectedCompDiff -} - -func (w *CompWarningExpectation) compExpectation() *ExpectedCompDiff { return w.parent } - -// WithWarning asserts that structured output carries a warning whose message contains the supplied -// substring. Chain WithWarningContext to pin the machine-readable context pairs. -func (e *ExpectedCompDiff) WithWarning(messageContains string) *CompWarningExpectation { - w := &CompWarningExpectation{ - parent: e, - warningMatch: warningMatch{messageContains: messageContains}, - } - e.warnings = append(e.warnings, w) - - return w -} - -// WithError asserts the top-level errors[] contains an error with the given resourceID. -func (e *ExpectedCompDiff) WithError(resourceID string) *ExpectedCompDiff { +// WithErrorID asserts the top-level errors[] contains an error with the given resourceID. Unlike +// WithError it pins neither the error's details nor the error count. +func (e *Expected) WithErrorID(resourceID string) *Expected { e.errorIDs = append(e.errorIDs, resourceID) return e } -// WithNoWarnings asserts that structured output carries no advisories at all. Distinct from simply -// declaring no WithWarning expectations, which asserts nothing: this is how a test pins that a -// condition the tool warns about elsewhere is correctly silent here. -func (e *ExpectedCompDiff) WithNoWarnings() *ExpectedCompDiff { - e.noWarnings = true - return e -} - -// WithWarningContext pins the expected context key/value pairs on the warning under construction. -func (w *CompWarningExpectation) WithWarningContext(context map[string]string) *CompWarningExpectation { - w.context = context - return w -} - -// And returns to the parent builder. -func (w *CompWarningExpectation) And() *ExpectedCompDiff { return w.parent } - // CompositionExpectation defines expectations for a single composition in the diff. type CompositionExpectation struct { - parent *ExpectedCompDiff + parent *Expected name string affectedTotal *int affectedWithChanges *int @@ -647,7 +621,7 @@ type expectedPredictedRevisionName struct { pattern string } -func (c *CompositionExpectation) compExpectation() *ExpectedCompDiff { return c.parent } +func (c *CompositionExpectation) expectation() *Expected { return c.parent } // XRImpactExpectation defines expectations for a single XR impact. type XRImpactExpectation struct { @@ -664,17 +638,10 @@ type XRImpactExpectation struct { downstreamResources []*DownstreamResourceExpectation } -func (x *XRImpactExpectation) compExpectation() *ExpectedCompDiff { return x.parent.parent } - -// ExpectCompDiff creates a new ExpectedCompDiff builder. -func ExpectCompDiff() *ExpectedCompDiff { - return &ExpectedCompDiff{ - compositions: make([]*CompositionExpectation, 0), - } -} +func (x *XRImpactExpectation) expectation() *Expected { return x.parent.parent } // WithComposition adds an expectation for a composition. -func (e *ExpectedCompDiff) WithComposition(name string) *CompositionExpectation { +func (e *Expected) WithComposition(name string) *CompositionExpectation { c := &CompositionExpectation{ parent: e, name: name, @@ -855,7 +822,7 @@ type DownstreamResourceExpectation struct { dryRun dryRunExpectation } -func (d *DownstreamResourceExpectation) compExpectation() *ExpectedCompDiff { +func (d *DownstreamResourceExpectation) expectation() *Expected { return d.parent.parent.parent } @@ -937,7 +904,7 @@ func (x *XRImpactExpectation) AndComp() *CompositionExpectation { // And returns to parent to chain more composition expectations. // Not needed at the end of a chain - AssertStructuredCompDiff accepts CompositionExpectation directly. -func (c *CompositionExpectation) And() *ExpectedCompDiff { +func (c *CompositionExpectation) And() *Expected { return c.parent } @@ -948,17 +915,17 @@ func (c *CompositionExpectation) And() *ExpectedCompDiff { // multi-composition expectation readable instead of ending each composition with a run of climbs // whose purpose is not obvious from reading them. func (d *DownstreamResourceExpectation) AndComposition(name string) *CompositionExpectation { - return d.compExpectation().WithComposition(name) + return d.expectation().WithComposition(name) } // AndComposition starts an expectation for another composition. See // DownstreamResourceExpectation.AndComposition. func (x *XRImpactExpectation) AndComposition(name string) *CompositionExpectation { - return x.compExpectation().WithComposition(name) + return x.expectation().WithComposition(name) } // AndComposition starts an expectation for another composition. See // DownstreamResourceExpectation.AndComposition. func (c *CompositionExpectation) AndComposition(name string) *CompositionExpectation { - return c.compExpectation().WithComposition(name) + return c.expectation().WithComposition(name) } diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 1eb5b150..1dd9ecbd 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -293,7 +293,7 @@ func TestAssertStructuredCompDiff(t *testing.T) { // Counts are per composition: comp-b's errored composite is not comp-a's. "CountsArePerComposition": {want: ExpectCompDiff().WithComposition("comp-a").WithAffectedResources(4, 1, 1, 1), wantFail: true}, "OtherComposition": {want: ExpectCompDiff(). - WithError("Composition/comp-b"). + WithErrorID("Composition/comp-b"). WithComposition("comp-b"). WithCompositionErrorContaining("cannot convert"). WithImpactAnalysisSkipped(). @@ -319,7 +319,7 @@ func TestAssertStructuredCompDiff(t *testing.T) { want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "unchanged").WithErrorResourceID("XNop/xr-1"), wantFail: true, }, - "MissingTopLevelError": {want: ExpectCompDiff().WithError("Composition/comp-a"), wantFail: true}, + "MissingTopLevelError": {want: ExpectCompDiff().WithErrorID("Composition/comp-a"), wantFail: true}, } for name, tc := range cases { @@ -387,3 +387,40 @@ func TestValueAt(t *testing.T) { }) } } + +// TestAssertStructuredCombined checks that xr-style and composition-style sections of one Expect() +// are asserted against a single plan.Result, and that each still fails where it did on its own. +func TestAssertStructuredCombined(t *testing.T) { + res := xrResult() + res.Compositions = []plan.CompositionResult{{Ref: compA, RevisionImpact: &plan.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}}} + res.Composites = append(res.Composites, plan.CompositeResult{Ref: xr1, Status: plan.CompositeChanged, Composition: &compA}) + out := resultJSON(t, res) + + combined := func() *Expected { + e := Expect().WithSummary(1, 1, 1).WithError("XBucket/broken-xr").AndError().WithWarning("advisory").And(). + WithXRs( + XR("XBucket", "changed-xr", "default").Status("changed"), + XR("XBucket", "unchanged-xr", "default").Status("unchanged"), + XR("XBucket", "broken-xr", "default").Status("error"), + XR("XNop", "xr-1", "default").Status("changed")) + e.WithComposition("comp-a").WithRevisionImpact("spec", true, 1).WithXRImpact("XNop", "xr-1", "default", "changed") + + return e + } + + cases := map[string]struct { + want Expectation + wantFail bool + }{ + "Combined": {want: combined()}, + "WrongSummary": {want: combined().WithSummary(2, 1, 1), wantFail: true}, + "WrongRevision": {want: combined().WithComposition("comp-a").WithRevisionImpact("metadata", true, 1), wantFail: true}, + "WrongXRImpact": {want: combined().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "unchanged"), wantFail: true}, + "MissingErrorID": {want: combined().WithErrorID("Composition/comp-a"), wantFail: true}, + "AbsentFieldFails": {want: Expect().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldAbsent("spec.region"), wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) + } +} From ec39f5e82b08f816875a3fe2bddc0a915f2443d5 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:20:56 -0400 Subject: [PATCH 111/126] test(simulate): merge the downstream and flat resource expectations Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../simulate/simtest/structured_assertions.go | 134 +++++------------- .../simtest/structured_assertions_test.go | 40 ++++++ 2 files changed, 75 insertions(+), 99 deletions(-) diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index 51804920..9245e581 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -75,8 +75,9 @@ type expectedSummary struct { // ResourceExpectation defines expectations for a single resource change. type ResourceExpectation struct { parent *Expected - xrParent *XRExpectation // set when this change is scoped to an xrs[] entry - changeType string // "added", "modified", "removed" + xrParent *XRExpectation // set when this change is scoped to an xrs[] entry + impactParent *XRImpactExpectation // set when this is a downstream resource under an XR impact + changeType string // "added", "modified", "removed" kind string name string namePattern *regexp.Regexp @@ -108,10 +109,14 @@ type dryRunExpectation struct { } // expectation returns the root builder. Only meaningful for a flat top-level -// change (r.parent set); an XR-scoped change (r.xrParent set) is built as a -// standalone sub-expression passed to WithXRs and is never walked back to a -// root, so r.parent is nil there and that is fine. +// change (r.parent set) or a downstream resource (r.impactParent set); an XR-scoped +// change (r.xrParent set) is built as a standalone sub-expression passed to WithXRs +// and is never walked back to a root, so it returns nil there and that is fine. func (r *ResourceExpectation) expectation() *Expected { + if r.impactParent != nil { + return r.impactParent.expectation() + } + return r.parent } @@ -455,7 +460,12 @@ func (r *ResourceExpectation) WithAnyName() *ResourceExpectation { // Not needed at the end of a chain - AssertStructuredDiff accepts a // ResourceExpectation directly. func (r *ResourceExpectation) And() *Expected { - return r.expectation() + e := r.expectation() + if e == nil { + panic("And chained on an XR-scoped change; it has no root builder") + } + + return e } // Change opens a sibling change on the same enclosing XR entry (only valid on a @@ -635,7 +645,7 @@ type XRImpactExpectation struct { errorContains string // substring of an "error" impact's Error; only checked when set errorIDs []string // resourceIDs the plan.Result errors[] attributed to the XR must contain downstreamSummary *expectedSummary - downstreamResources []*DownstreamResourceExpectation + downstreamResources []*ResourceExpectation } func (x *XRImpactExpectation) expectation() *Expected { return x.parent.parent } @@ -791,9 +801,9 @@ func (x *XRImpactExpectation) WithDownstreamSummary(added, modified, removed int // WithDownstreamResource adds an expectation for a downstream resource change. // Use this to assert on specific field-level changes in downstream resources. -func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, namespace string) *DownstreamResourceExpectation { - r := &DownstreamResourceExpectation{ - parent: x, +func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + impactParent: x, changeType: changeType, kind: kind, name: name, @@ -807,93 +817,19 @@ func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, nam return r } -// DownstreamResourceExpectation defines expectations for a downstream resource change. -type DownstreamResourceExpectation struct { - parent *XRImpactExpectation - changeType string // "added", "modified", "removed" - kind string - name string - namePattern *regexp.Regexp - namespace string - anyNameAllowed bool - fieldChanges map[string][2]any // For modified: field path -> [old, new] - fieldValues map[string]any // For added/removed: field path -> expected value - fieldValuePatterns map[string]*regexp.Regexp // For pattern matching field values - dryRun dryRunExpectation -} - -func (d *DownstreamResourceExpectation) expectation() *Expected { - return d.parent.parent.parent -} - -// WithFieldChange asserts a field changed from old to new value (for modified resources). -func (d *DownstreamResourceExpectation) WithFieldChange(path string, oldValue, newValue any) *DownstreamResourceExpectation { - d.fieldChanges[path] = [2]any{oldValue, newValue} - return d -} - -// WithFieldAdded asserts a field was added (for modified resources). -// Equivalent to WithFieldChange(path, nil, value). -func (d *DownstreamResourceExpectation) WithFieldAdded(path string, value any) *DownstreamResourceExpectation { - return d.WithFieldChange(path, nil, value) -} - -// WithFieldRemoved asserts a field was removed (for modified resources). -// Equivalent to WithFieldChange(path, value, nil). -func (d *DownstreamResourceExpectation) WithFieldRemoved(path string, value any) *DownstreamResourceExpectation { - return d.WithFieldChange(path, value, nil) -} - -// WithField asserts a specific field exists with exact value (for added/removed resources). -func (d *DownstreamResourceExpectation) WithField(path string, value any) *DownstreamResourceExpectation { - d.fieldValues[path] = value - return d -} - -// WithFieldValuePattern asserts a field value matches a regex pattern. -// Use this for fields with generated/dynamic values like resource names with random suffixes. -func (d *DownstreamResourceExpectation) WithFieldValuePattern(path, pattern string) *DownstreamResourceExpectation { - d.fieldValuePatterns[path] = regexp.MustCompile(pattern) - return d -} - -// WithDryRunSkipped asserts this downstream change reports that its desired state -// was NOT verified against the apiserver, for the given reason (one of the -// dt.DryRunSkipReason values: "disabled", "forbidden", "webhookUnavailable"). -// -// Pass a detailSubstring to additionally require that the cluster's own -// explanation was propagated rather than swallowed; pass "" to skip that check. -func (d *DownstreamResourceExpectation) WithDryRunSkipped(reason, detailSubstring string) *DownstreamResourceExpectation { - d.dryRun.skipReason = reason - d.dryRun.detailSubstring = detailSubstring - - return d -} - -// WithDryRunPerformed asserts this downstream change carries NO dryRun object, -// i.e. its desired state did go through the apiserver. See the ResourceExpectation -// counterpart for why the happy path is worth asserting explicitly. -func (d *DownstreamResourceExpectation) WithDryRunPerformed() *DownstreamResourceExpectation { - d.dryRun.mustBePerformed = true - return d -} - -// WithAnyName allows any resource name (useful for generated names). -func (d *DownstreamResourceExpectation) WithAnyName() *DownstreamResourceExpectation { - d.anyNameAllowed = true - return d -} +// DownstreamResourceExpectation is the same type as ResourceExpectation: one builder carries the +// field, dry-run and name methods for a flat resource and for one under a composition's XR impact. +type DownstreamResourceExpectation = ResourceExpectation -// WithNamePattern matches resource name against a regex pattern. -func (d *DownstreamResourceExpectation) WithNamePattern(pattern string) *DownstreamResourceExpectation { - d.namePattern = regexp.MustCompile(pattern) - return d -} +// AndXR returns to the enclosing XR impact to chain more downstream resource expectations. +// Not needed at the end of a chain - AssertStructured accepts a ResourceExpectation directly. +// Panics if called on a resource not opened via WithDownstreamResource. +func (r *ResourceExpectation) AndXR() *XRImpactExpectation { + if r.impactParent == nil { + panic("AndXR chained on a resource not opened via WithDownstreamResource") + } -// AndXR returns to parent to chain more downstream resource expectations. -// Not needed at the end of a chain - AssertStructuredCompDiff accepts DownstreamResourceExpectation directly. -func (d *DownstreamResourceExpectation) AndXR() *XRImpactExpectation { - return d.parent + return r.impactParent } // AndComp returns to parent to chain more XR impact expectations. @@ -914,18 +850,18 @@ func (c *CompositionExpectation) And() *Expected { // functions accept any builder level. Spelling the sibling case as one intention-named call keeps a // multi-composition expectation readable instead of ending each composition with a run of climbs // whose purpose is not obvious from reading them. -func (d *DownstreamResourceExpectation) AndComposition(name string) *CompositionExpectation { - return d.expectation().WithComposition(name) +func (r *ResourceExpectation) AndComposition(name string) *CompositionExpectation { + return r.expectation().WithComposition(name) } // AndComposition starts an expectation for another composition. See -// DownstreamResourceExpectation.AndComposition. +// ResourceExpectation.AndComposition. func (x *XRImpactExpectation) AndComposition(name string) *CompositionExpectation { return x.expectation().WithComposition(name) } // AndComposition starts an expectation for another composition. See -// DownstreamResourceExpectation.AndComposition. +// ResourceExpectation.AndComposition. func (c *CompositionExpectation) AndComposition(name string) *CompositionExpectation { return c.expectation().WithComposition(name) } diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 1dd9ecbd..f142dd67 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -424,3 +424,43 @@ func TestAssertStructuredCombined(t *testing.T) { checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) } } + +func TestResourceExpectationParents(t *testing.T) { + root := Expect() + flat := root.WithAddedResource("K", "n", "ns") + + if flat.And() != root { + t.Error("flat And() must return the root") + } + + impact := root.WithComposition("c").WithXRImpact("XR", "x", "ns", "changed") + down := impact.WithDownstreamResource("modified", "K", "n", "ns") + + if down.AndXR() != impact { + t.Error("downstream AndXR() must return the XR impact") + } + + if down.And() != root { + t.Error("downstream And() must return the root") + } + + var _ Expectation = down + + if down.expectation() != root { + t.Error("downstream expectation() must resolve to the root") + } + + for name, fn := range map[string]func(){ + "AndXR on flat": func() { flat.AndXR() }, + "And on XR-scoped": func() { XR("X", "x", "ns").Change("added", "K", "n", "ns").And() }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: expected panic, not a nil parent", name) + } + }() + fn() + }() + } +} From bdcd772310bf9c4bffeb273145971f9d805d9e24 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:23:54 -0400 Subject: [PATCH 112/126] test(simulate): enforce spec and absent-field checks on downstream resources Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 20 ++++++++++++++----- .../simtest/structured_assertions_test.go | 5 +++++ 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index ff20c433..da5db1b6 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -325,6 +325,7 @@ func assertXRImpact(t *testing.T, res plan.Result, label string, composites []pl id := fmt.Sprintf("%s: downstream %s %s/%s", label, wantRes.changeType, wantRes.kind, p.Name) assertDryRun(t, id, p.DryRun, wantRes.dryRun) assertFields(t, id, p, wantRes.changeType, wantRes.fieldValues, wantRes.fieldChanges, wantRes.fieldValuePatterns) + assertSpec(t, id, p, wantRes) } } @@ -428,11 +429,20 @@ func assertResourceExpectations(t *testing.T, scope string, changes []*plan.Reso assertDryRun(t, id, p.DryRun, want.dryRun) assertFields(t, id, p, want.changeType, want.fieldValues, want.fieldChanges, want.fieldValuePatterns) - if want.specMatch != nil { - if spec := valueAt(t, p.Changes, want.changeType != dt.DiffTypeWordRemoved, "spec"); !reflect.DeepEqual(spec, want.specMatch) { - t.Errorf("%s: spec mismatch: expected %v, got %v", id, want.specMatch, spec) - } - } + assertSpec(t, id, p, want) + } +} + +// assertSpec checks a plan's whole spec against the expectation's WithSpec, when set. +func assertSpec(t *testing.T, id string, p *plan.ResourcePlan, want *ResourceExpectation) { + t.Helper() + + if want.specMatch == nil { + return + } + + if spec := valueAt(t, p.Changes, want.changeType != dt.DiffTypeWordRemoved, "spec"); !reflect.DeepEqual(spec, want.specMatch) { + t.Errorf("%s: spec mismatch: expected %v, got %v", id, want.specMatch, spec) } } diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index f142dd67..c38ea7dc 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -249,6 +249,7 @@ func compResult() plan.Result { { APIVersion: "example.org/v1", Kind: "XDownstream", Name: "xr-1-abc12", Namespace: "default", Action: plan.ActionUpdate, Composite: &xr1, Changes: []plan.FieldChange{{Path: "spec.forProvider.configData", Actual: val("old"), Planned: val("new-value-42")}}, + After: &unstructured.Unstructured{Object: map[string]any{"apiVersion": "example.org/v1", "kind": "XDownstream", "metadata": map[string]any{"name": "xr-1-abc12"}, "spec": map[string]any{"forProvider": map[string]any{"afterOnly": "x"}}}}, DryRun: &plan.Verification{SkipReason: "not-found", Detail: "the server could not find it"}, }, }, @@ -315,6 +316,10 @@ func TestAssertStructuredCompDiff(t *testing.T) { "WrongDownstreamValue": {want: downstream().WithFieldChange("spec.forProvider.configData", "old", "other"), wantFail: true}, "BadValuePattern": {want: downstream().WithFieldValuePattern("spec.forProvider.configData", `^old$`), wantFail: true}, "DryRunPerformed": {want: downstream().WithDryRunPerformed(), wantFail: true}, + "SpecMatch": {want: downstream().WithSpec(map[string]any{"forProvider": map[string]any{"configData": "new-value-42"}})}, + "SpecMismatch": {want: downstream().WithSpec(map[string]any{"forProvider": map[string]any{"configData": "other"}}), wantFail: true}, + "AbsentInChanges": {want: downstream().WithFieldAbsent("spec.forProvider.configData"), wantFail: true}, + "AbsentInAfter": {want: downstream().WithFieldAbsent("spec.forProvider.afterOnly"), wantFail: true}, "ErrorOnOtherXR": { want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "unchanged").WithErrorResourceID("XNop/xr-1"), wantFail: true, From aefefd01c1898fd70de348ab5b1a5f9cb93755c1 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:26:21 -0400 Subject: [PATCH 113/126] test(simulate): call the unified plan expectation builder Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 240 +++++++++--------- internal/simulate/simtest/plan_assertions.go | 4 +- .../simulate/simtest/structured_assertions.go | 28 +- .../simtest/structured_assertions_test.go | 78 +++--- 4 files changed, 175 insertions(+), 175 deletions(-) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 9bd913d2..8e84b391 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -130,8 +130,8 @@ type IntegrationTestCase struct { // need not climb back to the root — but concrete field types here made that unreachable, which is // why every case in this file used to end with a trailing And()/AndXR()/AndComp(). A chain can now // simply end where it ends. - expectedStructuredOutput simtest.DiffExpectation // for XR JSON output assertions - expectedStructuredCompOutput simtest.CompDiffExpectation // for comp JSON output assertions + expectedStructuredOutput simtest.Expectation // for XR JSON output assertions + expectedStructuredCompOutput simtest.Expectation // for comp JSON output assertions } type XrdAPIVersion int @@ -567,12 +567,12 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC // Handle JSON output format with structured assertions if tt.outputFormat == "json" && tt.expectedStructuredOutput != nil { - simtest.AssertStructuredDiff(t, outputStr, tt.expectedStructuredOutput) + simtest.AssertStructured(t, outputStr, tt.expectedStructuredOutput) return } if tt.outputFormat == "json" && tt.expectedStructuredCompOutput != nil { - simtest.AssertStructuredCompDiff(t, outputStr, tt.expectedStructuredCompOutput) + simtest.AssertStructured(t, outputStr, tt.expectedStructuredCompOutput) return } @@ -652,7 +652,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-no-namespace-patch.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -675,7 +675,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-configmap.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("ConfigMap", "test-resource-config", "default"). WithField("data.configData", "new-value"). @@ -737,7 +737,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "test-resource", "nonexistent-namespace"). WithField("spec.coolField", "new-value"). @@ -811,7 +811,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-builtin-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("Deployment", "test-resource-deploy", "default"). // Absence of dryRun is the machine-readable claim that this desired state was @@ -846,7 +846,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-builtin-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("Deployment", "test-resource-deploy", "default"). // No detail substring: "disabled" is the one skip reason the user asked for, so @@ -896,7 +896,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-status.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XStatusResource", "test-resource", "default"). WithDryRunPerformed(). @@ -923,7 +923,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). // Both the XR and its composed resource use generateName, so both exercise the // identity restore. The pattern is the renderer's display form for a @@ -957,7 +957,7 @@ func TestDiffIntegration(t *testing.T) { awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", expectedError: true, expectedExitCode: dp.ExitCodeSchemaValidation, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). // errors[].resourceID names the input the user supplied, so the rejected composed // resource has to be identifiable from the message and the validationFailures entry. WithError("XNopResource/test-resource"). @@ -992,7 +992,7 @@ func TestDiffIntegration(t *testing.T) { inputFiles: []string{"testdata/diff/modified-xr.yaml"}, expectedError: true, expectedExitCode: dp.ExitCodeSchemaValidation, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithError("XNopResource/test-resource"). WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). @@ -1012,7 +1012,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). // Aggregate flat view still reports the merged total (2 adds per XR). WithSummary(4, 0, 0). // Grouped view: one XR(...) sub-expression per input XR, each with @@ -1049,7 +1049,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). // The top-level summary counts distinct resources. WithSummary(4, 0, 0). WithXRs( @@ -1147,7 +1147,7 @@ Summary: 2 modified`, "metadata.labels[argocd.argoproj.io/instance]", }, outputFormat: "json", - expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), expectedError: false, }, "IgnorePathsMixedChanges": { @@ -1177,7 +1177,7 @@ Summary: 2 modified`, "metadata.labels[argocd.argoproj.io/instance]", }, outputFormat: "json", - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "new-value", "modified-value"). @@ -1200,7 +1200,7 @@ Summary: 2 modified`, "testdata/diff/resources/existing-xr.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 1, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "modified-value"). @@ -1228,7 +1228,7 @@ Summary: 2 modified`, }, deleteAfterSetup: []string{"testdata/diff/resources/existing-xr-deleting.yaml"}, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1268,7 +1268,7 @@ Summary: 2 modified`, }, deleteAfterSetup: []string{"testdata/diff/resources/removal-test-ns-downstream-resource2.yaml"}, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 0, 0). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ @@ -1300,7 +1300,7 @@ Summary: 2 modified`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-env-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamEnvResource", "test-env-resource", ""). WithFieldChange("spec.forProvider.configData", "existing-config-value", "modified-config-value"). @@ -1329,7 +1329,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "testing-external-resource-data"). @@ -1364,7 +1364,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-with-external-dep"). @@ -1398,7 +1398,7 @@ Summary: 2 modified`, "testdata/diff/resources/external-res-gotpl-composition.yaml", }, inputFiles: []string{"testdata/diff/new-xr-with-cross-ns-external-dep.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -1429,7 +1429,7 @@ Summary: 2 modified`, "testdata/diff/resources/external-res-matchname-gotpl-composition.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -1535,7 +1535,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-cross-ns-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-cross-ns-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-cross-ns-data-existing-named-data-old-cross-ns-role", "cross-namespace-data-another-cross-namespace-data-external-named-clusterrole"). @@ -1673,7 +1673,7 @@ Summary: 2 modified, 2 removed`, // Function-sequencer sees both resources exist and does NOT remove them from desired state. // Since existing resources match rendered resources, there should be no changes. // XR diff outputs nothing when there are no changes (unlike comp diff which says "No changes detected"). - expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), expectedError: false, expectedExitCode: dp.ExitCodeSuccess, }, @@ -1707,7 +1707,7 @@ Summary: 2 modified, 2 removed`, }, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, // XR has NO changes // Expected: the nested XR resource2 and what it composes are removed, but XR and resource1 have no changes - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 0, 2). WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). // dryRun must be absent on a removal. A removal has no desired state to preview, so it can @@ -1753,7 +1753,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", }, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 0, 1). WithRemovedResource("XDownstreamResource", "resource-to-be-removed", "default"). WithField("spec.forProvider.configData", "existing-value"), @@ -1784,7 +1784,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 2). WithModifiedResource("XDownstreamResource", "resource-to-be-kept", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1825,7 +1825,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 2). WithModifiedResource("XDownstreamResource", "resource-to-be-kept", ""). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1861,7 +1861,7 @@ Summary: 2 modified, 2 removed`, }, // Use a composition that uses generateName for composed resources inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"). @@ -1889,7 +1889,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-downstream-with-generated-name.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 1, 0). WithAddedResource("XNopResource", "test-resource", "default"). And(). @@ -1910,7 +1910,7 @@ Summary: 2 modified, 2 removed`, }, inputFiles: []string{"testdata/diff/new-xr.yaml", "testdata/diff/generated-name-xr.yaml"}, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(4, 0, 0). WithXRs( simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0), @@ -1927,7 +1927,7 @@ Summary: 2 modified, 2 removed`, // We don't add any existing XR, as we're testing creation of a new one }, inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "", "default"). WithNamePattern(`generated-xr-\(generated\)`). @@ -1966,7 +1966,7 @@ Summary: 2 modified, 2 removed`, expectedExitCode: dp.ExitCodeDiffDetected, expectedStderrContains: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, // The warning channel's contract is both halves: stderr above, and warnings[] here. - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithXRs(simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0)). WithWarning("Ignoring a duplicate input: it is identical to an earlier one"). @@ -2050,7 +2050,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-downstream-resource.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 1, 0). WithAddedResource("XNopResource", "test-resource", "default"). WithField("spec.coolField", "new-value"). @@ -2079,7 +2079,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/first-xr.yaml", "testdata/diff/modified-xr.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 2, 0). WithAddedResource("XDownstreamResource", "first-resource", "default"). WithField("spec.forProvider.configData", "first-value"). @@ -2113,7 +2113,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/first-xr-with-composition-ref.yaml", "testdata/diff/modified-xr.yaml"}, rbacRules: leastPrivilegeRules("get", "create", "patch"), outputFormat: "json", - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 2, 0). WithAddedResource("XDownstreamResource", "first-resource", "default"). WithField("spec.forProvider.configData", "first-value"). @@ -2139,7 +2139,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/new-xr.yaml"}, outputFormat: "json", // The same diff NewResourceDiff shows, which has no such XRD. - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -2174,7 +2174,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{ "testdata/diff/xr-with-composition-ref.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -2199,7 +2199,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{ "testdata/diff/xr-with-composition-selector.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -2247,7 +2247,7 @@ Summary: 2 modified, 2 removed`, // ours to assert; the embedded k8s message wording is // left unasserted (apimachinery upgrades can shift its // phrasing without changing our output contract). - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithError("XNopResource/invalid-schema-xr"). WithValidationFailure("ns.diff.example.org/v1alpha1", "XNopResource", "invalid-schema-xr", "default"). WithStatus("invalid"). @@ -2275,7 +2275,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/new-claim.yaml"}, // The claim is defaulted with its own CRD (generated from claim-xrd.yaml), which shares the XRD's // user default spec.tier with the XR's CRD but, unlike it, does not default compositionUpdatePolicy. - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("NopClaim", "test-claim", "existing-namespace"). WithField("spec.coolField", "new-value"). @@ -2297,7 +2297,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-claim-with-claimref-composition.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("NopClaim", "test-claim", "existing-namespace"). WithField("spec.coolField", "new-value"). @@ -2327,7 +2327,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-claim.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). // The composed resource is labelled with the backing XR the claim's resourceRef names, so it // already belongs to this claim: no ownership advisory (#534). WithNoWarnings(). @@ -2363,7 +2363,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml", fieldManager: "platform-operator"}, }, inputFiles: []string{"testdata/diff/modified-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). // As in ModifiedClaimShowsDiff: the composed resource belongs to the claim's backing XR (#534). WithNoWarnings(). WithSummary(0, 2, 0). @@ -2392,7 +2392,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). WithDryRunPerformed(). @@ -2415,7 +2415,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). WithDryRunSkipped("disabled", ""). @@ -2438,7 +2438,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). WithField("spec.region", "us-east-1"). @@ -2458,7 +2458,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-rendered-defaults-xrs.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(4, 0, 0). WithAddedResource("XRegionalDatabase", "modern-database", "default"). WithField("spec.region", "us-east-1"). @@ -2501,7 +2501,7 @@ Summary: 2 modified, 2 removed`, fieldManager: "platform-operator", }, }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XNopResource", "test-resource", "default"), expectedError: false, @@ -2526,7 +2526,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml", fieldManager: CompositeReconcilerFieldManager}, {file: "testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml", fieldManager: "platform-operator"}, }, - expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), expectedError: false, expectedExitCode: dp.ExitCodeSuccess, }, @@ -2544,7 +2544,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). WithDryRunSkipped("disabled", ""). @@ -2564,7 +2564,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDefaultedDownstreamResource", "test-resource-defaulted", "default"). WithDryRunSkipped("disabled", ""). @@ -2591,7 +2591,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XVersionedDownstreamResource", "test-resource-versioned", "default"). WithDryRunSkipped("disabled", ""). @@ -2639,7 +2639,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-overrides", "default"). WithField("spec.region", "us-west-2"). @@ -2665,7 +2665,7 @@ Summary: 2 modified, 2 removed`, // Each XR should produce 3 base resources + 2 additional resources = 5 resources per XR // Plus the XR itself = 6 additions per XR // Total: 5 XRs * 6 additions = 30 additions - expectedStructuredOutput: simtest.ExpectDiff().WithSummary(30, 0, 0), + expectedStructuredOutput: simtest.Expect().WithSummary(30, 0, 0), expectedError: false, expectedExitCode: dp.ExitCodeDiffDetected, }, @@ -2684,7 +2684,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-nested-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("XChildResource", "test-parent-child", "default"). WithField("spec.childField", "parent-value"). @@ -2714,7 +2714,7 @@ Summary: 2 modified, 2 removed`, expectedError: true, expectedErrorContains: "maximum nesting depth exceeded: XCycleB/test-cycle-c-c-c-c-c-c-c-c-c-c-c (nested depth 11) is nested 11 levels deep, but --max-nested-depth is 10", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithError("XCycleA/test-cycle"). WithMessageContaining("maximum nesting depth exceeded"), }, @@ -2746,7 +2746,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -2789,7 +2789,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml", fieldManager: "platform-operator"}, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -2820,7 +2820,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-v1.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). @@ -2847,7 +2847,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-automatic", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2878,7 +2878,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-selector-stable.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-selector", "default"). // stable-* (not preview-*) proves the selector, not "latest overall", chose the revision. @@ -2922,7 +2922,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-upgrade-to-v2.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2969,7 +2969,7 @@ Summary: 2 modified, 2 removed`, // Key assertion: nothing is added or removed, proving API version migration works. The // XApiMigrateResource is stored identically at either version, so it renders unchanged; an // unmatched one would be a +++ addition plus a --- removal. Only the XR's revision ref changes. - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 1, 0). WithModifiedResource("XNopResource", "test-api-version-xr", "default"), expectedError: false, @@ -2997,7 +2997,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2-changed-field.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XApiMigrateResource", "test-api-version-xr-api-resource", "default"). WithFieldChange("spec.forProvider.configData", "test-value", "changed-value"). @@ -3023,7 +3023,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-switch-to-automatic.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -3043,7 +3043,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-xr-manual-no-ref.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-manual-no-ref", "default"). WithField("spec.forProvider.configData", "v2-new-value"). @@ -3071,7 +3071,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -3102,7 +3102,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/existing-xr-revision-ref-omitted.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), expectedExitCode: dp.ExitCodeSuccess, }, // Issue #499, the consequence for which revision renders. The cluster's ref is inherited before the @@ -3126,7 +3126,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). // v1-* (not v2-*) proves the pinned revision, not the latest, rendered. @@ -3154,7 +3154,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). @@ -3192,7 +3192,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -3228,7 +3228,7 @@ Summary: 2 modified, 2 removed`, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/unchanged-automatic-pinned-claim.yaml"}, // No warnings either: the composed resource belongs to the claim's backing XR (#534). - expectedStructuredOutput: simtest.ExpectDiff().WithNoWarnings().WithSummary(0, 0, 0), + expectedStructuredOutput: simtest.Expect().WithNoWarnings().WithSummary(0, 0, 0), expectedExitCode: dp.ExitCodeSuccess, }, // A Manual claim's pin lives on its backing XR: the claim syncer copies the ref back to a claim only @@ -3253,7 +3253,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-manual-pinned-claim.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). // The composed resource is labelled with the backing XR's name, which the claim's resourceRef // names, so it already belongs to this claim: no ownership advisory (#534). WithNoWarnings(). @@ -3287,7 +3287,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/unchanged-lagging-automatic-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 1, 0). WithModifiedResource("XDownstreamResource", "lagging-xr", ""). WithFieldChange("spec.forProvider.configData", @@ -3307,7 +3307,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-manual-default-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XManualDefault", "new-manual-default", "default"). And(). @@ -3334,7 +3334,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-selector-bound.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "selector-bound", "default"). WithFieldChange("spec.forProvider.configData", "a-existing-value", "a-modified-value"). @@ -3359,7 +3359,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-v2xrd-v1paths", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -3442,7 +3442,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-claim-nested.yaml"}, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("ClusterNopResource", "existing-parent-claim-82crv-nop", ""). And(). @@ -3470,7 +3470,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). WithField("spec.forProvider.configData", "test-value-creds"). @@ -3497,7 +3497,7 @@ Summary: 2 modified, 2 removed`, // instead, so the shortfall advisory must NOT fire. It used to, because the advisory was // raised inside the credential client, which runs before the CLI-supplied secrets are merged // in — telling a user who had just solved the problem to go and solve it. - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithNoWarnings(). WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). @@ -3525,7 +3525,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 added; stage1 NOT present (gated). - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "sequencer-test", "default"). WithField("spec.coolField", "test-value"). @@ -3547,7 +3547,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("XNopResource", "sequencer-test", "default"). WithField("spec.coolField", "test-value"). @@ -3579,7 +3579,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR and stage0 modified, stage1 added (unlocked by stage0's synthesized Ready). - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(1, 2, 0). WithModifiedResource("XNopResource", "sequencer-test", "default"). WithFieldChange("spec.coolField", "existing-value", "test-value"). @@ -3610,7 +3610,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml"), }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("XNopResource", "sequencer-test", "default"). WithFieldChange("spec.coolField", "existing-value", "test-value"). @@ -3639,7 +3639,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). @@ -3661,7 +3661,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 + stage1 all added. - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). @@ -3693,7 +3693,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/environment-config-v1beta1.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.ExpectDiff(). + expectedStructuredOutput: simtest.Expect(). WithSummary(3, 0, 0). // 2 downstream resources + 1 XR WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -3876,7 +3876,7 @@ Summary: 2 modified`, // Total counts both discovered XRs; only the live one is evaluated (1 with changes), and // the deleting one is reported via filteredByDeletion. In default-discovery mode filtered // XRs are counted but not surfaced as impact entries, so only test-resource appears. - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 1, 0, 0). @@ -3905,7 +3905,7 @@ Summary: 2 modified`, resources: []string{"default/deleting-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithFilteredByDeletion(1). @@ -3935,7 +3935,7 @@ Summary: 2 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ "resource": "XDownstreamResource/test-resource", @@ -3980,7 +3980,7 @@ Summary: 2 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("removal-test-composition"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -4107,7 +4107,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // composition's template read the revision name, the resulting change would go undetected. "Could not predict the name of the CompositionRevision", }, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). // The same advisory reaches warnings[] for machine consumers, keyed by composition so that two // compositions declining to predict stay two warnings rather than being merged as duplicates. WithWarning("Could not predict the name of the CompositionRevision"). @@ -4143,7 +4143,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithNoWarnings(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). @@ -4183,7 +4183,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). // The name was predictable, so the could-not-predict advisory must stay silent. WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). @@ -4231,7 +4231,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl includeManual: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). // One composite re-points; the Manual one is kept but adopts nothing. @@ -4272,7 +4272,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "test-namespace", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). // The claim's composed resource is labelled with its backing XR's name, which is the claim's // owner, so no "already belongs to another composite" advisory is raised (#534). WithNoWarnings(). @@ -4313,7 +4313,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, expectedStderrContains: []string{"Could not predict the name of the CompositionRevision"}, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). // The could-not-predict advisory is the only warning: the claim's composed resource is labelled // with its backing XR's name, so it raises no ownership advisory (#534). WithWarning("Could not predict the name of the CompositionRevision"). @@ -4350,7 +4350,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl analyzeOn: "spec-change", outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). WithRevisionImpact("metadata", true, 1). @@ -4384,7 +4384,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl analyzeUnchanged: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithAffectedResources(1, 1, 0, 0). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -4529,7 +4529,7 @@ Summary: 1 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -5526,7 +5526,7 @@ Summary: 1 modified`, expectedError: true, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). WithXRImpact("XCycleA", "test-cycle", "default", "error"). @@ -5548,7 +5548,7 @@ Summary: 1 modified`, expectedError: true, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithErrorID("XCycleA/test-cycle"). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). @@ -5736,7 +5736,7 @@ Summary: 2 modified`, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: impact shows stage0 modified (its configData changed); stage1 is absent from // downstream impact because sequencer gates it. - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -5764,7 +5764,7 @@ Summary: 2 modified`, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, // Expect: impact shows stage0 modified and stage1 added (eventual-state unlocks stage1). - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -5799,7 +5799,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -5826,7 +5826,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -5859,7 +5859,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -5887,7 +5887,7 @@ Summary: 2 modified`, `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=/v1, Kind=ConfigMap, namespace=nonexistent-namespace)`, }, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithWarning("their namespace does not exist yet"). WithWarningContext(map[string]string{ "gvk": "/v1, Kind=ConfigMap", @@ -5929,7 +5929,7 @@ Summary: 2 modified`, resources: []string{"default/test-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -5958,7 +5958,7 @@ Summary: 2 modified`, rbacRules: leastPrivilegeRules("get", "list", "create", "patch"), outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -5988,7 +5988,7 @@ Summary: 2 modified`, rbacRules: leastPrivilegeRules("get", "create", "patch"), outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -6015,7 +6015,7 @@ Summary: 2 modified`, resourcesCSV: "default/test-resource,default/another-resource", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -6059,7 +6059,7 @@ Summary: 2 modified`, resources: []string{"default/manual-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "filtered"). @@ -6083,7 +6083,7 @@ Summary: 2 modified`, includeManual: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "changed"), @@ -6103,7 +6103,7 @@ Summary: 2 modified`, resources: []string{"default/selector-mismatch-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). @@ -6127,7 +6127,7 @@ Summary: 2 modified`, resources: []string{"default/selector-match-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.ExpectCompDiff(). + expectedStructuredCompOutput: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-match-resource", "default", "changed"), diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index da5db1b6..11f4de9c 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -111,13 +111,13 @@ func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { } } -// AssertStructuredDiff is the pre-unification name of AssertStructured. +// AssertStructured is the pre-unification name of AssertStructured. func AssertStructuredDiff(t *testing.T, jsonOutput string, e Expectation) { t.Helper() AssertStructured(t, jsonOutput, e) } -// AssertStructuredCompDiff is the pre-unification name of AssertStructured. +// AssertStructured is the pre-unification name of AssertStructured. func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e Expectation) { t.Helper() AssertStructured(t, jsonOutput, e) diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index 9245e581..c768ab3f 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -14,19 +14,19 @@ type Expectation interface { expectation() *Expected } -// DiffExpectation is the pre-unification name of Expectation. +// Expectation is the pre-unification name of Expectation. type DiffExpectation = Expectation -// CompDiffExpectation is the pre-unification name of Expectation. +// Expectation is the pre-unification name of Expectation. type CompDiffExpectation = Expectation -// ExpectedDiff is the pre-unification name of Expected. +// Expected is the pre-unification name of Expected. type ExpectedDiff = Expected -// ExpectedCompDiff is the pre-unification name of Expected. +// Expected is the pre-unification name of Expected. type ExpectedCompDiff = Expected -// CompWarningExpectation is the pre-unification name of WarningExpectation. +// WarningExpectation is the pre-unification name of WarningExpectation. type CompWarningExpectation = WarningExpectation // Expected is a fluent builder for test expectations on a plan.Result, covering both xr-style sections @@ -288,7 +288,7 @@ func (e *ErrorExpectation) WithValidationFailure(apiVersion, kind, name, namespa return v } -// AndError returns to the parent ExpectedDiff to chain another +// AndError returns to the parent Expected to chain another // WithError call, mirroring the AndXR / AndComp helpers elsewhere in // this file. func (e *ErrorExpectation) AndError() *Expected { @@ -456,8 +456,8 @@ func (r *ResourceExpectation) WithAnyName() *ResourceExpectation { return r } -// And returns to the root ExpectedDiff to chain more expectations. -// Not needed at the end of a chain - AssertStructuredDiff accepts a +// And returns to the root Expected to chain more expectations. +// Not needed at the end of a chain - AssertStructured accepts a // ResourceExpectation directly. func (r *ResourceExpectation) And() *Expected { e := r.expectation() @@ -483,7 +483,7 @@ func (r *ResourceExpectation) Change(changeType, kind, name, namespace string) * // XRExpectation defines expectations for a single entry in the xrs[] grouped // view: the input XR's identity, its status, its per-XR summary, and its // per-XR changes. Build one with XR(...), configure it with the methods below, -// then pass it (with any siblings) to ExpectedDiff.WithXRs. +// then pass it (with any siblings) to Expected.WithXRs. type XRExpectation struct { kind string name string @@ -497,7 +497,7 @@ type XRExpectation struct { } // XR begins a standalone expectation for one xrs[] entry, keyed by the input -// XR's kind/name/namespace. Pass the result(s) to ExpectedDiff.WithXRs. +// XR's kind/name/namespace. Pass the result(s) to Expected.WithXRs. func XR(kind, name, namespace string) *XRExpectation { return &XRExpectation{kind: kind, name: name, namespace: namespace} } @@ -526,7 +526,7 @@ func (e *Expected) WithXRs(xrs ...xrSpec) *Expected { } // Build terminates the chain and returns the root expectation. Optional — -// AssertStructuredDiff accepts an *Expected directly — but it reads as an +// AssertStructured accepts an *Expected directly — but it reads as an // explicit "done building" marker. func (e *Expected) Build() *Expected { return e } @@ -817,7 +817,7 @@ func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, nam return r } -// DownstreamResourceExpectation is the same type as ResourceExpectation: one builder carries the +// ResourceExpectation is the same type as ResourceExpectation: one builder carries the // field, dry-run and name methods for a flat resource and for one under a composition's XR impact. type DownstreamResourceExpectation = ResourceExpectation @@ -833,13 +833,13 @@ func (r *ResourceExpectation) AndXR() *XRImpactExpectation { } // AndComp returns to parent to chain more XR impact expectations. -// Not needed at the end of a chain - AssertStructuredCompDiff accepts XRImpactExpectation directly. +// Not needed at the end of a chain - AssertStructured accepts XRImpactExpectation directly. func (x *XRImpactExpectation) AndComp() *CompositionExpectation { return x.parent } // And returns to parent to chain more composition expectations. -// Not needed at the end of a chain - AssertStructuredCompDiff accepts CompositionExpectation directly. +// Not needed at the end of a chain - AssertStructured accepts CompositionExpectation directly. func (c *CompositionExpectation) And() *Expected { return c.parent } diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index c38ea7dc..f9f5fef1 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -108,24 +108,24 @@ func TestParseResult(t *testing.T) { } } -func TestAssertStructuredDiff(t *testing.T) { +func TestAssertStructuredXRPlan(t *testing.T) { out := resultJSON(t, xrResult()) // Any resource expectation pins the change count, so a case about one change lists the others. - withoutBucket := func() *ExpectedDiff { - return ExpectDiff().WithAddedResource("ConfigMap", "cm-x7k2p", "default").And().WithRemovedResource("Secret", "gone", "default").And() + withoutBucket := func() *Expected { + return Expect().WithAddedResource("ConfigMap", "cm-x7k2p", "default").And().WithRemovedResource("Secret", "gone", "default").And() } - withoutConfigMap := func() *ExpectedDiff { - return ExpectDiff().WithModifiedResource("Bucket", "bucket-a", "default").And().WithRemovedResource("Secret", "gone", "default").And() + withoutConfigMap := func() *Expected { + return Expect().WithModifiedResource("Bucket", "bucket-a", "default").And().WithRemovedResource("Secret", "gone", "default").And() } cases := map[string]struct { - want DiffExpectation + want Expectation wantFail bool }{ - "Summary": {want: ExpectDiff().WithSummary(1, 1, 1)}, - "WrongSummary": {want: ExpectDiff().WithSummary(0, 1, 1), wantFail: true}, - "AllChanges": {want: ExpectDiff(). + "Summary": {want: Expect().WithSummary(1, 1, 1)}, + "WrongSummary": {want: Expect().WithSummary(0, 1, 1), wantFail: true}, + "AllChanges": {want: Expect(). WithModifiedResource("Bucket", "bucket-a", "default"). WithFieldChange("spec.region", "us-east-1", "us-west-2"). WithFieldChange("metadata.annotations['example.org/source']", "old", "new"). @@ -138,10 +138,10 @@ func TestAssertStructuredDiff(t *testing.T) { WithRemovedResource("Secret", "gone", "default"). WithField("type", "Opaque")}, // Any resource expectation pins the change count, so an unlisted change fails. - "UnlistedChange": {want: ExpectDiff(). + "UnlistedChange": {want: Expect(). WithModifiedResource("Bucket", "bucket-a", "default").And(). WithAddedResource("ConfigMap", "cm-x7k2p", "default"), wantFail: true}, - "WrongAction": {want: ExpectDiff().WithAddedResource("Bucket", "bucket-a", "default"), wantFail: true}, + "WrongAction": {want: Expect().WithAddedResource("Bucket", "bucket-a", "default"), wantFail: true}, "WrongNewValue": { want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "us-east-1", "eu-west-1"), wantFail: true, @@ -170,13 +170,13 @@ func TestAssertStructuredDiff(t *testing.T) { }, "FieldAbsent": {want: withoutConfigMap().WithAddedResource("ConfigMap", "cm-x7k2p", "default").WithFieldAbsent("spec.other")}, "FieldNotAbsent": {want: withoutConfigMap().WithAddedResource("ConfigMap", "cm-x7k2p", "default").WithFieldAbsent("data.key"), wantFail: true}, - "Error": {want: ExpectDiff().WithError("XBucket/broken-xr").WithMessageContaining("cannot get")}, - "WrongErrorMessage": {want: ExpectDiff().WithError("XBucket/broken-xr").WithMessageContaining("timeout"), wantFail: true}, - "MissingError": {want: ExpectDiff().WithError("XBucket/changed-xr"), wantFail: true}, - "Warning": {want: ExpectDiff().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "c"})}, - "WrongContext": {want: ExpectDiff().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "d"}), wantFail: true}, - "NoWarnings": {want: ExpectDiff().WithNoWarnings(), wantFail: true}, - "GroupedByXR": {want: ExpectDiff(). + "Error": {want: Expect().WithError("XBucket/broken-xr").WithMessageContaining("cannot get")}, + "WrongErrorMessage": {want: Expect().WithError("XBucket/broken-xr").WithMessageContaining("timeout"), wantFail: true}, + "MissingError": {want: Expect().WithError("XBucket/changed-xr"), wantFail: true}, + "Warning": {want: Expect().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "c"})}, + "WrongContext": {want: Expect().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "d"}), wantFail: true}, + "NoWarnings": {want: Expect().WithNoWarnings(), wantFail: true}, + "GroupedByXR": {want: Expect(). WithXRs( XR("XBucket", "changed-xr", "default"). Status("changed"). @@ -189,7 +189,7 @@ func TestAssertStructuredDiff(t *testing.T) { XR("XBucket", "broken-xr", "default").Status("error").Err("XBucket/broken-xr"), ). Build()}, - "GroupedWrongStatus": {want: ExpectDiff(). + "GroupedWrongStatus": {want: Expect(). WithXRs( XR("XBucket", "changed-xr", "default").Status("unchanged"), XR("XBucket", "unchanged-xr", "default"), @@ -197,14 +197,14 @@ func TestAssertStructuredDiff(t *testing.T) { ). Build(), wantFail: true}, // Two expectations cannot both consume the same entry. - "GroupedDuplicate": {want: ExpectDiff(). + "GroupedDuplicate": {want: Expect(). WithXRs( XR("XBucket", "changed-xr", "default"), XR("XBucket", "changed-xr", "default"), XR("XBucket", "broken-xr", "default"), ). Build(), wantFail: true}, - "GroupedErrorOnWrongXR": {want: ExpectDiff(). + "GroupedErrorOnWrongXR": {want: Expect(). WithXRs( XR("XBucket", "changed-xr", "default").Err("XBucket/broken-xr"), XR("XBucket", "unchanged-xr", "default"), @@ -214,7 +214,7 @@ func TestAssertStructuredDiff(t *testing.T) { } for name, tc := range cases { - checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructuredDiff(t, out, tc.want) }) + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) } } @@ -260,19 +260,19 @@ func compResult() plan.Result { } } -func TestAssertStructuredCompDiff(t *testing.T) { +func TestAssertStructuredCompositionPlan(t *testing.T) { out := resultJSON(t, compResult()) - downstream := func() *DownstreamResourceExpectation { - return ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "changed"). + downstream := func() *ResourceExpectation { + return Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "changed"). WithDownstreamResource("modified", "XDownstream", "xr-1-abc12", "default") } cases := map[string]struct { - want CompDiffExpectation + want Expectation wantFail bool }{ - "Composition": {want: ExpectCompDiff(). + "Composition": {want: Expect(). WithComposition("comp-a"). WithAffectedResources(3, 1, 1, 0). WithFilteredByDeletion(1). @@ -292,8 +292,8 @@ func TestAssertStructuredCompDiff(t *testing.T) { WithXRImpact("XNop", "xr-3", "default", "filtered"). WithFilterReason(string(dt.FilterReasonDeleting))}, // Counts are per composition: comp-b's errored composite is not comp-a's. - "CountsArePerComposition": {want: ExpectCompDiff().WithComposition("comp-a").WithAffectedResources(4, 1, 1, 1), wantFail: true}, - "OtherComposition": {want: ExpectCompDiff(). + "CountsArePerComposition": {want: Expect().WithComposition("comp-a").WithAffectedResources(4, 1, 1, 1), wantFail: true}, + "OtherComposition": {want: Expect(). WithErrorID("Composition/comp-b"). WithComposition("comp-b"). WithCompositionErrorContaining("cannot convert"). @@ -302,14 +302,14 @@ func TestAssertStructuredCompDiff(t *testing.T) { WithXRImpact("XNop", "xr-1", "default", "error"). WithErrorContaining("maximum nesting depth"). WithErrorResourceID("XNop/xr-1")}, - "MissingComposition": {want: ExpectCompDiff().WithComposition("comp-c"), wantFail: true}, - "WrongRevisionScope": {want: ExpectCompDiff().WithComposition("comp-a").WithRevisionImpact("metadata", true, 2), wantFail: true}, - "PredictedNameAbsent": {want: ExpectCompDiff().WithComposition("comp-a").WithoutPredictedRevisionName(), wantFail: true}, - "NoRevisionImpact": {want: ExpectCompDiff().WithComposition("comp-b").WithRevisionImpact("none", false, 0), wantFail: true}, - "NotSkipped": {want: ExpectCompDiff().WithComposition("comp-a").WithImpactAnalysisSkipped(), wantFail: true}, - "WrongXRStatus": {want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "changed"), wantFail: true}, + "MissingComposition": {want: Expect().WithComposition("comp-c"), wantFail: true}, + "WrongRevisionScope": {want: Expect().WithComposition("comp-a").WithRevisionImpact("metadata", true, 2), wantFail: true}, + "PredictedNameAbsent": {want: Expect().WithComposition("comp-a").WithoutPredictedRevisionName(), wantFail: true}, + "NoRevisionImpact": {want: Expect().WithComposition("comp-b").WithRevisionImpact("none", false, 0), wantFail: true}, + "NotSkipped": {want: Expect().WithComposition("comp-a").WithImpactAnalysisSkipped(), wantFail: true}, + "WrongXRStatus": {want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "changed"), wantFail: true}, "WrongFilterReason": { - want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-3", "default", "filtered"). + want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-3", "default", "filtered"). WithFilterReason(string(dt.FilterReasonManualPolicy)), wantFail: true, }, @@ -321,14 +321,14 @@ func TestAssertStructuredCompDiff(t *testing.T) { "AbsentInChanges": {want: downstream().WithFieldAbsent("spec.forProvider.configData"), wantFail: true}, "AbsentInAfter": {want: downstream().WithFieldAbsent("spec.forProvider.afterOnly"), wantFail: true}, "ErrorOnOtherXR": { - want: ExpectCompDiff().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "unchanged").WithErrorResourceID("XNop/xr-1"), + want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "unchanged").WithErrorResourceID("XNop/xr-1"), wantFail: true, }, - "MissingTopLevelError": {want: ExpectCompDiff().WithErrorID("Composition/comp-a"), wantFail: true}, + "MissingTopLevelError": {want: Expect().WithErrorID("Composition/comp-a"), wantFail: true}, } for name, tc := range cases { - checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructuredCompDiff(t, out, tc.want) }) + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) } } From 65f353d62ab6e6c9059413a8644d3e55b8a56eac Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:26:51 -0400 Subject: [PATCH 114/126] test(simulate): drop the pre-unification expectation names Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 12 --------- .../simulate/simtest/structured_assertions.go | 25 ------------------- 2 files changed, 37 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 11f4de9c..5fabbe59 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -111,18 +111,6 @@ func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { } } -// AssertStructured is the pre-unification name of AssertStructured. -func AssertStructuredDiff(t *testing.T, jsonOutput string, e Expectation) { - t.Helper() - AssertStructured(t, jsonOutput, e) -} - -// AssertStructured is the pre-unification name of AssertStructured. -func AssertStructuredCompDiff(t *testing.T, jsonOutput string, e Expectation) { - t.Helper() - AssertStructured(t, jsonOutput, e) -} - func assertComposition(t *testing.T, res plan.Result, want *CompositionExpectation) { t.Helper() diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index c768ab3f..1dc60aa2 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -14,21 +14,6 @@ type Expectation interface { expectation() *Expected } -// Expectation is the pre-unification name of Expectation. -type DiffExpectation = Expectation - -// Expectation is the pre-unification name of Expectation. -type CompDiffExpectation = Expectation - -// Expected is the pre-unification name of Expected. -type ExpectedDiff = Expected - -// Expected is the pre-unification name of Expected. -type ExpectedCompDiff = Expected - -// WarningExpectation is the pre-unification name of WarningExpectation. -type CompWarningExpectation = WarningExpectation - // Expected is a fluent builder for test expectations on a plan.Result, covering both xr-style sections // (summary, flat resources, errors, XR groups) and composition-style sections. Each section is asserted // only if it was expected. @@ -128,12 +113,6 @@ func Expect() *Expected { } } -// ExpectDiff is the pre-unification name of Expect. -func ExpectDiff() *Expected { return Expect() } - -// ExpectCompDiff is the pre-unification name of Expect. -func ExpectCompDiff() *Expected { return Expect() } - // WithSummary sets the expected summary counts. func (e *Expected) WithSummary(added, modified, removed int) *Expected { e.summary = &expectedSummary{ @@ -817,10 +796,6 @@ func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, nam return r } -// ResourceExpectation is the same type as ResourceExpectation: one builder carries the -// field, dry-run and name methods for a flat resource and for one under a composition's XR impact. -type DownstreamResourceExpectation = ResourceExpectation - // AndXR returns to the enclosing XR impact to chain more downstream resource expectations. // Not needed at the end of a chain - AssertStructured accepts a ResourceExpectation directly. // Panics if called on a resource not opened via WithDownstreamResource. From e01e2cae3b6e587ef18e5a7f89507f57fcdb9f6b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:29:57 -0400 Subject: [PATCH 115/126] test(diff): give integration cases one plan expectation field Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/integration_test.go | 293 ++++++++---------- 1 file changed, 134 insertions(+), 159 deletions(-) diff --git a/internal/diff/integration/integration_test.go b/internal/diff/integration/integration_test.go index 8e84b391..024501ad 100644 --- a/internal/diff/integration/integration_test.go +++ b/internal/diff/integration/integration_test.go @@ -122,16 +122,10 @@ type IntegrationTestCase struct { skip bool skipReason string // JSON output support: set outputFormat to "json" to use structured assertions. - // For XR tests, populate expectedStructuredOutput. For CompositionDiffTest tests, - // populate expectedStructuredCompOutput. Only one should be set per test case. outputFormat string // "json" or "" (default=visual diff) - // Typed as the assertion interfaces rather than the concrete root builders. The assert functions - // have accepted any builder level since those interfaces were introduced, specifically so a chain - // need not climb back to the root — but concrete field types here made that unreachable, which is - // why every case in this file used to end with a trailing And()/AndXR()/AndComp(). A chain can now - // simply end where it ends. - expectedStructuredOutput simtest.Expectation // for XR JSON output assertions - expectedStructuredCompOutput simtest.Expectation // for comp JSON output assertions + // expectedStructured is asserted against the JSON output in either mode. It is typed as the + // Expectation interface, which every builder level implements, so a chain can end where it ends. + expectedStructured simtest.Expectation } type XrdAPIVersion int @@ -314,20 +308,6 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC t.Parallel() // Enable parallel test execution - // Validate structured-output config up front so misconfiguration fails loudly - // instead of silently routing assertions to the wrong path. - if tt.expectedStructuredOutput != nil && tt.expectedStructuredCompOutput != nil { - t.Fatalf("test case sets both expectedStructuredOutput and expectedStructuredCompOutput; set only one") - } - - if tt.expectedStructuredOutput != nil && testType != XRDiffTest { - t.Fatalf("expectedStructuredOutput is only valid for XRDiffTest (got %q)", testType) - } - - if tt.expectedStructuredCompOutput != nil && testType != CompositionDiffTest { - t.Fatalf("expectedStructuredCompOutput is only valid for CompositionDiffTest (got %q)", testType) - } - // Resolve a local crossplane binary if one is present. When non-empty, // it's threaded into the kong arg slice below as // --crossplane-render-binary= so each parallel subtest has its own @@ -538,7 +518,7 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC // the structured assertion below validates errors[]. // 3. unexpected error: already caught by the t.Fatalf above. if err != nil && tt.expectedError { - hasStructuredAssertion := tt.expectedStructuredOutput != nil || tt.expectedStructuredCompOutput != nil + hasStructuredAssertion := tt.expectedStructured != nil switch { case tt.expectedErrorContains != "": @@ -558,7 +538,7 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC // substring assertion. Cases that pin a structured assertion fall // through so the JSON path is exercised against errors[] / changes. if tt.expectedError && tt.expectedErrorContains != "" && - tt.expectedStructuredOutput == nil && tt.expectedStructuredCompOutput == nil { + tt.expectedStructured == nil { return } @@ -566,13 +546,8 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC outputStr := stdout.String() // Handle JSON output format with structured assertions - if tt.outputFormat == "json" && tt.expectedStructuredOutput != nil { - simtest.AssertStructured(t, outputStr, tt.expectedStructuredOutput) - return - } - - if tt.outputFormat == "json" && tt.expectedStructuredCompOutput != nil { - simtest.AssertStructured(t, outputStr, tt.expectedStructuredCompOutput) + if tt.outputFormat == "json" && tt.expectedStructured != nil { + simtest.AssertStructured(t, outputStr, tt.expectedStructured) return } @@ -652,7 +627,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-no-namespace-patch.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -675,7 +650,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-configmap.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("ConfigMap", "test-resource-config", "default"). WithField("data.configData", "new-value"). @@ -737,7 +712,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "test-resource", "nonexistent-namespace"). WithField("spec.coolField", "new-value"). @@ -811,7 +786,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-builtin-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("Deployment", "test-resource-deploy", "default"). // Absence of dryRun is the machine-readable claim that this desired state was @@ -846,7 +821,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-builtin-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("Deployment", "test-resource-deploy", "default"). // No detail substring: "disabled" is the one skip reason the user asked for, so @@ -896,7 +871,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition-with-status.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XStatusResource", "test-resource", "default"). WithDryRunPerformed(). @@ -923,7 +898,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). // Both the XR and its composed resource use generateName, so both exercise the // identity restore. The pattern is the renderer's display form for a @@ -957,7 +932,7 @@ func TestDiffIntegration(t *testing.T) { awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", expectedError: true, expectedExitCode: dp.ExitCodeSchemaValidation, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // errors[].resourceID names the input the user supplied, so the rejected composed // resource has to be identifiable from the message and the validationFailures entry. WithError("XNopResource/test-resource"). @@ -992,7 +967,7 @@ func TestDiffIntegration(t *testing.T) { inputFiles: []string{"testdata/diff/modified-xr.yaml"}, expectedError: true, expectedExitCode: dp.ExitCodeSchemaValidation, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithError("XNopResource/test-resource"). WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). @@ -1012,7 +987,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // Aggregate flat view still reports the merged total (2 adds per XR). WithSummary(4, 0, 0). // Grouped view: one XR(...) sub-expression per input XR, each with @@ -1049,7 +1024,7 @@ func TestDiffIntegration(t *testing.T) { "testdata/diff/resources/composition.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The top-level summary counts distinct resources. WithSummary(4, 0, 0). WithXRs( @@ -1146,9 +1121,9 @@ Summary: 2 modified`, "metadata.annotations[argocd.argoproj.io/tracking-id]", "metadata.labels[argocd.argoproj.io/instance]", }, - outputFormat: "json", - expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), - expectedError: false, + outputFormat: "json", + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedError: false, }, "IgnorePathsMixedChanges": { // Regression test: when a resource has BOTH ignored path changes @@ -1177,7 +1152,7 @@ Summary: 2 modified`, "metadata.labels[argocd.argoproj.io/instance]", }, outputFormat: "json", - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "new-value", "modified-value"). @@ -1200,7 +1175,7 @@ Summary: 2 modified`, "testdata/diff/resources/existing-xr.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 1, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "modified-value"). @@ -1228,7 +1203,7 @@ Summary: 2 modified`, }, deleteAfterSetup: []string{"testdata/diff/resources/existing-xr-deleting.yaml"}, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1268,7 +1243,7 @@ Summary: 2 modified`, }, deleteAfterSetup: []string{"testdata/diff/resources/removal-test-ns-downstream-resource2.yaml"}, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 0, 0). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ @@ -1300,7 +1275,7 @@ Summary: 2 modified`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-env-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamEnvResource", "test-env-resource", ""). WithFieldChange("spec.forProvider.configData", "existing-config-value", "modified-config-value"). @@ -1329,7 +1304,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "testing-external-resource-data"). @@ -1364,7 +1339,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-with-external-dep"). @@ -1398,7 +1373,7 @@ Summary: 2 modified`, "testdata/diff/resources/external-res-gotpl-composition.yaml", }, inputFiles: []string{"testdata/diff/new-xr-with-cross-ns-external-dep.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -1429,7 +1404,7 @@ Summary: 2 modified`, "testdata/diff/resources/external-res-matchname-gotpl-composition.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -1535,7 +1510,7 @@ Summary: 2 modified`, ), }, inputFiles: []string{"testdata/diff/modified-cross-ns-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-cross-ns-resource", "default"). WithFieldChange("spec.forProvider.configData", "existing-cross-ns-data-existing-named-data-old-cross-ns-role", "cross-namespace-data-another-cross-namespace-data-external-named-clusterrole"). @@ -1673,9 +1648,9 @@ Summary: 2 modified, 2 removed`, // Function-sequencer sees both resources exist and does NOT remove them from desired state. // Since existing resources match rendered resources, there should be no changes. // XR diff outputs nothing when there are no changes (unlike comp diff which says "No changes detected"). - expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), - expectedError: false, - expectedExitCode: dp.ExitCodeSuccess, + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, }, // Issue #259: Validates that removal detection works even when the XR has no changes. // This is the core fix for the function-sequencer bug where existingXR was nil because @@ -1707,7 +1682,7 @@ Summary: 2 modified, 2 removed`, }, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, // XR has NO changes // Expected: the nested XR resource2 and what it composes are removed, but XR and resource1 have no changes - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 0, 2). WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). // dryRun must be absent on a removal. A removal has no desired state to preview, so it can @@ -1753,7 +1728,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", }, inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 0, 1). WithRemovedResource("XDownstreamResource", "resource-to-be-removed", "default"). WithField("spec.forProvider.configData", "existing-value"), @@ -1784,7 +1759,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 2). WithModifiedResource("XDownstreamResource", "resource-to-be-kept", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1825,7 +1800,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 2). WithModifiedResource("XDownstreamResource", "resource-to-be-kept", ""). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -1861,7 +1836,7 @@ Summary: 2 modified, 2 removed`, }, // Use a composition that uses generateName for composed resources inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"). @@ -1889,7 +1864,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-downstream-with-generated-name.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 1, 0). WithAddedResource("XNopResource", "test-resource", "default"). And(). @@ -1910,7 +1885,7 @@ Summary: 2 modified, 2 removed`, }, inputFiles: []string{"testdata/diff/new-xr.yaml", "testdata/diff/generated-name-xr.yaml"}, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(4, 0, 0). WithXRs( simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0), @@ -1927,7 +1902,7 @@ Summary: 2 modified, 2 removed`, // We don't add any existing XR, as we're testing creation of a new one }, inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "", "default"). WithNamePattern(`generated-xr-\(generated\)`). @@ -1966,7 +1941,7 @@ Summary: 2 modified, 2 removed`, expectedExitCode: dp.ExitCodeDiffDetected, expectedStderrContains: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, // The warning channel's contract is both halves: stderr above, and warnings[] here. - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithXRs(simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0)). WithWarning("Ignoring a duplicate input: it is identical to an earlier one"). @@ -2050,7 +2025,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-downstream-resource.yaml", }, inputFiles: []string{"testdata/diff/new-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 1, 0). WithAddedResource("XNopResource", "test-resource", "default"). WithField("spec.coolField", "new-value"). @@ -2079,7 +2054,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/first-xr.yaml", "testdata/diff/modified-xr.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 2, 0). WithAddedResource("XDownstreamResource", "first-resource", "default"). WithField("spec.forProvider.configData", "first-value"). @@ -2113,7 +2088,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/first-xr-with-composition-ref.yaml", "testdata/diff/modified-xr.yaml"}, rbacRules: leastPrivilegeRules("get", "create", "patch"), outputFormat: "json", - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 2, 0). WithAddedResource("XDownstreamResource", "first-resource", "default"). WithField("spec.forProvider.configData", "first-value"). @@ -2139,7 +2114,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/new-xr.yaml"}, outputFormat: "json", // The same diff NewResourceDiff shows, which has no such XRD. - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "new-value"). @@ -2174,7 +2149,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{ "testdata/diff/xr-with-composition-ref.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -2199,7 +2174,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{ "testdata/diff/xr-with-composition-selector.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -2247,7 +2222,7 @@ Summary: 2 modified, 2 removed`, // ours to assert; the embedded k8s message wording is // left unasserted (apimachinery upgrades can shift its // phrasing without changing our output contract). - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithError("XNopResource/invalid-schema-xr"). WithValidationFailure("ns.diff.example.org/v1alpha1", "XNopResource", "invalid-schema-xr", "default"). WithStatus("invalid"). @@ -2275,7 +2250,7 @@ Summary: 2 modified, 2 removed`, inputFiles: []string{"testdata/diff/new-claim.yaml"}, // The claim is defaulted with its own CRD (generated from claim-xrd.yaml), which shares the XRD's // user default spec.tier with the XR's CRD but, unlike it, does not default compositionUpdatePolicy. - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("NopClaim", "test-claim", "existing-namespace"). WithField("spec.coolField", "new-value"). @@ -2297,7 +2272,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-claim-with-claimref-composition.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("NopClaim", "test-claim", "existing-namespace"). WithField("spec.coolField", "new-value"). @@ -2327,7 +2302,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-claim.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The composed resource is labelled with the backing XR the claim's resourceRef names, so it // already belongs to this claim: no ownership advisory (#534). WithNoWarnings(). @@ -2363,7 +2338,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml", fieldManager: "platform-operator"}, }, inputFiles: []string{"testdata/diff/modified-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // As in ModifiedClaimShowsDiff: the composed resource belongs to the claim's backing XR (#534). WithNoWarnings(). WithSummary(0, 2, 0). @@ -2392,7 +2367,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). WithDryRunPerformed(). @@ -2415,7 +2390,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). WithDryRunSkipped("disabled", ""). @@ -2438,7 +2413,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). WithField("spec.region", "us-east-1"). @@ -2458,7 +2433,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-rendered-defaults-xrs.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(4, 0, 0). WithAddedResource("XRegionalDatabase", "modern-database", "default"). WithField("spec.region", "us-east-1"). @@ -2501,7 +2476,7 @@ Summary: 2 modified, 2 removed`, fieldManager: "platform-operator", }, }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XNopResource", "test-resource", "default"), expectedError: false, @@ -2526,9 +2501,9 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml", fieldManager: CompositeReconcilerFieldManager}, {file: "testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml", fieldManager: "platform-operator"}, }, - expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), - expectedError: false, - expectedExitCode: dp.ExitCodeSuccess, + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, }, // With no apiserver result for an addition, its defaults have to be predicted locally. These // pin that keeping local defaults out of dry-run payloads did not also take them out of the @@ -2544,7 +2519,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). WithDryRunSkipped("disabled", ""). @@ -2564,7 +2539,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDefaultedDownstreamResource", "test-resource-defaulted", "default"). WithDryRunSkipped("disabled", ""). @@ -2591,7 +2566,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XVersionedDownstreamResource", "test-resource-versioned", "default"). WithDryRunSkipped("disabled", ""). @@ -2639,7 +2614,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/composition-with-defaults.yaml", "testdata/diff/resources/functions.yaml", }, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 0, 0). WithAddedResource("XTestDefaultResource", "test-resource-with-overrides", "default"). WithField("spec.region", "us-west-2"). @@ -2665,9 +2640,9 @@ Summary: 2 modified, 2 removed`, // Each XR should produce 3 base resources + 2 additional resources = 5 resources per XR // Plus the XR itself = 6 additions per XR // Total: 5 XRs * 6 additions = 30 additions - expectedStructuredOutput: simtest.Expect().WithSummary(30, 0, 0), - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect().WithSummary(30, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, }, "NewNestedXRCreatesChildren": { reason: "Validates that new nested XR creates child XR and downstream resources", @@ -2684,7 +2659,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-nested-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("XChildResource", "test-parent-child", "default"). WithField("spec.childField", "parent-value"). @@ -2714,7 +2689,7 @@ Summary: 2 modified, 2 removed`, expectedError: true, expectedErrorContains: "maximum nesting depth exceeded: XCycleB/test-cycle-c-c-c-c-c-c-c-c-c-c-c (nested depth 11) is nested 11 levels deep, but --max-nested-depth is 10", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithError("XCycleA/test-cycle"). WithMessageContaining("maximum nesting depth exceeded"), }, @@ -2746,7 +2721,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -2789,7 +2764,7 @@ Summary: 2 modified, 2 removed`, {file: "testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml", fieldManager: "platform-operator"}, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -2820,7 +2795,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-v1.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). @@ -2847,7 +2822,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-automatic", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2878,7 +2853,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-selector-stable.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-selector", "default"). // stable-* (not preview-*) proves the selector, not "latest overall", chose the revision. @@ -2922,7 +2897,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-upgrade-to-v2.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -2969,7 +2944,7 @@ Summary: 2 modified, 2 removed`, // Key assertion: nothing is added or removed, proving API version migration works. The // XApiMigrateResource is stored identically at either version, so it renders unchanged; an // unmatched one would be a +++ addition plus a --- removal. Only the XR's revision ref changes. - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 1, 0). WithModifiedResource("XNopResource", "test-api-version-xr", "default"), expectedError: false, @@ -2997,7 +2972,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2-changed-field.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XApiMigrateResource", "test-api-version-xr-api-resource", "default"). WithFieldChange("spec.forProvider.configData", "test-value", "changed-value"). @@ -3023,7 +2998,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-switch-to-automatic.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -3043,7 +3018,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-xr-manual-no-ref.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-manual-no-ref", "default"). WithField("spec.forProvider.configData", "v2-new-value"). @@ -3071,7 +3046,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). @@ -3101,9 +3076,9 @@ Summary: 2 modified, 2 removed`, "testdata/comp/resources/existing-downstream-revision-ref.yaml", ), }, - inputFiles: []string{"testdata/diff/existing-xr-revision-ref-omitted.yaml"}, - expectedStructuredOutput: simtest.Expect().WithSummary(0, 0, 0), - expectedExitCode: dp.ExitCodeSuccess, + inputFiles: []string{"testdata/diff/existing-xr-revision-ref-omitted.yaml"}, + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedExitCode: dp.ExitCodeSuccess, }, // Issue #499, the consequence for which revision renders. The cluster's ref is inherited before the // composition is resolved, so a Manual composite whose input omits the ref stays on the revision it @@ -3126,7 +3101,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). // v1-* (not v2-*) proves the pinned revision, not the latest, rendered. @@ -3154,7 +3129,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). @@ -3192,7 +3167,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XChildResource", "test-parent-child", "default"). WithFieldChange("spec.childField", "existing-value", "modified-value"). @@ -3228,8 +3203,8 @@ Summary: 2 modified, 2 removed`, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/unchanged-automatic-pinned-claim.yaml"}, // No warnings either: the composed resource belongs to the claim's backing XR (#534). - expectedStructuredOutput: simtest.Expect().WithNoWarnings().WithSummary(0, 0, 0), - expectedExitCode: dp.ExitCodeSuccess, + expectedStructured: simtest.Expect().WithNoWarnings().WithSummary(0, 0, 0), + expectedExitCode: dp.ExitCodeSuccess, }, // A Manual claim's pin lives on its backing XR: the claim syncer copies the ref back to a claim only // under Automatic. Composition resolution and render must both see that pin. Before, the composition @@ -3253,7 +3228,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/modified-manual-pinned-claim.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The composed resource is labelled with the backing XR's name, which the claim's resourceRef // names, so it already belongs to this claim: no ownership advisory (#534). WithNoWarnings(). @@ -3287,7 +3262,7 @@ Summary: 2 modified, 2 removed`, }, xrdAPIVersion: V1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs inputFiles: []string{"testdata/diff/unchanged-lagging-automatic-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 1, 0). WithModifiedResource("XDownstreamResource", "lagging-xr", ""). WithFieldChange("spec.forProvider.configData", @@ -3307,7 +3282,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-manual-default-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XManualDefault", "new-manual-default", "default"). And(). @@ -3334,7 +3309,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/modified-xr-selector-bound.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "selector-bound", "default"). WithFieldChange("spec.forProvider.configData", "a-existing-value", "a-modified-value"). @@ -3359,7 +3334,7 @@ Summary: 2 modified, 2 removed`, ), }, inputFiles: []string{"testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 2, 0). WithModifiedResource("XDownstreamResource", "test-v2xrd-v1paths", "default"). WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). @@ -3442,7 +3417,7 @@ Summary: 2 modified, 2 removed`, }, }, inputFiles: []string{"testdata/diff/modified-claim-nested.yaml"}, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("ClusterNopResource", "existing-parent-claim-82crv-nop", ""). And(). @@ -3470,7 +3445,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/functions.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). WithField("spec.forProvider.configData", "test-value-creds"). @@ -3497,7 +3472,7 @@ Summary: 2 modified, 2 removed`, // instead, so the shortfall advisory must NOT fire. It used to, because the advisory was // raised inside the credential client, which runs before the CLI-supplied secrets are merged // in — telling a user who had just solved the problem to go and solve it. - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithNoWarnings(). WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). @@ -3525,7 +3500,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 added; stage1 NOT present (gated). - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "sequencer-test", "default"). WithField("spec.coolField", "test-value"). @@ -3547,7 +3522,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("XNopResource", "sequencer-test", "default"). WithField("spec.coolField", "test-value"). @@ -3579,7 +3554,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR and stage0 modified, stage1 added (unlocked by stage0's synthesized Ready). - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(1, 2, 0). WithModifiedResource("XNopResource", "sequencer-test", "default"). WithFieldChange("spec.coolField", "existing-value", "test-value"). @@ -3610,7 +3585,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml"), }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(0, 3, 0). WithModifiedResource("XNopResource", "sequencer-test", "default"). WithFieldChange("spec.coolField", "existing-value", "test-value"). @@ -3639,7 +3614,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(2, 0, 0). WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). @@ -3661,7 +3636,7 @@ Summary: 2 modified, 2 removed`, }, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: XR + stage0 + stage1 all added. - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(3, 0, 0). WithAddedResource("XNopResource", "conditional-gating-test", "default"). WithField("spec.coolField", "test-value"). @@ -3693,7 +3668,7 @@ Summary: 2 modified, 2 removed`, "testdata/diff/resources/environment-config-v1beta1.yaml", }, expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithSummary(3, 0, 0). // 2 downstream resources + 1 XR WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). WithField("spec.forProvider.configData", "test-value"). @@ -3876,7 +3851,7 @@ Summary: 2 modified`, // Total counts both discovered XRs; only the live one is evaluated (1 with changes), and // the deleting one is reported via filteredByDeletion. In default-discovery mode filtered // XRs are counted but not surfaced as impact entries, so only test-resource appears. - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 1, 0, 0). @@ -3905,7 +3880,7 @@ Summary: 2 modified`, resources: []string{"default/deleting-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithFilteredByDeletion(1). @@ -3935,7 +3910,7 @@ Summary: 2 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ "resource": "XDownstreamResource/test-resource", @@ -3980,7 +3955,7 @@ Summary: 2 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("removal-test-composition"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -4107,7 +4082,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // composition's template read the revision name, the resulting change would go undetected. "Could not predict the name of the CompositionRevision", }, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The same advisory reaches warnings[] for machine consumers, keyed by composition so that two // compositions declining to predict stay two warnings rather than being merged as duplicates. WithWarning("Could not predict the name of the CompositionRevision"). @@ -4143,7 +4118,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithNoWarnings(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). @@ -4183,7 +4158,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The name was predictable, so the could-not-predict advisory must stay silent. WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). @@ -4231,7 +4206,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl includeManual: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). // One composite re-points; the Manual one is kept but adopts nothing. @@ -4272,7 +4247,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl namespace: "test-namespace", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The claim's composed resource is labelled with its backing XR's name, which is the claim's // owner, so no "already belongs to another composite" advisory is raised (#534). WithNoWarnings(). @@ -4313,7 +4288,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, expectedStderrContains: []string{"Could not predict the name of the CompositionRevision"}, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). // The could-not-predict advisory is the only warning: the claim's composed resource is labelled // with its backing XR's name, so it raises no ownership advisory (#534). WithWarning("Could not predict the name of the CompositionRevision"). @@ -4350,7 +4325,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl analyzeOn: "spec-change", outputFormat: "json", expectedExitCode: dp.ExitCodeSuccess, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). WithRevisionImpact("metadata", true, 1). @@ -4384,7 +4359,7 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl analyzeUnchanged: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithAffectedResources(1, 1, 0, 0). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -4529,7 +4504,7 @@ Summary: 1 modified`, namespace: "default", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -5526,7 +5501,7 @@ Summary: 1 modified`, expectedError: true, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). WithXRImpact("XCycleA", "test-cycle", "default", "error"). @@ -5548,7 +5523,7 @@ Summary: 1 modified`, expectedError: true, expectedErrorContains: "impact analysis failed for 1 XR(s)", expectedExitCode: dp.ExitCodeToolError, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithErrorID("XCycleA/test-cycle"). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). @@ -5736,7 +5711,7 @@ Summary: 2 modified`, expectedExitCode: dp.ExitCodeDiffDetected, // Expect: impact shows stage0 modified (its configData changed); stage1 is absent from // downstream impact because sequencer gates it. - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -5764,7 +5739,7 @@ Summary: 2 modified`, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, // Expect: impact shows stage0 modified and stage1 added (eventual-state unlocks stage1). - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -5799,7 +5774,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -5826,7 +5801,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -5859,7 +5834,7 @@ Summary: 2 modified`, inputFiles: []string{"testdata/comp/updated-composition.yaml"}, namespace: "default", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -5887,7 +5862,7 @@ Summary: 2 modified`, `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=/v1, Kind=ConfigMap, namespace=nonexistent-namespace)`, }, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithWarning("their namespace does not exist yet"). WithWarningContext(map[string]string{ "gvk": "/v1, Kind=ConfigMap", @@ -5929,7 +5904,7 @@ Summary: 2 modified`, resources: []string{"default/test-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -5958,7 +5933,7 @@ Summary: 2 modified`, rbacRules: leastPrivilegeRules("get", "list", "create", "patch"), outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -5988,7 +5963,7 @@ Summary: 2 modified`, rbacRules: leastPrivilegeRules("get", "create", "patch"), outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -6015,7 +5990,7 @@ Summary: 2 modified`, resourcesCSV: "default/test-resource,default/another-resource", outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -6059,7 +6034,7 @@ Summary: 2 modified`, resources: []string{"default/manual-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "filtered"). @@ -6083,7 +6058,7 @@ Summary: 2 modified`, includeManual: true, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "changed"), @@ -6103,7 +6078,7 @@ Summary: 2 modified`, resources: []string{"default/selector-mismatch-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). @@ -6127,7 +6102,7 @@ Summary: 2 modified`, resources: []string{"default/selector-match-resource"}, outputFormat: "json", expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructuredCompOutput: simtest.Expect(). + expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-match-resource", "default", "changed"), From 7456234e5564b6ec0119a67ca7f0332949ac2597 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:37:03 -0400 Subject: [PATCH 116/126] test(diff): run project simulate on project fixtures Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/project_test.go | 160 ++++++++++++++++++ .../project/malformed/crossplane-project.yaml | 3 + .../testdata/project/nocomps/apis/.gitkeep | 0 .../project/nocomps/crossplane-project.yaml | 6 + .../project/plain/apis/composition.yaml | 33 ++++ .../project/plain/crossplane-project.yaml | 6 + 6 files changed, 208 insertions(+) create mode 100644 internal/diff/integration/project_test.go create mode 100644 internal/diff/integration/testdata/project/malformed/crossplane-project.yaml create mode 100644 internal/diff/integration/testdata/project/nocomps/apis/.gitkeep create mode 100644 internal/diff/integration/testdata/project/nocomps/crossplane-project.yaml create mode 100644 internal/diff/integration/testdata/project/plain/apis/composition.yaml create mode 100644 internal/diff/integration/testdata/project/plain/crossplane-project.yaml diff --git a/internal/diff/integration/project_test.go b/internal/diff/integration/project_test.go new file mode 100644 index 00000000..89d4d72d --- /dev/null +++ b/internal/diff/integration/project_test.go @@ -0,0 +1,160 @@ +//go:build integration + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/crossplane/cli/v2/cmd/crossplane/project" + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/simulate/simtest" +) + +// projectTestCase is a `project simulate -f ` run against a +// fixture project, exercising how a project varies rather than the XR and +// Composition logic the other tables cover. +type projectTestCase struct { + reason string + // setupFiles and crossplaneManagedResources set up the cluster, as in the + // other tables. Cases that fail before the cluster is used leave them empty. + setupFiles []string + crossplaneManagedResources []HierarchicalOwnershipRelation + // project is a directory under testdata/project, copied to a temp dir so + // the build cannot dirty the tree. + project string + // extraArgs follow the project file argument. + extraArgs []string + expectedExitCode int + expectedErrorContains string + expectedStructured simtest.Expectation + // slow cases are skipped under testing.Short (registry pulls, builds). + slow bool +} + +func TestProjectSimulateIntegration(t *testing.T) { + t.Parallel() + + tu.SetupKubeTestLogger(t) + + cases := map[string]projectTestCase{ + "MalformedProjectFile": { + reason: "A project file that does not parse fails the run and the error names the file.", + project: "malformed", + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: "crossplane-project.yaml", + }, + "NoCompositions": { + reason: "A valid project whose APIs path holds no Composition has nothing to simulate.", + project: "nocomps", + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: "no Compositions found", + }, + "ProjectFileWithOtherInputs": { + reason: "A project file must be the only -f input, so a second input is rejected before the project is built.", + project: "plain", + extraArgs: []string{"-f", "testdata/comp/updated-composition.yaml"}, + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: "a project file must be the only -f input", + }, + "PlainProject": { + reason: "A project with no dependencies or embedded functions simulates its Composition against cluster functions, like the same Composition in Composition mode.", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + composes( + "testdata/comp/resources/existing-xr-2.yaml", + "testdata/comp/resources/existing-downstream-2.yaml", + ), + }, + project: "plain", + extraArgs: []string{"-n", "default"}, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 2, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + AndComp(). + WithXRImpact("XNopResource", "another-resource", "default", "changed"), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.slow && testing.Short() { + t.Skip("slow: pulls or builds against a registry") + } + + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + dir := t.TempDir() + if err := os.CopyFS(dir, os.DirFS(filepath.Join("testdata", "project", tc.project))); err != nil { + t.Fatalf("\n%s\ncannot copy fixture %q: %v", tc.reason, tc.project, err) + } + + cfg := startIntegrationCluster(t, CompositionDiffTest, IntegrationTestCase{ + setupFiles: tc.setupFiles, + crossplaneManagedResources: tc.crossplaneManagedResources, + }, defaultTimeout).cfg + + argv := []string{ + "project", "simulate", "--timeout=" + defaultTimeout.String(), + "-f", filepath.Join(dir, "crossplane-project.yaml"), + "--cache-dir=" + t.TempDir(), "--skip-plan", + } + argv = append(argv, tc.extraArgs...) + if tc.expectedStructured != nil { + argv = append(argv, "-o", "json") + } + + var stdout, stderr bytes.Buffer + code, err := runSimulateArgvWith(t, cfg, argv, &project.SimulateHooks{RenderBinary: localCrossplaneBinary(t)}, &stdout, &stderr) + + if tc.expectedErrorContains != "" { + if err == nil || !strings.Contains(err.Error(), tc.expectedErrorContains) { + t.Fatalf("\n%s\nsimulate: want error containing %q, got %v\nstderr:\n%s", tc.reason, tc.expectedErrorContains, err, stderr.String()) + } + } else if err != nil { + t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) + } + if code != tc.expectedExitCode { + t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, tc.expectedExitCode, code) + } + if tc.expectedStructured != nil { + simtest.AssertStructured(t, stdout.String(), tc.expectedStructured) + } + }) + } +} diff --git a/internal/diff/integration/testdata/project/malformed/crossplane-project.yaml b/internal/diff/integration/testdata/project/malformed/crossplane-project.yaml new file mode 100644 index 00000000..0ed7cb23 --- /dev/null +++ b/internal/diff/integration/testdata/project/malformed/crossplane-project.yaml @@ -0,0 +1,3 @@ +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +spec: [unterminated diff --git a/internal/diff/integration/testdata/project/nocomps/apis/.gitkeep b/internal/diff/integration/testdata/project/nocomps/apis/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/internal/diff/integration/testdata/project/nocomps/crossplane-project.yaml b/internal/diff/integration/testdata/project/nocomps/crossplane-project.yaml new file mode 100644 index 00000000..e99141a9 --- /dev/null +++ b/internal/diff/integration/testdata/project/nocomps/crossplane-project.yaml @@ -0,0 +1,6 @@ +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: it-project +spec: + repository: xpkg.crossplane.io/example/it-project diff --git a/internal/diff/integration/testdata/project/plain/apis/composition.yaml b/internal/diff/integration/testdata/project/plain/apis/composition.yaml new file mode 100644 index 00000000..e3e72edb --- /dev/null +++ b/internal/diff/integration/testdata/project/plain/apis/composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/project/plain/crossplane-project.yaml b/internal/diff/integration/testdata/project/plain/crossplane-project.yaml new file mode 100644 index 00000000..e99141a9 --- /dev/null +++ b/internal/diff/integration/testdata/project/plain/crossplane-project.yaml @@ -0,0 +1,6 @@ +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: it-project +spec: + repository: xpkg.crossplane.io/example/it-project From df3be228baab562c591e37fcd2b54b4c5d14b41c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 17:45:11 -0400 Subject: [PATCH 117/126] test(diff): simulate a project with an embedded function Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/project_test.go | 26 ++++++++++++++++++- .../project/embedded/apis/composition.yaml | 16 ++++++++++++ .../project/embedded/crossplane-project.yaml | 6 +++++ .../embedded/functions/render/template.gotmpl | 11 ++++++++ 4 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 internal/diff/integration/testdata/project/embedded/apis/composition.yaml create mode 100644 internal/diff/integration/testdata/project/embedded/crossplane-project.yaml create mode 100644 internal/diff/integration/testdata/project/embedded/functions/render/template.gotmpl diff --git a/internal/diff/integration/project_test.go b/internal/diff/integration/project_test.go index 89d4d72d..2c58fff3 100644 --- a/internal/diff/integration/project_test.go +++ b/internal/diff/integration/project_test.go @@ -105,6 +105,30 @@ func TestProjectSimulateIntegration(t *testing.T) { AndComp(). WithXRImpact("XNopResource", "another-resource", "default", "changed"), }, + "EmbeddedFunctionOverlay": { + reason: "A Composition step naming a function embedded in the project runs the project's function, whose output no cluster function produces.", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + project: "embedded", + extraArgs: []string{"-n", "default"}, + expectedExitCode: dp.ExitCodeDiffDetected, + slow: true, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamResource("modified", "XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "embedded-existing-value"), + }, } for name, tc := range cases { @@ -112,7 +136,7 @@ func TestProjectSimulateIntegration(t *testing.T) { t.Parallel() if tc.slow && testing.Short() { - t.Skip("slow: pulls or builds against a registry") + t.Skip("slow: builds an embedded function image") } if reason := integrationEnvGap(); reason != "" { diff --git a/internal/diff/integration/testdata/project/embedded/apis/composition.yaml b/internal/diff/integration/testdata/project/embedded/apis/composition.yaml new file mode 100644 index 00000000..2dc66ce5 --- /dev/null +++ b/internal/diff/integration/testdata/project/embedded/apis/composition.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: example-itprojectrender + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/internal/diff/integration/testdata/project/embedded/crossplane-project.yaml b/internal/diff/integration/testdata/project/embedded/crossplane-project.yaml new file mode 100644 index 00000000..b227feb7 --- /dev/null +++ b/internal/diff/integration/testdata/project/embedded/crossplane-project.yaml @@ -0,0 +1,6 @@ +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: it-project +spec: + repository: xpkg.crossplane.io/example/itproject diff --git a/internal/diff/integration/testdata/project/embedded/functions/render/template.gotmpl b/internal/diff/integration/testdata/project/embedded/functions/render/template.gotmpl new file mode 100644 index 00000000..7ac6307e --- /dev/null +++ b/internal/diff/integration/testdata/project/embedded/functions/render/template.gotmpl @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: embedded-{{ .observed.composite.resource.spec.coolField }} + resourceTier: basic From 317cea163b7b6b5c96df6437421f5a3955c40199 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 18:02:17 -0400 Subject: [PATCH 118/126] build(nix): keep the host PATH for integration tests Docker credential helpers live on the host PATH, not in Nix. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- nix/apps.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/nix/apps.nix b/nix/apps.nix index 8fe86344..40535534 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -44,6 +44,8 @@ in # render engine; envtest binaries and Crossplane CRDs come from the Nix # store. CI runs it on GitHub runners, where a host Docker daemon is # available (see the integration-tests job in .github/workflows/ci.yml). + # It keeps the host PATH (Nix inputs first) because Docker and its credential + # helpers (docker-credential-*) come from the host, not from Nix. integration-test = _: { type = "app"; meta.description = "Run the diff integration tests (needs Docker)"; @@ -51,7 +53,7 @@ in pkgs.writeShellApplication { name = "crossplane-cli-integration-test"; runtimeInputs = [ pkgs.unstable.go_1_26 ]; - inheritPath = false; + inheritPath = true; text = '' export CGO_ENABLED=0 export CROSSPLANE_CRDS_DIR="''${CROSSPLANE_CRDS_DIR:-${ From 829de8073ea88beb34983172990f97a8a5255b72 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 18:05:13 -0400 Subject: [PATCH 119/126] test(project): let tests replace simulate's diff server set Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 16 ++++++++++++++-- internal/simulate/servers.go | 11 +++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 039939c7..88292b09 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -80,7 +80,7 @@ var simulateHelp string type simulateCmd struct { // Run sets these; nil with --skip-plan (servers also when Hooks.Router is set). xrPlanner *simulate.XRPlanner - servers *simulate.ServerSet + servers simulate.Servers // noServersNoted is set once the "no diff servers" line is printed. noServersNoted bool @@ -144,6 +144,11 @@ type SimulateHooks struct { // diff servers. Router simulate.Router + // Servers, when set, replaces the diff server set that would otherwise + // start provider servers; everything else still runs. Test-only. It + // can't be combined with Router. + Servers simulate.Servers + // ChangedFunctions marks these functions as changed in any mode, as // project mode's digest detection would. Test-only. ChangedFunctions []string @@ -371,6 +376,9 @@ func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logge var ics []pkgv1beta1.ImageConfig var router simulate.Router var depMgr *dependency.Manager + if c.Hooks != nil && c.Hooks.Router != nil && c.Hooks.Servers != nil { + return nil, errors.New("hooks Router and Servers are mutually exclusive") + } if c.Hooks != nil && c.Hooks.Router != nil { router = c.Hooks.Router } else { @@ -397,7 +405,11 @@ func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logge dependency.WithResolver(resolver), ) } - c.servers = simulate.NewServerSet(sp, logger, k.Stderr, pkgs, c.SessionID) + if c.Hooks != nil && c.Hooks.Servers != nil { + c.servers = c.Hooks.Servers + } else { + c.servers = simulate.NewServerSet(sp, logger, k.Stderr, pkgs, c.SessionID) + } router = c.servers } diff --git a/internal/simulate/servers.go b/internal/simulate/servers.go index 8c6cd9fc..0f819c65 100644 --- a/internal/simulate/servers.go +++ b/internal/simulate/servers.go @@ -94,6 +94,17 @@ const ( defaultDevelopmentTarget = "localhost:9099" ) +// Servers is what a simulation needs from its diff servers: start them, +// route resources to them, and stop them. *ServerSet implements it. +type Servers interface { + Router + Ensure(ctx context.Context, inputs []diffserver.Input) error + Ran() bool + Stop(ctx context.Context) +} + +var _ Servers = (*ServerSet)(nil) + // ServerSet runs provider diff servers for a simulation, starting each // provider's server the first time it is asked for. It routes resources to // the servers it runs. From a01d9559bc9e5fe365e419c8e48710f369b32ef3 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 18:11:04 -0400 Subject: [PATCH 120/126] test(diff): simulate a project with a provider dependency Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../diff/integration/project_servers_test.go | 69 +++++++++++++++++++ internal/diff/integration/project_test.go | 43 +++++++++++- .../project/providerdep/apis/composition.yaml | 33 +++++++++ .../providerdep/crossplane-project.yaml | 13 ++++ 4 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 internal/diff/integration/project_servers_test.go create mode 100644 internal/diff/integration/testdata/project/providerdep/apis/composition.yaml create mode 100644 internal/diff/integration/testdata/project/providerdep/crossplane-project.yaml diff --git a/internal/diff/integration/project_servers_test.go b/internal/diff/integration/project_servers_test.go new file mode 100644 index 00000000..1e5b1963 --- /dev/null +++ b/internal/diff/integration/project_servers_test.go @@ -0,0 +1,69 @@ +//go:build integration + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package integration + +import ( + "context" + "strings" + "sync" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/cli/v2/internal/diffserver" + "github.com/crossplane/cli/v2/internal/simulate" +) + +// recordingServers is a simulate.Servers that starts nothing. It records the +// inputs it is asked to ensure and routes XDownstreamResource to plans only +// once an input whose image contains wantImage has been ensured. +type recordingServers struct { + wantImage string + plans diffv1alpha1.PlanServiceClient + + mu sync.Mutex + ensured []diffserver.Input +} + +var _ simulate.Servers = (*recordingServers)(nil) + +func (r *recordingServers) Ensure(_ context.Context, inputs []diffserver.Input) error { + r.mu.Lock() + defer r.mu.Unlock() + r.ensured = append(r.ensured, inputs...) + return nil +} + +func (r *recordingServers) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if gk.Kind != "XDownstreamResource" { + return nil, false + } + for _, in := range r.ensured { + if strings.Contains(in.Image, r.wantImage) { + return r.plans, true + } + } + return nil, false +} + +func (r *recordingServers) Ran() bool { return true } + +func (r *recordingServers) Stop(context.Context) {} diff --git a/internal/diff/integration/project_test.go b/internal/diff/integration/project_test.go index 2c58fff3..387885c5 100644 --- a/internal/diff/integration/project_test.go +++ b/internal/diff/integration/project_test.go @@ -25,9 +25,12 @@ import ( "strings" "testing" + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "github.com/crossplane/cli/v2/cmd/crossplane/project" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/simulate/simtest" ) @@ -50,6 +53,9 @@ type projectTestCase struct { expectedStructured simtest.Expectation // slow cases are skipped under testing.Short (registry pulls, builds). slow bool + // servers, when set, replaces the diff server set and the run does not + // pass --skip-plan. + servers simulate.Servers } func TestProjectSimulateIntegration(t *testing.T) { @@ -57,6 +63,9 @@ func TestProjectSimulateIntegration(t *testing.T) { tu.SetupKubeTestLogger(t) + providerDigest := "sha256:a52ba8a7a0548d8b709c53462a135affaf5b277c5738053c38d5d7ddfcfb1331" + region := &diffv1alpha1.FieldChange{Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1")} + cases := map[string]projectTestCase{ "MalformedProjectFile": { reason: "A project file that does not parse fails the run and the error names the file.", @@ -105,6 +114,33 @@ func TestProjectSimulateIntegration(t *testing.T) { AndComp(). WithXRImpact("XNopResource", "another-resource", "default", "changed"), }, + "ProviderDependencyStartsServer": { + reason: "A provider dependency of the project is ensured as a diff server, so its plan joins the composed resource's changes.", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []HierarchicalOwnershipRelation{ + composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + project: "providerdep", + extraArgs: []string{"-n", "default"}, + expectedExitCode: dp.ExitCodeDiffDetected, + slow: true, + servers: &recordingServers{wantImage: providerDigest, plans: fakeProviderRouter{fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UPDATE, Changes: []*diffv1alpha1.FieldChange{region}}, nil + }}}, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamResource("modified", "XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.region", "us-east-1", "eu-west-1"), + }, "EmbeddedFunctionOverlay": { reason: "A Composition step naming a function embedded in the project runs the project's function, whose output no cluster function produces.", setupFiles: []string{ @@ -156,7 +192,10 @@ func TestProjectSimulateIntegration(t *testing.T) { argv := []string{ "project", "simulate", "--timeout=" + defaultTimeout.String(), "-f", filepath.Join(dir, "crossplane-project.yaml"), - "--cache-dir=" + t.TempDir(), "--skip-plan", + "--cache-dir=" + t.TempDir(), + } + if tc.servers == nil { + argv = append(argv, "--skip-plan") } argv = append(argv, tc.extraArgs...) if tc.expectedStructured != nil { @@ -164,7 +203,7 @@ func TestProjectSimulateIntegration(t *testing.T) { } var stdout, stderr bytes.Buffer - code, err := runSimulateArgvWith(t, cfg, argv, &project.SimulateHooks{RenderBinary: localCrossplaneBinary(t)}, &stdout, &stderr) + code, err := runSimulateArgvWith(t, cfg, argv, &project.SimulateHooks{RenderBinary: localCrossplaneBinary(t), Servers: tc.servers}, &stdout, &stderr) if tc.expectedErrorContains != "" { if err == nil || !strings.Contains(err.Error(), tc.expectedErrorContains) { diff --git a/internal/diff/integration/testdata/project/providerdep/apis/composition.yaml b/internal/diff/integration/testdata/project/providerdep/apis/composition.yaml new file mode 100644 index 00000000..e3e72edb --- /dev/null +++ b/internal/diff/integration/testdata/project/providerdep/apis/composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/internal/diff/integration/testdata/project/providerdep/crossplane-project.yaml b/internal/diff/integration/testdata/project/providerdep/crossplane-project.yaml new file mode 100644 index 00000000..e89716d7 --- /dev/null +++ b/internal/diff/integration/testdata/project/providerdep/crossplane-project.yaml @@ -0,0 +1,13 @@ +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: it-project +spec: + repository: xpkg.crossplane.io/example/it-project + dependencies: + - type: xpkg + xpkg: + apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-nop + version: sha256:a52ba8a7a0548d8b709c53462a135affaf5b277c5738053c38d5d7ddfcfb1331 From 669522b80354fee75d62de3d27d9d1b5bffbcf89 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 18:23:29 -0400 Subject: [PATCH 121/126] test(diff): prove the project's function wins over the cluster's Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/project_test.go | 5 +++-- .../testdata/project/embedded/cluster-function.yaml | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 internal/diff/integration/testdata/project/embedded/cluster-function.yaml diff --git a/internal/diff/integration/project_test.go b/internal/diff/integration/project_test.go index 387885c5..3373e2d3 100644 --- a/internal/diff/integration/project_test.go +++ b/internal/diff/integration/project_test.go @@ -142,11 +142,12 @@ func TestProjectSimulateIntegration(t *testing.T) { WithFieldChange("spec.forProvider.region", "us-east-1", "eu-west-1"), }, "EmbeddedFunctionOverlay": { - reason: "A Composition step naming a function embedded in the project runs the project's function, whose output no cluster function produces.", + reason: "A Composition step naming a function embedded in the project runs the project's function, not a same-named cluster Function, which would fail without step input.", setupFiles: []string{ "testdata/comp/resources/xrd.yaml", "testdata/comp/resources/original-composition.yaml", "testdata/comp/resources/functions.yaml", + "testdata/project/embedded/cluster-function.yaml", }, crossplaneManagedResources: []HierarchicalOwnershipRelation{ composes( @@ -172,7 +173,7 @@ func TestProjectSimulateIntegration(t *testing.T) { t.Parallel() if tc.slow && testing.Short() { - t.Skip("slow: builds an embedded function image") + t.Skip("slow: pulls from a registry") } if reason := integrationEnvGap(); reason != "" { diff --git a/internal/diff/integration/testdata/project/embedded/cluster-function.yaml b/internal/diff/integration/testdata/project/embedded/cluster-function.yaml new file mode 100644 index 00000000..42b38287 --- /dev/null +++ b/internal/diff/integration/testdata/project/embedded/cluster-function.yaml @@ -0,0 +1,9 @@ +--- +# A cluster Function with the same name as the project's embedded function. The +# project's function must win; this one has no step input, so go-templating errors if it is used. +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: example-itprojectrender +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0 From 8b52e619a2e29b5b03f9672f5c3f514d65e11439 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Thu, 22 Oct 2026 18:25:23 -0400 Subject: [PATCH 122/126] test: address project table review nits Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 7 ++++--- internal/simulate/simtest/structured_assertions.go | 7 ++++++- internal/simulate/simtest/structured_assertions_test.go | 3 +++ nix/apps.nix | 3 ++- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 88292b09..62cde160 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -228,6 +228,10 @@ func (c *simulateCmd) Help() string { // during parsing, so an unsupported render backend fails before any build or // cluster connection. func (c *simulateCmd) Validate() error { + if c.Hooks != nil && c.Hooks.Router != nil && c.Hooks.Servers != nil { + return errors.New("hooks Router and Servers are mutually exclusive") + } + if c.MaxIterations < 1 { return errors.Errorf("--max-iterations must be at least 1, got %d", c.MaxIterations) } @@ -376,9 +380,6 @@ func (c *simulateCmd) planOptions(sigCtx context.Context, k *kong.Context, logge var ics []pkgv1beta1.ImageConfig var router simulate.Router var depMgr *dependency.Manager - if c.Hooks != nil && c.Hooks.Router != nil && c.Hooks.Servers != nil { - return nil, errors.New("hooks Router and Servers are mutually exclusive") - } if c.Hooks != nil && c.Hooks.Router != nil { router = c.Hooks.Router } else { diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index 1dc60aa2..01779462 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -826,7 +826,12 @@ func (c *CompositionExpectation) And() *Expected { // multi-composition expectation readable instead of ending each composition with a run of climbs // whose purpose is not obvious from reading them. func (r *ResourceExpectation) AndComposition(name string) *CompositionExpectation { - return r.expectation().WithComposition(name) + e := r.expectation() + if e == nil { + panic("AndComposition chained on an XR-scoped change; it has no root builder") + } + + return e.WithComposition(name) } // AndComposition starts an expectation for another composition. See diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index f9f5fef1..b0932033 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -458,6 +458,9 @@ func TestResourceExpectationParents(t *testing.T) { for name, fn := range map[string]func(){ "AndXR on flat": func() { flat.AndXR() }, "And on XR-scoped": func() { XR("X", "x", "ns").Change("added", "K", "n", "ns").And() }, + "AndComposition on XR-scoped": func() { + XR("X", "x", "ns").Change("added", "K", "n", "ns").AndComposition("c") + }, } { func() { defer func() { diff --git a/nix/apps.nix b/nix/apps.nix index 40535534..f6fc5bb2 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -11,7 +11,8 @@ # ''${SOME_VAR} -> ${SOME_VAR} (shell variable, escaped) # # Each app declares its tool dependencies via runtimeInputs, with inheritPath -# set to false. This ensures apps only use explicitly declared tools. +# set to false. This ensures apps only use explicitly declared tools. The +# exception is integration-test, which keeps the host PATH for Docker. { pkgs }: let # envtest binaries for the integration tests; null only on systems From 48f2d6eb17fd0985681aab7dbaf28fdeaa718600 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 23 Oct 2026 10:22:15 -0400 Subject: [PATCH 123/126] test(diff): drop the -short skip from project cases Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/integration/project_test.go | 8 -------- 1 file changed, 8 deletions(-) diff --git a/internal/diff/integration/project_test.go b/internal/diff/integration/project_test.go index 3373e2d3..39503a99 100644 --- a/internal/diff/integration/project_test.go +++ b/internal/diff/integration/project_test.go @@ -51,8 +51,6 @@ type projectTestCase struct { expectedExitCode int expectedErrorContains string expectedStructured simtest.Expectation - // slow cases are skipped under testing.Short (registry pulls, builds). - slow bool // servers, when set, replaces the diff server set and the run does not // pass --skip-plan. servers simulate.Servers @@ -130,7 +128,6 @@ func TestProjectSimulateIntegration(t *testing.T) { project: "providerdep", extraArgs: []string{"-n", "default"}, expectedExitCode: dp.ExitCodeDiffDetected, - slow: true, servers: &recordingServers{wantImage: providerDigest, plans: fakeProviderRouter{fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UPDATE, Changes: []*diffv1alpha1.FieldChange{region}}, nil }}}, @@ -158,7 +155,6 @@ func TestProjectSimulateIntegration(t *testing.T) { project: "embedded", extraArgs: []string{"-n", "default"}, expectedExitCode: dp.ExitCodeDiffDetected, - slow: true, expectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). @@ -172,10 +168,6 @@ func TestProjectSimulateIntegration(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - if tc.slow && testing.Short() { - t.Skip("slow: pulls from a registry") - } - if reason := integrationEnvGap(); reason != "" { t.Skip(reason) } From b351a0570362295d25bd69e9f23886438379390b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 23 Oct 2026 11:04:48 -0400 Subject: [PATCH 124/126] docs(project): fix vale errors in simulate help Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 2 +- cmd/crossplane/project/simulate.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 43f051cd..50ff3e82 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -130,7 +130,7 @@ The command exits with one of these statuses, so a CI pipeline can gate on the result: - 0: the simulation succeeded and found no changes. -- 1: the simulation failed. A provider plan that fails fails its whole +- 1: the simulation failed. If a provider plan fails, so does its whole composite resource, which then reports an error entry and none of its resources' plans. - 2: only schema validation failed. diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 62cde160..7764898a 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -103,7 +103,7 @@ type simulateCmd struct { FunctionCredentials FunctionCredentials `help:"A YAML file or directory of YAML files specifying Secret credentials to pass to Functions." placeholder:"PATH"` FunctionRegistryOverride string `help:"Override the registry for all function images (e.g., 'my-company.registry.io')."` EventualState bool `help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)."` - MaxRecvMessageSize int `env:"MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4MB if undefined)."` + MaxRecvMessageSize int `env:"MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4 MB if undefined)."` MinimizeComposition bool `help:"Collapse each changed composition to a single marker line in -o diff output (errors and no-change compositions still print in full)."` AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` From 2365fb39cbc59590b6ad5e0c9f120242a1c0228e Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 23 Oct 2026 11:41:05 -0400 Subject: [PATCH 125/126] docs(project): say which errors exit 1 and 80 in simulate help Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/help/simulate.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 50ff3e82..235c8501 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -130,13 +130,14 @@ The command exits with one of these statuses, so a CI pipeline can gate on the result: - 0: the simulation succeeded and found no changes. -- 1: the simulation failed. If a provider plan fails, so does its whole +- 1: the simulation failed, including when the -f inputs can't be read or + can't be simulated together. If a provider plan fails, so does its whole composite resource, which then reports an error entry and none of its resources' plans. - 2: only schema validation failed. - 3: the simulation succeeded and found changes, including drift a provider plan found that the cluster diff didn't. -- 80: the flags or arguments were invalid, so nothing was simulated. +- 80: the flags were invalid, so nothing was simulated. - 130: the simulation was interrupted. When more than one applies, the lowest of 1, 2 and 3 wins. From 7e15b0cd4c40a1de6ad6a78b2e5f4e33e9e6c9e5 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 23 Oct 2026 11:53:27 -0400 Subject: [PATCH 126/126] style(project): realign simulate flag tags Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 7764898a..16287f42 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -105,8 +105,8 @@ type simulateCmd struct { EventualState bool `help:"Show eventual state after all reconciliation cycles complete (useful with function-sequencer)."` MaxRecvMessageSize int `env:"MAX_RECV_MESSAGE_SIZE" help:"Max gRPC message size (MB) for render function containers (4 MB if undefined)."` MinimizeComposition bool `help:"Collapse each changed composition to a single marker line in -o diff output (errors and no-change compositions still print in full)."` - AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` - DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` + AnalyzeOn string `default:"" enum:",spec-change,any-change,always" help:"Smallest composition change that triggers per-composite impact analysis. \"spec-change\": only when the composition spec changes. \"any-change\": also when only its metadata changes, which still creates a new CompositionRevision that composites re-point to. \"always\": even when the composition is identical, for a pre-edit convergence baseline. Defaults to any-change."` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run: 'all' also dry-run creates added resources so their diffs include server-side defaulting and admission (needs the 'create' verb; degrades per-resource without it), 'existing' only resources already in the cluster."` SkipPlan bool `help:"Don't run provider diff servers; show only the cluster-layer diff."` Providers string `help:"YAML file or directory with Provider manifests to run as diff servers, overriding discovery from the cluster. Supports per-provider runtime annotations." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"`