diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index a5665bbe..b33ebf22 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -16,7 +16,9 @@ one `--context` names. `-f` (`--input`) names a file, or a directory of YAML files, of Compositions to simulate, and is required. Give a comma-separated list, or repeat it, for more; `-f -` reads standard input. Every resource in them must be a Composition; to simulate a project, use -`crossplane project simulate`. +`crossplane project simulate`, to simulate composite resources or claims, use +`crossplane xr simulate`, and to simulate managed resources directly, use +`crossplane resource simulate`. Functions come from the cluster. `--cache-dir` sets where provider package contents are cached. diff --git a/cmd/crossplane/composition/integration_main_test.go b/cmd/crossplane/composition/integration_main_test.go new file mode 100644 index 00000000..58bc78d3 --- /dev/null +++ b/cmd/crossplane/composition/integration_main_test.go @@ -0,0 +1,23 @@ +//go:build integration + +package composition_test + +import ( + "fmt" + "os" + "testing" + + "github.com/crossplane/cli/v2/internal/testutils/integration" +) + +// TestMain runs before all tests. +func TestMain(m *testing.M) { + // Kill envtest servers that an earlier run left behind when it died abnormally. + integration.ReapOrphanedEnvtest(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/cmd/crossplane/project/integration_test.go b/cmd/crossplane/composition/integration_test.go similarity index 64% rename from cmd/crossplane/project/integration_test.go rename to cmd/crossplane/composition/integration_test.go index 9da6d1ca..6b7368fa 100644 --- a/cmd/crossplane/project/integration_test.go +++ b/cmd/crossplane/composition/integration_test.go @@ -1,371 +1,64 @@ //go:build integration -package project +/* +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 composition_test import ( - "bytes" - "fmt" - "strconv" "strings" "testing" - "time" - - rbacv1 "k8s.io/api/rbac/v1" - "k8s.io/client-go/rest" 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" "github.com/crossplane/cli/v2/internal/testutils/integration" + "github.com/crossplane/cli/v2/internal/testutils/integration/simharness" ) -// longTimeout is the --timeout for cases with multiple iterations, such as -// eventual state. -const longTimeout = 180 * time.Second - -// DiffTestType represents the type of diff test to run. -type DiffTestType string - -const ( - XRDiffTest DiffTestType = "diff" - CompositionDiffTest DiffTestType = "comp" -) - -// 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 integration.Composes() for an XR without nested XRs. Put a - // composed resource in setupFiles instead only when it deliberately has no controlling XR. - crossplaneManagedResources []integration.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 []integration.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 - 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 integration.XRDAPIVersion // For XR tests (optional) - ignorePaths []string // Paths to ignore in diffs - 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 - skip bool - skipReason string - // JSON output support: set outputFormat to "json" to use structured assertions. - outputFormat string // "json" or "" (default=visual diff) - // 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 -} - -// 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 - - // 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 - } - - // 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)) - - // 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") - } - - // 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 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...) - - // 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 - } - - // Start fails, rather than skips, a run without envtest's binaries or Crossplane's CRDs. - cluster := integration.Start(t, integration.Setup{ - CRDDirs: []string{"testdata/" + string(testType) + "/crds"}, - SetupFiles: tt.setupFiles, - UnestablishedXRDs: tt.unestablishedXRDs, - CrossplaneManagedResources: tt.crossplaneManagedResources, - XRDAPIVersion: tt.xrdAPIVersion, - FieldManagerApplies: tt.fieldManagerApplies, - DeleteAfterSetup: tt.deleteAfterSetup, - RBACRules: tt.rbacRules, - }) - - // 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(runCfg *rest.Config) (int, error) { - stdout.Reset() - stderr.Reset() - - return runSimulate(t, runCfg, testType, args, &stdout, &stderr) - } - - exitCode, err := runOnce(cluster.Config) - - if cluster.UserConfig != nil { - admin, adminErr := stdout.String(), err - - exitCode, err = runOnce(cluster.UserConfig) - - 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) - } - - // 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.expectedStructured != 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.expectedStructured == nil { - return - } - - // Check the output - outputStr := stdout.String() - - // Handle JSON output format with structured assertions - if tt.outputFormat == "json" && tt.expectedStructured != nil { - simtest.AssertStructured(t, outputStr, tt.expectedStructured) - 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) - } -} - -// 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"}}, - } -} - // TestCompDiffIntegration runs an integration test for the composition diff command. +// +//nolint:tparallel // simharness.RunIntegrationTest calls t.Parallel in each subtest. func TestCompDiffIntegration(t *testing.T) { t.Parallel() - tests := map[string]IntegrationTestCase{ + tests := map[string]simharness.IntegrationTestCase{ "CompositionChangeImpactsXRs": { - reason: "Validates composition change impacts existing XRs", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, // Add existing XRs that use the composition - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml", ), }, // New composition files that will be diffed - inputFiles: []string{"testdata/comp/updated-composition.yaml"}, - namespace: "default", - expectedOutput: ` + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xnopresources.diff.example.org @@ -458,26 +151,26 @@ Summary: 2 resources with changes --- Summary: 2 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + 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", + Reason: "A deleting XR is excluded from impact analysis and counted as filteredByDeletion; the live XR is still evaluated", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { - OwnerFile: "testdata/comp/resources/existing-xr-1.yaml", + OwnerFile: "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ - "testdata/comp/resources/existing-downstream-1.yaml": nil, + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml": nil, }, }, { @@ -487,15 +180,15 @@ Summary: 2 modified`, }, }, }, - 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 + DeleteAfterSetup: []string{"testdata/comp/resources/existing-xr-deleting.yaml"}, + InputFiles: []string{"../../../internal/testutils/integration/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. - expectedStructured: simtest.Expect(). + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 1, 0, 0). @@ -505,13 +198,13 @@ Summary: 2 modified`, // 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", + Reason: "--resource matching a deleting composite surfaces it as filtered with reason deleting", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { OwnerFile: "testdata/comp/resources/existing-xr-deleting.yaml", OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ @@ -519,12 +212,12 @@ Summary: 2 modified`, }, }, }, - 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 - expectedStructured: simtest.Expect(). + DeleteAfterSetup: []string{"testdata/comp/resources/existing-xr-deleting.yaml"}, + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Resources: []string{"default/deleting-resource"}, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithFilteredByDeletion(1). @@ -535,26 +228,26 @@ Summary: 2 modified`, // 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", + Reason: "comp against an XR whose resourceRefs name a deleted composed resource warns and reports it as an addition", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { - OwnerFile: "testdata/comp/resources/existing-xr-1.yaml", + OwnerFile: "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ - "testdata/comp/resources/existing-downstream-1.yaml": nil, + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + DeleteAfterSetup: []string{"../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml"}, + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Namespace: "default", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithWarning("was not found in the cluster"). WithWarningContext(map[string]string{ "resource": "XDownstreamResource/test-resource", @@ -568,38 +261,38 @@ Summary: 2 modified`, WithDownstreamSummary(1, 0, 0). WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"), }, - // Regression test for #552: + // 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", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/removal-test-original-composition.yaml", - "testdata/diff/resources/composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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 + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml", // still rendered: kept + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml", // no longer rendered: removed ), integration.Composes( - "testdata/diff/resources/existing-other-xr.yaml", - "testdata/diff/resources/existing-other-xr-downstream.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-other-xr.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-other-xr-downstream.yaml", ), }, - inputFiles: []string{"testdata/diff/resources/removal-test-composition.yaml"}, - namespace: "default", - outputFormat: "json", - expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml"}, + Namespace: "default", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("removal-test-composition"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -608,29 +301,29 @@ Summary: 2 modified`, 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", + Reason: "Validates that ArgoCD annotations are ignored in composition diffs", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, // Add existing XR with ArgoCD annotations - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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", + 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{ + AnalyzeUnchanged: true, + IgnorePaths: []string{ "metadata.annotations[argocd.argoproj.io/tracking-id]", "metadata.labels[argocd.argoproj.io/instance]", }, - expectedOutput: ` + ExpectedOutput: ` === Composition Changes === No changes detected in composition xnopresources.diff.example.org @@ -646,8 +339,8 @@ Summary: 1 resource unchanged All composite resources are up-to-date. No downstream resource changes detected. `, - expectedError: false, - noColor: true, + 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. @@ -658,21 +351,21 @@ All composite resources are up-to-date. No downstream resource changes detected. // 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", + Reason: "An unchanged composition skips impact analysis entirely and says so", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), }, - inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, - namespace: "default", - expectedOutput: ` + InputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === No changes detected in composition xnopresources.diff.example.org @@ -680,10 +373,10 @@ 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, + ExpectedError: false, // No composition change and no evaluated XRs, so nothing was detected. - expectedExitCode: dp.ExitCodeSuccess, - noColor: true, + 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. @@ -703,30 +396,30 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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", + Reason: "A composition differing only in kubectl's last-applied-configuration still counts as changed, because applying it creates a new CompositionRevision", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/original-composition-kubectl-applied.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-xr-1-kubectl-applied.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), }, - inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, - namespace: "default", - outputFormat: "json", - expectedExitCode: dp.ExitCodeSuccess, - expectedStderrContains: []string{ + 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", }, - expectedStructured: 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"). @@ -746,23 +439,23 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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", + Reason: "A composition whose live last-applied-configuration already describes the file is unchanged, because re-applying it writes nothing", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/original-composition-kubectl-applied-current.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), }, - inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, - namespace: "default", - outputFormat: "json", - expectedExitCode: dp.ExitCodeSuccess, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + Namespace: "default", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeSuccess, + ExpectedStructured: simtest.Expect(). WithNoWarnings(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). @@ -785,24 +478,24 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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: []integration.HierarchicalOwnershipRelation{ + Reason: "A metadata-only composition edit whose template reads the CompositionRevision name changes rendered output, and is detected rather than assumed away", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/revision-templating-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/revision-templating-composition-revision.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-revision-ref.yaml", - "testdata/comp/resources/existing-downstream-revision-ref.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-revision-ref.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml", ), }, - inputFiles: []string{"testdata/comp/revision-templating-updated-composition.yaml"}, - namespace: "default", - outputFormat: "json", - expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/revision-templating-updated-composition.yaml"}, + Namespace: "default", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). // The name was predictable, so the could-not-predict advisory must stay silent. WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). @@ -827,30 +520,30 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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", + Reason: "Under --include-manual, the predicted revision name reaches an Automatic composite but not a Manual one pinned to an older revision", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/revision-templating-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/revision-templating-composition-revision.yaml", "testdata/comp/resources/revision-templating-composition-older-revision.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-revision-ref.yaml", - "testdata/comp/resources/existing-downstream-revision-ref.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-revision-ref.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml", ), integration.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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/revision-templating-updated-composition.yaml"}, + Namespace: "default", + IncludeManual: true, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithNoWarnings(). WithComposition("xrevisionrefs.diff.example.org"). // One composite re-points; the Manual one is kept but adopts nothing. @@ -872,26 +565,26 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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{ + 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", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", "testdata/comp/resources/test-namespace.yaml", "testdata/comp/resources/existing-claim-revision-ref.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-claim-revision-ref-xr.yaml", "testdata/comp/resources/existing-claim-revision-ref-downstream.yaml", ), }, - xrdAPIVersion: integration.XRDV1, // 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, - expectedStructured: simtest.Expect(). + XrdAPIVersion: integration.XRDV1, // 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, + 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(). @@ -912,27 +605,27 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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{ + 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", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", "testdata/comp/resources/test-namespace.yaml", "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml", "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml", ), }, - xrdAPIVersion: integration.XRDV1, // 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"}, - expectedStructured: simtest.Expect(). + XrdAPIVersion: integration.XRDV1, // 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"}, + 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"). @@ -952,24 +645,24 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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", + Reason: "--analyze-on=spec-change leaves a metadata-only change unevaluated, while still reporting that it creates a CompositionRevision", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/original-composition-kubectl-applied.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-xr-1-kubectl-applied.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + Namespace: "default", + AnalyzeOn: "spec-change", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeSuccess, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithImpactAnalysisSkipped(). WithRevisionImpact("metadata", true, 1). @@ -986,24 +679,24 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl // 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", + Reason: "--analyze-unchanged evaluates affected XRs even though the composition is unchanged, surfacing deltas not caused by it", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + Namespace: "default", + AnalyzeUnchanged: true, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithAffectedResources(1, 1, 0, 0). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -1012,16 +705,16 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl WithFieldChange("spec.forProvider.configData", "drifted-value", "existing-value"), }, "CompositionDiffCustomNamespace": { - reason: "Validates composition diff with custom namespace", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", // Create the custom namespace first "testdata/comp/resources/custom-namespace.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ // Add existing XRs in custom namespace (should be included in output) integration.Composes( "testdata/comp/resources/existing-custom-ns-xr.yaml", @@ -1029,16 +722,16 @@ Impact analysis skipped: this composition is identical to the cluster's, so appl ), // Add existing XRs in default namespace (should be filtered out) integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), }, // New composition files that will be diffed - inputFiles: []string{"testdata/comp/updated-composition.yaml"}, - namespace: "custom-namespace", + InputFiles: []string{"../../../internal/testutils/integration/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: ` + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xnopresources.diff.example.org @@ -1112,9 +805,9 @@ Summary: 1 resource with changes --- Summary: 1 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + 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, @@ -1122,18 +815,18 @@ Summary: 1 modified`, // 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", + Reason: "Two changed compositions each report their own affected XR's downstream changes", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", "testdata/comp/resources/original-composition-2.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ // XR using composition 1 integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), // XR using composition 2 integration.Composes( @@ -1141,14 +834,14 @@ Summary: 1 modified`, "testdata/comp/resources/existing-downstream-v2.yaml", ), }, - inputFiles: []string{ - "testdata/comp/updated-composition.yaml", + InputFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/updated-composition.yaml", "testdata/comp/updated-composition-2-changed.yaml", }, - namespace: "default", - outputFormat: "json", - expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructured: simtest.Expect(). + Namespace: "default", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(1, 1, 0, 0). @@ -1171,32 +864,32 @@ Summary: 1 modified`, // 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", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", "testdata/comp/resources/original-composition-2.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, // Add existing XRs that use the compositions - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml", ), }, // Multiple composition files that will be diffed - inputFiles: []string{ - "testdata/comp/updated-composition.yaml", + InputFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/updated-composition.yaml", "testdata/comp/updated-composition-2.yaml", }, - namespace: "default", - expectedOutput: ` + Namespace: "default", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xnopresources.diff.example.org @@ -1298,35 +991,35 @@ 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, + ExpectedError: false, + ExpectedExitCode: dp.ExitCodeDiffDetected, + NoColor: true, }, "CompositionDiffFiltersManualXRs": { - reason: "Validates composition diff filters Manual policy XRs by default", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ // Add existing XR with Automatic policy (should be included) integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), // Add existing XR with Manual policy (should be filtered out by default) integration.Composes( - "testdata/comp/resources/existing-xr-manual.yaml", - "testdata/comp/resources/existing-downstream-manual.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-manual.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-manual.yaml", ), }, // Updated composition - inputFiles: []string{"testdata/comp/updated-composition.yaml"}, - namespace: "default", + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Namespace: "default", // Expected output should only show test-resource (Automatic), NOT manual-resource (Manual) - expectedOutput: ` + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xnopresources.diff.example.org @@ -1400,9 +1093,9 @@ Summary: 1 resource with changes --- Summary: 1 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + ExpectedError: false, + ExpectedExitCode: dp.ExitCodeDiffDetected, + NoColor: true, }, "CompositionUpgradesResourceAPIVersion": { // NOTE: This test validates that resources are correctly matched across API version changes, @@ -1416,19 +1109,19 @@ Summary: 1 modified`, // +++ addition and a --- removal. The composition diff itself WILL show the template change // from v1beta1 to v1beta2. // - // The XR composes the + // 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", + Reason: "Validates composition upgrade that changes resource API version shows as update not remove/add", + SetupFiles: []string{ + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { OwnerFile: "testdata/comp/resources/existing-api-version-xr.yaml", OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ @@ -1436,9 +1129,9 @@ Summary: 1 modified`, }, }, }, - inputFiles: []string{"testdata/comp/api-version-updated-composition.yaml"}, - namespace: "default", - expectedOutput: ` + InputFiles: []string{"testdata/comp/api-version-updated-composition.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xapimigrateresources.example.org @@ -1492,29 +1185,29 @@ Summary: 1 resource unchanged All composite resources are up-to-date. No downstream resource changes detected. `, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + ExpectedError: false, + ExpectedExitCode: dp.ExitCodeDiffDetected, + NoColor: true, }, "NetNewCompositionNoImpact": { - reason: "Validates net-new composition with no downstream impact", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, // Add existing XRs that use different compositions (won't be affected) - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/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: ` + InputFiles: []string{"testdata/comp/net-new-composition.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === +++ Composition/xnewresources.diff.example.org @@ -1561,20 +1254,20 @@ All composite resources are up-to-date. No downstream resource changes detected. Summary: 1 added No XRs found using composition xnewresources.diff.example.org`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + ExpectedError: false, + ExpectedExitCode: dp.ExitCodeDiffDetected, + NoColor: true, }, "CompositionChangeWithUnchangedDownstreamResources": { - reason: "Validates status indicators for unchanged downstream resources", + Reason: "Validates status indicators for unchanged downstream resources", // Set up existing XRs that use the original composition - setupFiles: []string{ - "testdata/comp/resources/xrd.yaml", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/status-indicator-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, // Add existing XRs that use the composition - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/status-xr-1.yaml", "testdata/comp/resources/status-downstream-1.yaml", @@ -1585,9 +1278,9 @@ No XRs found using composition xnewresources.diff.example.org`, ), }, // Updated composition with only metadata change (no downstream impact) - inputFiles: []string{"testdata/comp/status-indicator-updated-composition.yaml"}, - namespace: "default", - expectedOutput: ` + InputFiles: []string{"testdata/comp/status-indicator-updated-composition.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xstatus.diff.example.org @@ -1643,18 +1336,18 @@ 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, + 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", + Reason: "Validates status indicators with colorization for mixed changed/unchanged XRs", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/mixed-status-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ // XR 1 with downstream resources (standard tier - will change) integration.Composes( "testdata/comp/resources/mixed-xr-1.yaml", @@ -1677,9 +1370,9 @@ All composite resources are up-to-date. No downstream resource changes detected. "testdata/comp/resources/mixed-downstream-network-3.yaml", ), }, - inputFiles: []string{"testdata/comp/mixed-status-updated-composition.yaml"}, - namespace: "default", - expectedOutput: strings.Join([]string{ + InputFiles: []string{"testdata/comp/mixed-status-updated-composition.yaml"}, + Namespace: "default", + ExpectedOutput: strings.Join([]string{ ` === Composition Changes === @@ -1807,25 +1500,25 @@ Summary: 2 resources with changes, 1 resource unchanged Summary: 2 modified `, }, ""), - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: false, + ExpectedError: false, + ExpectedExitCode: dp.ExitCodeDiffDetected, + NoColor: false, }, "CompositionChangeImpactsClaims": { - reason: "Validates composition change impacts existing Claims (issue #120)", + Reason: "Validates composition change impacts existing Claims (issue #120)", // Set up existing Claims and their XRs that use the original composition - setupFiles: []string{ + SetupFiles: []string{ // XRD, composition, and functions "testdata/comp/resources/claim-xrd.yaml", "testdata/comp/resources/claim-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-claim-1-xr.yaml", "testdata/comp/resources/existing-claim-downstream-1.yaml", @@ -1835,11 +1528,11 @@ Summary: 2 modified "testdata/comp/resources/existing-claim-downstream-2.yaml", ), }, - xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + XrdAPIVersion: integration.XRDV1, // 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: ` + InputFiles: []string{"testdata/comp/updated-claim-composition.yaml"}, + Namespace: "test-namespace", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/nopclaims.diff.example.org @@ -1933,24 +1626,24 @@ Summary: 2 resources with changes --- Summary: 2 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + ExpectedError: false, + ExpectedExitCode: dp.ExitCodeDiffDetected, + NoColor: true, }, // Without -n every namespace is listed, so discovery finds each claim and its cluster-scoped // backing XR. The claim renders through that XR, so the XR is dropped: each change is reported // once, under the claim. "CompositionChangeImpactsClaimsAllNamespaces": { - reason: "A claim's backing XR discovered alongside it is not reported a second time", - setupFiles: []string{ + Reason: "A claim's backing XR discovered alongside it is not reported a second time", + SetupFiles: []string{ "testdata/comp/resources/claim-xrd.yaml", "testdata/comp/resources/claim-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", "testdata/comp/resources/test-namespace.yaml", "testdata/comp/resources/existing-claim-1.yaml", "testdata/comp/resources/existing-claim-2.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( "testdata/comp/resources/existing-claim-1-xr.yaml", "testdata/comp/resources/existing-claim-downstream-1.yaml", @@ -1960,11 +1653,11 @@ Summary: 2 modified`, "testdata/comp/resources/existing-claim-downstream-2.yaml", ), }, - xrdAPIVersion: integration.XRDV1, - inputFiles: []string{"testdata/comp/updated-claim-composition.yaml"}, - outputFormat: "json", - expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructured: simtest.Expect(). + XrdAPIVersion: integration.XRDV1, + InputFiles: []string{"testdata/comp/updated-claim-composition.yaml"}, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("nopclaims.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -1975,45 +1668,45 @@ Summary: 2 modified`, // two XRs on one composition that render one fixed-name child differently would both control it. // Contention is checked before differing renderings, so it is the error the run fails with. "SharedChildDifferingRendersFail": { - reason: "Two XRs a composition change renders one child for, differently, fail the run", - setupFiles: []string{ - "testdata/diff/resources/xrd.yaml", - "testdata/diff/resources/shared-child-composition.yaml", - "testdata/diff/resources/functions.yaml", + Reason: "Two XRs a composition change renders one child for, differently, fail the run", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/shared-child-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", "testdata/comp/resources/shared-child-xrs-differing.yaml", }, - inputFiles: []string{"testdata/comp/updated-shared-child-composition.yaml"}, - namespace: "default", - expectedError: true, - expectedExitCode: dp.ExitCodeToolError, - expectedErrorContains: `resource "ns.nop.example.org/v1alpha1/XDownstreamResource/default/shared-child" would be ` + + InputFiles: []string{"testdata/comp/updated-shared-child-composition.yaml"}, + Namespace: "default", + 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)`, }, "SharedChildContendedEvenWhenIdentical": { - reason: "Two XRs a composition change renders one child for, identically, still contend for it", - setupFiles: []string{ - "testdata/diff/resources/xrd.yaml", - "testdata/diff/resources/shared-child-composition.yaml", - "testdata/diff/resources/functions.yaml", - "testdata/diff/shared-child-xrs.yaml", - }, - inputFiles: []string{"testdata/comp/updated-shared-child-composition.yaml"}, - namespace: "default", - expectedError: true, - expectedExitCode: dp.ExitCodeToolError, - expectedErrorContains: `resource "ns.nop.example.org/v1alpha1/XDownstreamResource/default/shared-child" would be ` + + Reason: "Two XRs a composition change renders one child for, identically, still contend for it", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/shared-child-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/diff/shared-child-xrs.yaml", + }, + InputFiles: []string{"testdata/comp/updated-shared-child-composition.yaml"}, + Namespace: "default", + 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)`, }, "SSAFieldRemovalDetection": { - reason: "Validates that field removals are correctly detected when resources are created via Server-Side Apply with Crossplane's field manager (issue #121)", + 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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { OwnerFile: "testdata/comp/resources/field-removal/existing-xr.yaml", OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ @@ -2022,9 +1715,9 @@ Summary: 2 modified`, }, }, // Updated composition that removes the removableField - inputFiles: []string{"testdata/comp/field-removal-updated-composition.yaml"}, - namespace: "default", - expectedOutput: ` + InputFiles: []string{"testdata/comp/field-removal-updated-composition.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xfieldremoval.diff.example.org @@ -2094,26 +1787,26 @@ Summary: 1 resource with changes --- Summary: 1 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + 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", + Reason: "Validates composition diff works with functions referenced by SHA256 digest instead of tag", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", "testdata/comp/resources/sha256-composition.yaml", "testdata/comp/resources/functions-sha256.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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: ` + InputFiles: []string{"testdata/comp/updated-sha256-composition.yaml"}, + Namespace: "default", + ExpectedOutput: ` === Composition Changes === ~~~ Composition/xnopresources-sha256.diff.example.org @@ -2187,9 +1880,9 @@ Summary: 1 resource with changes --- Summary: 1 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + 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 @@ -2197,22 +1890,22 @@ Summary: 1 modified`, // 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", + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/cycle/xrds.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/cycle/compositions.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/updated-cycle-composition.yaml"}, + Namespace: "default", + ExpectedError: true, + ExpectedErrorContains: "impact analysis failed for 1 XR(s)", + ExpectedExitCode: dp.ExitCodeToolError, + ExpectedStructured: simtest.Expect(). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). WithXRImpact("XCycleA", "test-cycle", "default", "error"). @@ -2220,21 +1913,21 @@ Summary: 1 modified`, }, // The same failure, asserting the composite's error is recorded against it in the top-level errors[]. "CyclicCompositionStopsAtMaxNestedDepthV2": { - 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", - "testdata/comp/resources/functions.yaml", + Reason: "A failing composite's error is a structured errors[] entry recorded against it", + OutputFormat: "json", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/cycle/xrds.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/cycle/compositions.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/updated-cycle-composition.yaml"}, + Namespace: "default", + ExpectedError: true, + ExpectedErrorContains: "impact analysis failed for 1 XR(s)", + ExpectedExitCode: dp.ExitCodeToolError, + ExpectedStructured: simtest.Expect(). WithErrorID("XCycleA/test-cycle"). WithComposition("xcycleas.cycle.example.org"). WithAffectedResources(1, 0, 0, 1). @@ -2243,19 +1936,19 @@ Summary: 1 modified`, WithErrorResourceID("XCycleA/test-cycle"), }, "NestedXRUsesOwnComposition": { - reason: "Validates that nested XRs use their own composition from the cluster, not the parent's CLI composition", + 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{ + 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", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { // Parent XR owns both direct downstream and nested child XR OwnerFile: "testdata/comp/resources/nested-xr/existing-parent-xr.yaml", @@ -2273,8 +1966,8 @@ Summary: 1 modified`, }, }, // Diff the updated parent composition - inputFiles: []string{"testdata/comp/updated-parent-composition.yaml"}, - namespace: "default", + 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) @@ -2283,7 +1976,7 @@ Summary: 1 modified`, // 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 === + ExpectedOutput: `=== Impact Analysis === ~~~ XDownstreamResource/test-parent-direct apiVersion: ns.nop.example.org/v1alpha1 @@ -2304,14 +1997,14 @@ Summary: 1 modified`, --- Summary: 1 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + 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. " + + 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 " + @@ -2320,18 +2013,18 @@ Summary: 1 modified`, "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 + Timeout: simharness.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{ + 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", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/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 @@ -2339,7 +2032,7 @@ Summary: 1 modified`, "testdata/comp/resources/namespace-collision/configmap-ns-b.yaml", }, // XRs and their downstream resources in different namespaces - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ { OwnerFile: "testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml", OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ @@ -2353,13 +2046,13 @@ Summary: 1 modified`, }, }, }, - inputFiles: []string{"testdata/comp/updated-collision-composition.yaml"}, + 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 === + ExpectedOutput: `=== Impact Analysis === ~~~ XDownstreamResource/xr-in-ns-a apiVersion: ns.nop.example.org/v1alpha1 @@ -2395,62 +2088,62 @@ Summary: 1 modified`, --- Summary: 2 modified`, - expectedError: false, - expectedExitCode: dp.ExitCodeDiffDetected, - noColor: true, + 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", + Reason: "`comp` without --eventual-state: sequencer-gated stage1 is absent from the impact analysis", + Timeout: simharness.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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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, + 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. - expectedStructured: simtest.Expect(). + ExpectedStructured: simtest.Expect(). 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, + 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", + Reason: "`comp` with --eventual-state: sequencer-gated stage1 surfaces in the impact analysis", + Timeout: simharness.LongTimeout, // Multiple simulation iterations need more time + OutputFormat: "json", + EventualState: true, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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, + 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). - expectedStructured: simtest.Expect(). + ExpectedStructured: simtest.Expect(). WithComposition("sequencer-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). @@ -2464,55 +2157,55 @@ Summary: 2 modified`, // own envtest instance, since it is a pure additional assertion. WithDryRunPerformed(). WithField("spec.forProvider.configData", "updated-existing-value"), - expectedError: false, + 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", + Reason: "`comp` without --eventual-state: template-gated stage1 is absent from the impact analysis", + Timeout: simharness.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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/conditional-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", }, // The XR has composed stage0, which isn't Ready, so stage1 stays gated. - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, + Namespace: "default", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). 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, + 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", + Reason: "`comp` with --eventual-state: template-gated stage1 surfaces in the impact analysis", + Timeout: simharness.LongTimeout, // Multiple simulation iterations need more time + OutputFormat: "json", + EventualState: true, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/conditional-gating-composition.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/updated-conditional-gating-composition.yaml"}, + Namespace: "default", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("conditional-gating-composition"). WithCompositionModified(). WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). @@ -2522,7 +2215,7 @@ Summary: 2 modified`, AndXR(). WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). WithField("spec.forProvider.configData", "updated-existing-value"), - expectedError: false, + ExpectedError: false, }, // dryRun presence through `comp`. dryRun lives on ChangeDetail, which `xr` reaches via // changes[] and `comp` via impactAnalysis[].downstreamChanges.changes[] — one shared wire @@ -2531,21 +2224,21 @@ Summary: 2 modified`, // 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", + Reason: "`comp` structured output carries dryRun.skipReason on a downstream addition when --dry-run-on=existing", + OutputFormat: "json", + DryRunOn: "existing", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", }, - inputFiles: []string{"testdata/comp/updated-composition.yaml"}, - namespace: "default", - expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Namespace: "default", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "test-resource", "default", "changed"). @@ -2553,27 +2246,27 @@ Summary: 2 modified`, WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"). WithDryRunSkipped("disabled", ""). WithField("spec.forProvider.configData", "updated-existing-value"), - expectedError: false, + 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", + Reason: "`comp` summarises downstream additions it could not verify as one warning per GVK + namespace + cause, with a count", + OutputFormat: "json", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/cluster-xrd.yaml", "testdata/comp/resources/unverified-cluster-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/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{ + 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)`, }, - expectedStructured: simtest.Expect(). + ExpectedStructured: simtest.Expect(). WithWarning("their namespace does not exist yet"). WithWarningContext(map[string]string{ "gvk": "/v1, Kind=ConfigMap", @@ -2591,60 +2284,60 @@ Summary: 2 modified`, AndXR(). WithDownstreamResource("added", "ConfigMap", "unverified-xr-second", "nonexistent-namespace"). WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"), - expectedError: false, + 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", + Reason: "--resource limits impact analysis to the named composites and ignores the rest", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( // test-resource (default ns) - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( // another-resource (default ns) - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml", ), }, - inputFiles: []string{"testdata/comp/updated-composition.yaml"}, - resources: []string{"default/test-resource"}, - outputFormat: "json", - expectedExitCode: dp.ExitCodeDiffDetected, - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Resources: []string{"default/test-resource"}, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). 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", + 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{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml", ), }, - inputFiles: []string{"testdata/comp/updated-composition.yaml"}, - namespace: "default", + InputFiles: []string{"../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + RbacRules: simharness.LeastPrivilegeRules("get", "list", "create", "patch"), + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -2653,55 +2346,55 @@ Summary: 2 modified`, 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", + 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{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Resources: []string{"default/test-resource"}, + RbacRules: simharness.LeastPrivilegeRules("get", "create", "patch"), + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). 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", + Reason: "--resource accepts comma-separated values via kong's auto-parsing", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + ResourcesCSV: "default/test-resource,default/another-resource", + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithAffectedResources(2, 2, 0, 0). @@ -2710,66 +2403,66 @@ Summary: 2 modified`, 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", + Reason: "--resource naming a non-existent composite fails fast with an error before any rendering", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/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, + InputFiles: []string{"../../../internal/testutils/integration/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", + Reason: "--resource matching a Manual-policy composite surfaces it as filtered (reason manual_policy) when --include-manual is unset", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-manual.yaml", - "testdata/comp/resources/existing-downstream-manual.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-manual.yaml", + "../../../internal/testutils/integration/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 - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Resources: []string{"default/manual-resource"}, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "filtered"). WithFilterReason("manualPolicy"), }, "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", + Reason: "--include-manual evaluates the Manual-policy composite normally instead of marking it filtered", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, - crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-manual.yaml", - "testdata/comp/resources/existing-downstream-manual.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-manual.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"../../../internal/testutils/integration/testdata/comp/updated-composition.yaml"}, + Resources: []string{"default/manual-resource"}, + IncludeManual: true, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "manual-resource", "default", "changed"), @@ -2778,18 +2471,18 @@ Summary: 2 modified`, // 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", + 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{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/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 - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/updated-composition-labeled.yaml"}, + Resources: []string{"default/selector-mismatch-resource"}, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). @@ -2802,18 +2495,18 @@ Summary: 2 modified`, // 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", + Reason: "--resource matching an Automatic XR whose compositionRevisionSelector matches the composition's labels keeps it and reports downstream changes", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/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, - expectedStructured: simtest.Expect(). + InputFiles: []string{"testdata/comp/updated-composition-labeled.yaml"}, + Resources: []string{"default/selector-match-resource"}, + OutputFormat: "json", + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). WithComposition("xnopresources.diff.example.org"). WithCompositionModified(). WithXRImpact("XNopResource", "selector-match-resource", "default", "changed"), @@ -2822,7 +2515,7 @@ Summary: 2 modified`, for name, tt := range tests { t.Run(name, func(t *testing.T) { - runIntegrationTest(t, CompositionDiffTest, tt) + simharness.RunIntegrationTest(t, simharness.CompositionTest, tt) }) } } diff --git a/cmd/crossplane/composition/testdata/README.md b/cmd/crossplane/composition/testdata/README.md new file mode 100644 index 00000000..18bb223d --- /dev/null +++ b/cmd/crossplane/composition/testdata/README.md @@ -0,0 +1,12 @@ +# composition simulate integration tests + +`integration_test.go` runs `TestCompDiffIntegration`: crossplane-diff's composition cases, run as +`crossplane composition simulate -f ` against an envtest cluster. A case keeps its +crossplane-diff args; `simharness`'s `RunIntegrationTest` translates them +into the command's flags and runs it. Fixtures only this suite uses are in +`testdata/comp`; those shared with another suite are in +`internal/testutils/integration/testdata/comp` (and `crds`). + +The skips, how to run the suite and its coverage are in +`cmd/crossplane/project/testdata/README.md`. Run only this suite with +`go test -tags integration -run TestCompDiffIntegration ./cmd/crossplane/composition/`. diff --git a/cmd/crossplane/project/testdata/comp/api-version-updated-composition.yaml b/cmd/crossplane/composition/testdata/comp/api-version-updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/api-version-updated-composition.yaml rename to cmd/crossplane/composition/testdata/comp/api-version-updated-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/claim-revision-templating-updated-composition.yaml b/cmd/crossplane/composition/testdata/comp/claim-revision-templating-updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/claim-revision-templating-updated-composition.yaml rename to cmd/crossplane/composition/testdata/comp/claim-revision-templating-updated-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/composition-no-changes.yaml b/cmd/crossplane/composition/testdata/comp/composition-no-changes.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/composition-no-changes.yaml rename to cmd/crossplane/composition/testdata/comp/composition-no-changes.yaml diff --git a/cmd/crossplane/project/testdata/comp/field-removal-updated-composition.yaml b/cmd/crossplane/composition/testdata/comp/field-removal-updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/field-removal-updated-composition.yaml rename to cmd/crossplane/composition/testdata/comp/field-removal-updated-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/mixed-status-updated-composition.yaml b/cmd/crossplane/composition/testdata/comp/mixed-status-updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/mixed-status-updated-composition.yaml rename to cmd/crossplane/composition/testdata/comp/mixed-status-updated-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/net-new-composition.yaml b/cmd/crossplane/composition/testdata/comp/net-new-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/net-new-composition.yaml rename to cmd/crossplane/composition/testdata/comp/net-new-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/api-version-original-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/api-version-original-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/api-version-original-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/api-version-original-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/claim-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/claim-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/claim-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml b/cmd/crossplane/composition/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml rename to cmd/crossplane/composition/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/claim-revision-templating-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/claim-revision-templating-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-xrd.yaml b/cmd/crossplane/composition/testdata/comp/resources/claim-xrd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/claim-xrd.yaml rename to cmd/crossplane/composition/testdata/comp/resources/claim-xrd.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/custom-namespace.yaml b/cmd/crossplane/composition/testdata/comp/resources/custom-namespace.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/custom-namespace.yaml rename to cmd/crossplane/composition/testdata/comp/resources/custom-namespace.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/cycle/existing-cycle-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/cycle/existing-cycle-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/cycle/existing-cycle-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/cycle/existing-cycle-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-api-version-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-api-version-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-api-version-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-api-version-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-1-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-1-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-1-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-1-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-2-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-2-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-2-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-2-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-downstream-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-downstream-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-downstream-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-downstream-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-claim-revision-ref.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-stage0.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-conditional-gating-stage0.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-stage0.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-conditional-gating-stage0.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-conditional-gating-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-conditional-gating-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-custom-ns-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-custom-ns-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-custom-ns-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-custom-ns-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-deleting.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-downstream-deleting.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-deleting.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-downstream-deleting.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-drifted.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-downstream-drifted.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-drifted.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-downstream-drifted.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-v2.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-downstream-v2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-v2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-downstream-v2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-with-argocd.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-downstream-with-argocd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-with-argocd.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-downstream-with-argocd.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-stage0.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-sequencer-gating-stage0.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-stage0.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-sequencer-gating-stage0.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-sequencer-gating-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-sequencer-gating-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sha256-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-sha256-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-sha256-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-sha256-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sha256-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-sha256-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-sha256-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-sha256-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-unverified-cluster-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-unverified-cluster-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-unverified-cluster-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-unverified-cluster-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-deleting.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-deleting.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-deleting.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-deleting.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-match.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-selector-match.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-match.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-selector-match.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-mismatch.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-selector-mismatch.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-mismatch.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-selector-mismatch.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-v2.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-v2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-v2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-v2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-with-argocd.yaml b/cmd/crossplane/composition/testdata/comp/resources/existing-xr-with-argocd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-with-argocd.yaml rename to cmd/crossplane/composition/testdata/comp/resources/existing-xr-with-argocd.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/field-removal/composition-with-field.yaml b/cmd/crossplane/composition/testdata/comp/resources/field-removal/composition-with-field.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/field-removal/composition-with-field.yaml rename to cmd/crossplane/composition/testdata/comp/resources/field-removal/composition-with-field.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/field-removal/existing-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/field-removal/existing-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/field-removal/existing-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/field-removal/existing-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/field-removal/existing-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/field-removal/existing-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/functions-sha256.yaml b/cmd/crossplane/composition/testdata/comp/resources/functions-sha256.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/functions-sha256.yaml rename to cmd/crossplane/composition/testdata/comp/resources/functions-sha256.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-db-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-db-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-db-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-db-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-3.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-db-3.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-3.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-db-3.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-network-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-network-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-network-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-network-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-3.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-network-3.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-3.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-network-3.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-storage-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-storage-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-storage-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-storage-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-3.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-storage-3.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-3.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-downstream-storage-3.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-status-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-status-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-status-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-status-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-xr-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-xr-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-xr-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-xr-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-xr-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-xr-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-xr-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-xr-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-xr-3.yaml b/cmd/crossplane/composition/testdata/comp/resources/mixed-xr-3.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/mixed-xr-3.yaml rename to cmd/crossplane/composition/testdata/comp/resources/mixed-xr-3.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/collision-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/collision-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml b/cmd/crossplane/composition/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml rename to cmd/crossplane/composition/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-child-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-child-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-child-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-child-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml b/cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml rename to cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-xr.yaml b/cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-parent-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-xr.yaml rename to cmd/crossplane/composition/testdata/comp/resources/nested-xr/existing-parent-xr.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/nested-xr/parent-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/nested-xr/parent-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-xrd.yaml b/cmd/crossplane/composition/testdata/comp/resources/nested-xr/parent-xrd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-xrd.yaml rename to cmd/crossplane/composition/testdata/comp/resources/nested-xr/parent-xrd.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/original-composition-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/original-composition-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/original-composition-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied-current.yaml b/cmd/crossplane/composition/testdata/comp/resources/original-composition-kubectl-applied-current.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied-current.yaml rename to cmd/crossplane/composition/testdata/comp/resources/original-composition-kubectl-applied-current.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied.yaml b/cmd/crossplane/composition/testdata/comp/resources/original-composition-kubectl-applied.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied.yaml rename to cmd/crossplane/composition/testdata/comp/resources/original-composition-kubectl-applied.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/removal-test-original-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/removal-test-original-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/removal-test-original-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/removal-test-original-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-older-revision.yaml b/cmd/crossplane/composition/testdata/comp/resources/revision-templating-composition-older-revision.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-older-revision.yaml rename to cmd/crossplane/composition/testdata/comp/resources/revision-templating-composition-older-revision.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/sha256-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/sha256-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/sha256-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/sha256-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/shared-child-xrs-differing.yaml b/cmd/crossplane/composition/testdata/comp/resources/shared-child-xrs-differing.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/shared-child-xrs-differing.yaml rename to cmd/crossplane/composition/testdata/comp/resources/shared-child-xrs-differing.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/status-downstream-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/status-downstream-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/status-downstream-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/status-downstream-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/status-downstream-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/status-downstream-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/status-downstream-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/status-downstream-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/status-indicator-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/status-indicator-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/status-indicator-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/status-indicator-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/status-xr-1.yaml b/cmd/crossplane/composition/testdata/comp/resources/status-xr-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/status-xr-1.yaml rename to cmd/crossplane/composition/testdata/comp/resources/status-xr-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/status-xr-2.yaml b/cmd/crossplane/composition/testdata/comp/resources/status-xr-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/status-xr-2.yaml rename to cmd/crossplane/composition/testdata/comp/resources/status-xr-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/test-namespace.yaml b/cmd/crossplane/composition/testdata/comp/resources/test-namespace.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/test-namespace.yaml rename to cmd/crossplane/composition/testdata/comp/resources/test-namespace.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/unverified-cluster-composition.yaml b/cmd/crossplane/composition/testdata/comp/resources/unverified-cluster-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/unverified-cluster-composition.yaml rename to cmd/crossplane/composition/testdata/comp/resources/unverified-cluster-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/revision-templating-updated-composition.yaml b/cmd/crossplane/composition/testdata/comp/revision-templating-updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/revision-templating-updated-composition.yaml rename to cmd/crossplane/composition/testdata/comp/revision-templating-updated-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/status-indicator-updated-composition.yaml b/cmd/crossplane/composition/testdata/comp/status-indicator-updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/status-indicator-updated-composition.yaml rename to cmd/crossplane/composition/testdata/comp/status-indicator-updated-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-claim-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-claim-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-claim-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-claim-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-collision-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-collision-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-collision-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-collision-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-composition-2-changed.yaml b/cmd/crossplane/composition/testdata/comp/updated-composition-2-changed.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-composition-2-changed.yaml rename to cmd/crossplane/composition/testdata/comp/updated-composition-2-changed.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-composition-2.yaml b/cmd/crossplane/composition/testdata/comp/updated-composition-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-composition-2.yaml rename to cmd/crossplane/composition/testdata/comp/updated-composition-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-composition-labeled.yaml b/cmd/crossplane/composition/testdata/comp/updated-composition-labeled.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-composition-labeled.yaml rename to cmd/crossplane/composition/testdata/comp/updated-composition-labeled.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-conditional-gating-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-conditional-gating-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-conditional-gating-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-conditional-gating-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-cycle-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-cycle-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-cycle-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-cycle-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-parent-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-parent-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-parent-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-parent-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-sequencer-gating-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-sequencer-gating-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-sequencer-gating-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-sequencer-gating-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-sha256-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-sha256-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-sha256-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-sha256-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-shared-child-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-shared-child-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-shared-child-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-shared-child-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-unverified-cluster-composition.yaml b/cmd/crossplane/composition/testdata/comp/updated-unverified-cluster-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-unverified-cluster-composition.yaml rename to cmd/crossplane/composition/testdata/comp/updated-unverified-cluster-composition.yaml diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 8b523937..0a1844e6 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -2,7 +2,9 @@ The `project simulate` command previews how applying a project's Compositions 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. To simulate Composition files against a cluster -without a project, use `crossplane composition simulate`. +without a project, use `crossplane composition simulate`; to simulate composite +resource or claim files, use `crossplane xr simulate`; to simulate managed +resources directly, use `crossplane resource simulate`. > **Important:** Although it doesn't change the cluster, this command needs > `create` and `patch` permission on the resources it renders. It checks each diff --git a/cmd/crossplane/project/integration_main_test.go b/cmd/crossplane/project/integration_main_test.go index 3087fcde..23aba2e8 100644 --- a/cmd/crossplane/project/integration_main_test.go +++ b/cmd/crossplane/project/integration_main_test.go @@ -1,6 +1,6 @@ //go:build integration -package project +package project_test import ( "fmt" diff --git a/cmd/crossplane/project/integration_plan_output_test.go b/cmd/crossplane/project/integration_plan_output_test.go index 38ca236e..dbed8e77 100644 --- a/cmd/crossplane/project/integration_plan_output_test.go +++ b/cmd/crossplane/project/integration_plan_output_test.go @@ -16,7 +16,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -package project +package project_test import ( "bytes" @@ -34,24 +34,37 @@ import ( "github.com/crossplane/cli/v2/internal/plan" "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/testutils/integration" + "github.com/crossplane/cli/v2/internal/testutils/integration/simharness" ) -// TestSimulatePlanOutput runs project simulate's -o json simulate.ProjectResult -// against the plain project fixture, which the diff cases cannot -// express. Each case passes simulate args directly. +// TestSimulatePlanOutput runs simulate's own output formats, -o plan and +// -o json's simulate.Result, for xr, composition and the plain project +// fixture, 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() + xrCase := integration.Setup{ + CRDDirs: []string{"../../../internal/testutils/integration/testdata/diff/crds"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", "../../../internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml"), + }, + } compCase := integration.Setup{ - CRDDirs: []string{"testdata/comp/crds"}, + CRDDirs: []string{"../../../internal/testutils/integration/testdata/comp/crds"}, SetupFiles: []string{ - "testdata/comp/resources/xrd.yaml", - "testdata/comp/resources/original-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ - integration.Composes("testdata/comp/resources/existing-xr-1.yaml", "testdata/comp/resources/existing-downstream-1.yaml"), - integration.Composes("testdata/comp/resources/existing-xr-2.yaml", "testdata/comp/resources/existing-downstream-2.yaml"), + integration.Composes("../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml"), + integration.Composes("../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml"), }, } @@ -60,19 +73,79 @@ func TestSimulatePlanOutput(t *testing.T) { another.Name = "another-resource" comp := plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "xnopresources.diff.example.org"} - compArgs := []string{"-f", "testdata/comp/updated-composition.yaml", "--namespace=default"} + xrArgs := []string{"-f", "../../../internal/testutils/integration/testdata/diff/modified-xr.yaml"} + compArgs := []string{"-f", "../../../internal/testutils/integration/testdata/comp/updated-composition.yaml", "--namespace=default"} cases := map[string]struct { reason string - // args follow the plain project's -f, or with compArgv set, `composition simulate`. + // args follow the plain project's -f, or `composition simulate`'s with compArgv, or `xr simulate`'s with tt. args []string // compArgv runs `composition simulate` with args instead. compArgv bool + // tt, when set, runs `xr simulate` with args against its cluster + // instead of compCase's. + tt *integration.Setup // 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.", + 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: Provider plan not run: no provider diff server planned these resources. +`, + }, + "XRCreatePlanText": { + reason: "-o plan prints a dotted annotation or label key, such as crossplane.io/composite, whole as one level.", + tt: &integration.Setup{CRDDirs: xrCase.CRDDirs, SetupFiles: xrCase.SetupFiles}, + args: []string{"-f", "../../../internal/testutils/integration/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: Provider plan not run: no provider diff server planned these resources. +`, + }, + "XRPlanJSON": { + reason: "-o json emits simulate.Result: the XR as a changed composite, with both its plans grouped under it.", + tt: &xrCase, + args: append([]string{"-o", "json"}, xrArgs...), + wantResult: &planShape{ + Summary: plan.Summary{Update: 2}, + Composites: []simulate.CompositeResult{{Ref: xnop, Status: simulate.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.", compArgv: true, @@ -105,7 +178,7 @@ Note: Provider plan not run: no provider diff server planned these resources. `, }, "CompPlanJSON": { - reason: "-o json emits simulate.ProjectResult: the Composition's revision impact, both affected XRs as changed composites, and their plans.", + reason: "-o json emits simulate.Result: the Composition's revision impact, both affected XRs as changed composites, and their plans.", compArgv: true, args: append([]string{"-o", "json"}, compArgs...), wantResult: &planShape{ @@ -170,14 +243,21 @@ Note: Provider plan not run: no provider diff server planned these resources. t.Skip(reason) } - cfg := integration.Start(t, compCase).Config + setup := compCase + if tc.tt != nil { + setup = *tc.tt + } + cfg := integration.Start(t, setup).Config var stdout, stderr bytes.Buffer argv := append(plainProjectArgv(t), tc.args...) - if tc.compArgv { - argv = append([]string{"composition", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) + switch { + case tc.compArgv: + argv = append([]string{"composition", "simulate", "--timeout=" + simharness.DefaultTimeout.String()}, tc.args...) + case tc.tt != nil: + argv = append([]string{"xr", "simulate", "--timeout=" + simharness.DefaultTimeout.String()}, tc.args...) } - code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) + code, err := simharness.RunSimulateArgv(t, cfg, argv, simharness.LocalCrossplaneBinary(t), nil, &stdout, &stderr) if err != nil { t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) } @@ -192,9 +272,9 @@ Note: Provider plan not run: no provider diff server planned these resources. return } - var res simulate.ProjectResult + var res simulate.Result if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { - t.Fatalf("\n%s\nsimulate -o json: output is not a simulate.ProjectResult: %v\n%s", tc.reason, err, stdout.String()) + t.Fatalf("\n%s\nsimulate -o json: output is not a simulate.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) @@ -206,7 +286,7 @@ Note: Provider plan not run: no provider diff server planned these resources. } } -// planShape is the part of a simulate.ProjectResult these cases pin: everything but +// planShape is the part of a simulate.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 { @@ -223,7 +303,7 @@ type planEntry struct { Composite string } -func summarize(r simulate.ProjectResult) planShape { +func summarize(r simulate.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} @@ -248,7 +328,7 @@ func summarize(r simulate.ProjectResult) planShape { func integrationEnvGap() string { // integration.Start fails, rather than skips, a run without envtest's // binaries or Crossplane's CRDs. - if !dockerAvailable() { + if !simharness.DockerAvailable() { return "env: no Docker daemon for the function render engine" } return "" @@ -277,7 +357,7 @@ func TestSimulatePinnedChangedFunction(t *testing.T) { }, "PinnedXRWithUnchangedFunctionDeferred": { reason: "A Manual XR whose pinned revision calls no changed function stays deferred.", - composition: "testdata/comp/resources/original-composition.yaml", + composition: "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", wantDeferred: true, }, } @@ -291,17 +371,17 @@ func TestSimulatePinnedChangedFunction(t *testing.T) { } cluster := integration.Start(t, integration.Setup{ - CRDDirs: []string{"testdata/comp/crds"}, + CRDDirs: []string{"../../../internal/testutils/integration/testdata/comp/crds"}, SetupFiles: []string{ - "testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", tc.composition, - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", "testdata/project/embedded/cluster-function.yaml", }, CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-manual.yaml", - "testdata/comp/resources/existing-downstream-manual.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-manual.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-manual.yaml", ), }, }) @@ -316,20 +396,20 @@ func TestSimulatePinnedChangedFunction(t *testing.T) { } rev, _, _ := un.NestedString(xr.Object, "spec", "crossplane", "compositionRevisionRef", "name") - base := withRenderBinary([]string{ - "project", "simulate", "--timeout=" + defaultTimeout.String(), + base := simharness.WithRenderBinary([]string{ + "project", "simulate", "--timeout=" + simharness.DefaultTimeout.String(), "-f", copyProjectFixture(t, "embedded"), "--cache-dir=" + t.TempDir(), "--skip-plan", "-n", "default", - }, localCrossplaneBinary(t)) + }, simharness.LocalCrossplaneBinary(t)) var stdout, stderr bytes.Buffer - code, err := runSimulateArgvWith(t, cfg, append(slices.Clip(base), "-o", "json"), &simulate.Hooks{}, &stdout, &stderr) + code, err := simharness.RunSimulateArgvWith(t, cfg, append(slices.Clip(base), "-o", "json"), &simulate.Hooks{}, &stdout, &stderr) if err != nil { t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) } - var res simulate.ProjectResult + var res simulate.Result if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { - t.Fatalf("\n%s\nsimulate -o json: output is not a simulate.ProjectResult: %v\n%s", tc.reason, err, stdout.String()) + t.Fatalf("\n%s\nsimulate -o json: output is not a simulate.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) @@ -364,7 +444,7 @@ func TestSimulatePinnedChangedFunction(t *testing.T) { } var text bytes.Buffer - if _, err := runSimulateArgvWith(t, cfg, base, &simulate.Hooks{}, &text, &stderr); err != nil { + if _, err := simharness.RunSimulateArgvWith(t, cfg, base, &simulate.Hooks{}, &text, &stderr); err != nil { t.Fatalf("simulate -o plan: unexpected error: %v", err) } if os.Getenv("SIMULATE_DEMO") != "" { @@ -385,15 +465,15 @@ func TestSimulateNameRejected(t *testing.T) { t.Parallel() compCase := integration.Setup{ - CRDDirs: []string{"testdata/comp/crds"}, + CRDDirs: []string{"../../../internal/testutils/integration/testdata/comp/crds"}, SetupFiles: []string{ - "testdata/comp/resources/xrd.yaml", - "testdata/comp/resources/original-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ - integration.Composes("testdata/comp/resources/existing-xr-1.yaml", "testdata/comp/resources/existing-downstream-1.yaml"), - integration.Composes("testdata/comp/resources/existing-xr-2.yaml", "testdata/comp/resources/existing-downstream-2.yaml"), + integration.Composes("../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml"), + integration.Composes("../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml"), }, } cases := map[string]struct { @@ -428,7 +508,7 @@ func TestSimulateNameRejected(t *testing.T) { var stdout, stderr bytes.Buffer argv := append(plainProjectArgv(t), tc.args...) - code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) + code, err := simharness.RunSimulateArgv(t, cfg, argv, simharness.LocalCrossplaneBinary(t), nil, &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) } @@ -448,7 +528,7 @@ func plainProjectArgv(t *testing.T) []string { t.Helper() return []string{ - "project", "simulate", "--timeout=" + defaultTimeout.String(), + "project", "simulate", "--timeout=" + simharness.DefaultTimeout.String(), "-f", copyProjectFixture(t, "plain"), "--cache-dir=" + t.TempDir(), } diff --git a/cmd/crossplane/project/integration_project_test.go b/cmd/crossplane/project/integration_project_test.go index ff904f86..e1a2d3e9 100644 --- a/cmd/crossplane/project/integration_project_test.go +++ b/cmd/crossplane/project/integration_project_test.go @@ -16,11 +16,10 @@ See the License for the specific language governing permissions and limitations under the License. */ -package project +package project_test import ( "bytes" - "context" "encoding/json" "os" "path/filepath" @@ -28,37 +27,14 @@ import ( diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" "github.com/google/go-cmp/cmp" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/structpb" - "k8s.io/apimachinery/pkg/runtime/schema" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/plan" "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/testutils/integration" + "github.com/crossplane/cli/v2/internal/testutils/integration/simharness" ) -// 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)}} -} - // 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. @@ -114,18 +90,18 @@ func TestProjectSimulateIntegration(t *testing.T) { "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", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), integration.Composes( - "testdata/comp/resources/existing-xr-2.yaml", - "testdata/comp/resources/existing-downstream-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml", ), }, project: "plain", @@ -151,14 +127,14 @@ func TestProjectSimulateIntegration(t *testing.T) { "ProviderPlanJoinsChanges": { reason: "A provider plan routed for a composed resource joins that resource's changes.", setupFiles: []string{ - "testdata/comp/resources/xrd.yaml", - "testdata/comp/resources/original-composition.yaml", - "testdata/comp/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", }, crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), }, project: "providerdep", @@ -182,15 +158,15 @@ func TestProjectSimulateIntegration(t *testing.T) { "EmbeddedFunctionOverlay": { 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", + "../../../internal/testutils/integration/testdata/comp/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/original-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/functions.yaml", "testdata/project/embedded/cluster-function.yaml", }, crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ integration.Composes( - "testdata/comp/resources/existing-xr-1.yaml", - "testdata/comp/resources/existing-downstream-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml", ), }, project: "embedded", @@ -217,13 +193,13 @@ func TestProjectSimulateIntegration(t *testing.T) { } cfg := integration.Start(t, integration.Setup{ - CRDDirs: []string{"testdata/comp/crds"}, + CRDDirs: []string{"../../../internal/testutils/integration/testdata/comp/crds"}, SetupFiles: tc.setupFiles, CrossplaneManagedResources: tc.crossplaneManagedResources, }).Config argv := []string{ - "project", "simulate", "--timeout=" + defaultTimeout.String(), + "project", "simulate", "--timeout=" + simharness.DefaultTimeout.String(), "-f", copyProjectFixture(t, tc.project), "--cache-dir=" + t.TempDir(), } @@ -234,7 +210,7 @@ func TestProjectSimulateIntegration(t *testing.T) { argv = append(argv, "-o", "json") var stdout, stderr bytes.Buffer - code, err := runSimulateArgvWith(t, cfg, withRenderBinary(argv, localCrossplaneBinary(t)), &simulate.Hooks{Router: tc.router}, &stdout, &stderr) + code, err := simharness.RunSimulateArgvWith(t, cfg, simharness.WithRenderBinary(argv, simharness.LocalCrossplaneBinary(t)), &simulate.Hooks{Router: tc.router}, &stdout, &stderr) if err != nil { t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) } @@ -242,9 +218,9 @@ func TestProjectSimulateIntegration(t *testing.T) { t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, tc.expectedExitCode, code) } - var res simulate.ProjectResult + var res simulate.Result if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { - t.Fatalf("\n%s\nsimulate -o json: output is not a simulate.ProjectResult: %v\n%s", tc.reason, err, stdout.String()) + t.Fatalf("\n%s\nsimulate -o json: output is not a simulate.Result: %v\n%s", tc.reason, err, stdout.String()) } if diff := cmp.Diff(tc.expectedResult, summarize(res)); diff != "" { t.Errorf("\n%s\nsimulate -o json: -want, +got:\n%s", tc.reason, diff) diff --git a/cmd/crossplane/project/integration_provider_plan_test.go b/cmd/crossplane/project/integration_provider_plan_test.go new file mode 100644 index 00000000..561eb48f --- /dev/null +++ b/cmd/crossplane/project/integration_provider_plan_test.go @@ -0,0 +1,305 @@ +//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 project_test + +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" + "github.com/crossplane/cli/v2/internal/simulate" + "github.com/crossplane/cli/v2/internal/testutils/integration" + "github.com/crossplane/cli/v2/internal/testutils/integration/simharness" +) + +// 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() + + xrCase := integration.Setup{ + CRDDirs: []string{"../../../internal/testutils/integration/testdata/diff/crds"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", "../../../internal/testutils/integration/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 simulate.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{"Provider plan not run"}, + check: func(t *testing.T, res simulate.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.", + 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{"~~~ XDownstreamResource/test-resource (requires replacement)", "(1 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 simulate.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)) + } + // 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": { + 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 simulate.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: "../../../internal/testutils/integration/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) + } + + cluster := integration.Start(t, xrCase) + cfg, kube, ctx := cluster.Config, cluster.Client, cluster.Context + if tc.ownCRD { + ownByProviderRevision(ctx, t, kube, "xdownstreamresources.ns.nop.example.org") + } + + xr := tc.xrFile + if xr == "" { + xr = "../../../internal/testutils/integration/testdata/diff/modified-xr.yaml" + } + argv := append([]string{"xr", "simulate", "--timeout=" + simharness.DefaultTimeout.String(), "-f", xr}, tc.args...) + + var stdout, stderr bytes.Buffer + code, err := simharness.RunSimulateArgv(t, cfg, argv, simharness.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 simulate.Result + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("\n%s\nstdout is not a simulate.Result: %v\n%s\nstderr:\n%s", tc.reason, err, stdout.String(), stderr.String()) + } + tc.check(t, res) + } + }) + } +} + +func planJSON(t *testing.T, res simulate.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/cmd/crossplane/project/testdata/README.md b/cmd/crossplane/project/testdata/README.md index 11bc26cd..c86a2ab1 100644 --- a/cmd/crossplane/project/testdata/README.md +++ b/cmd/crossplane/project/testdata/README.md @@ -1,23 +1,28 @@ -# Simulate integration tests +# Project simulate integration tests The `integration*_test.go` files in `cmd/crossplane/project` run `crossplane project simulate -f ` against fixture projects under `testdata/project`, each against its own envtest cluster seeded from -`testdata/comp/resources`. `TestCompDiffIntegration` runs -`crossplane composition simulate -f ` with the -crossplane-diff cases and fixtures under `testdata/comp`, plus a few shared -ones under `testdata/diff`. +the shared fixtures in `internal/testutils/integration/testdata/comp`. +`TestSimulatePlanOutput` and `TestSimulateProviderPlan` also run +`composition simulate` and `xr simulate` for their output formats. The +`composition` and `xr` suites, with their own `integration_test.go`, are +in `cmd/crossplane/composition` and `cmd/crossplane/xr`; the harness all +three share is `internal/testutils/integration/simharness`, and the +fixtures more than one suite uses are in +`internal/testutils/integration/testdata`. ## How a case runs A case copies its project fixture to a temp dir and runs the real command through kong with `runSimulateArgvWith` in -`integration_simulate_harness_test.go`. `simulate.Hooks` points the +`internal/testutils/integration/simharness`. `simulate.Hooks` points the command at the case's envtest cluster in place of a kubeconfig, so the cases run in parallel. -A `TestCompDiffIntegration` case keeps its crossplane-diff args; -`runSimulate` translates them into `composition simulate`'s flags (its +A `TestCompDiffIntegration` (`cmd/crossplane/composition`) or `TestDiffIntegration` (`cmd/crossplane/xr`) case keeps its crossplane-diff args; +`runSimulate` translates them into `composition simulate`'s or +`xr simulate`'s flags (its files as `-f`, `--resource` as `--name`, `--include-manual` as `--include-pinned`) and runs the command the same way. @@ -36,7 +41,7 @@ unset. # Full suite (Docker + envtest assets required): CROSSPLANE_CRDS_DIR= \ KUBEBUILDER_ASSETS=$(setup-envtest use -p path) \ -go test -tags integration -v ./cmd/crossplane/project/ +go test -tags integration -v ./cmd/crossplane/project/ ./cmd/crossplane/composition/ ./cmd/crossplane/xr/ # One case: go test -tags integration -run 'TestProjectSimulateIntegration/PlainProject' ./cmd/crossplane/project/ diff --git a/cmd/crossplane/simulate/help/simulate.md b/cmd/crossplane/simulate/help/simulate.md index 9caa16f0..b980ff43 100644 --- a/cmd/crossplane/simulate/help/simulate.md +++ b/cmd/crossplane/simulate/help/simulate.md @@ -11,6 +11,11 @@ desired and live state. The provider computes its own diff, so the preview includes replacement detection, values known only after apply, and redacted sensitive values. A resource with no live counterpart plans as a create. +This command takes managed resources. To simulate composite resources or +claims, use `crossplane xr simulate`; to simulate Compositions, use +`crossplane composition simulate`; to simulate a project, use +`crossplane project simulate`. + Which providers run as diff servers comes from the current kubeconfig context by default: the CLI follows each resource's kind to the provider that installed it on the cluster, so the preview matches the provider versions diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md new file mode 100644 index 00000000..95e9efe1 --- /dev/null +++ b/cmd/crossplane/xr/help/simulate.md @@ -0,0 +1,28 @@ +The `xr simulate` command previews how applying changed composite resources or +claims would change a cluster. It renders each one with the Composition the +cluster selects for it and the functions installed in the cluster, and shows +the difference from what's in the cluster. It doesn't change the cluster. + +> **Important:** Although it doesn't change the cluster, this command needs +> `create` and `patch` permission on the resources it renders. It checks each +> rendered resource with a server-side dry-run request, and Kubernetes +> authorizes a dry-run request like a real one. It also needs read access to +> the cluster's Crossplane resources, and Docker to run functions. Use `--as`, +> `--as-group`, and `--as-uid` to simulate as another user. + +The command connects to the cluster in your current Kubernetes context, or the +one `--context` names. + +`-f` (`--input`) names a file, or a directory of YAML files, of composite +resources or claims to simulate, and is required. Give a comma-separated list, +or repeat it, for more; `-f -` reads standard input. Every resource in them +must be a kind an XRD in the cluster defines as a composite resource or claim; +to simulate a Composition, use `crossplane composition simulate`, to simulate a +project, use `crossplane project simulate`, and to simulate managed resources +directly, use `crossplane resource simulate`. A claim is simulated as its +composite resource, and its result is listed under the claim's name. A claim or +namespaced composite resource with no `metadata.namespace` is simulated in the +current context's namespace, as `kubectl apply` would. +Functions come from the cluster. `--cache-dir` sets where provider package +contents are cached. + diff --git a/cmd/crossplane/xr/help/simulate_examples.md b/cmd/crossplane/xr/help/simulate_examples.md new file mode 100644 index 00000000..b31b2776 --- /dev/null +++ b/cmd/crossplane/xr/help/simulate_examples.md @@ -0,0 +1,26 @@ + +## Examples + +Simulate a changed composite resource against the current context: + +```shell +crossplane xr simulate -f xr.yaml +``` + +Simulate a composite resource and a claim: + +```shell +crossplane xr simulate -f network.yaml -f database-claim.yaml +``` + +Simulate a composite resource read from standard input: + +```shell +cat xr.yaml | crossplane xr simulate -f - +``` + +Print the plan as JSON, without provider plans: + +```shell +crossplane xr simulate -f xr.yaml -o json --skip-plan +``` diff --git a/cmd/crossplane/xr/integration_main_test.go b/cmd/crossplane/xr/integration_main_test.go new file mode 100644 index 00000000..18f843b8 --- /dev/null +++ b/cmd/crossplane/xr/integration_main_test.go @@ -0,0 +1,23 @@ +//go:build integration + +package xr_test + +import ( + "fmt" + "os" + "testing" + + "github.com/crossplane/cli/v2/internal/testutils/integration" +) + +// TestMain runs before all tests. +func TestMain(m *testing.M) { + // Kill envtest servers that an earlier run left behind when it died abnormally. + integration.ReapOrphanedEnvtest(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/cmd/crossplane/xr/integration_test.go b/cmd/crossplane/xr/integration_test.go new file mode 100644 index 00000000..c60bc0d3 --- /dev/null +++ b/cmd/crossplane/xr/integration_test.go @@ -0,0 +1,3131 @@ +//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 xr_test + +import ( + "strings" + "testing" + + 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" + "github.com/crossplane/cli/v2/internal/testutils/integration" + "github.com/crossplane/cli/v2/internal/testutils/integration/simharness" +) + +// TestDiffIntegration runs an integration test for the diff command. +// +//nolint:tparallel // simharness.RunIntegrationTest calls t.Parallel in each subtest. +func TestDiffIntegration(t *testing.T) { + t.Parallel() + + tests := map[string]simharness.IntegrationTestCase{ + "NewResourceDiff": { + Reason: "Shows color diff for new resources", + InputFiles: []string{"../../../internal/testutils/integration/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. + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-no-namespace-patch.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-configmap.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-unserved-k8s-group.yaml", + "../../../internal/testutils/integration/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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-builtin-defaults.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + 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 + // 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-builtin-defaults.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + 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 + // 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 (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" + + "; 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-status.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, + 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 + // 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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, + 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"). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/rejecting-admission-policy.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + AwaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/modified-xr.yaml"}, + ExpectedError: true, + ExpectedExitCode: dp.ExitCodeSchemaValidation, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/new-xr.yaml", + "testdata/diff/new-xr-second.yaml", + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + 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 + // its own changed tree. + WithXRs( + 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"), + simtest.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: "The composites[] entries group each input's changes", + OutputFormat: "json", + InputFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/new-xr.yaml", + "testdata/diff/new-xr-second.yaml", + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + // The top-level summary counts distinct resources. + WithSummary(4, 0, 0). + WithXRs( + 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"), + simtest.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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + // put an existing XR in the cluster to diff against + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + InputFiles: []string{"../../../internal/testutils/integration/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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + // put an existing resource with different ArgoCD annotations + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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", + ExpectedStructured: simtest.Expect().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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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", + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/modified-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + // A Terminating XR still has the resource it composed until garbage collection removes it. + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("testdata/diff/resources/existing-xr-deleting.yaml", "../../../internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml"), + }, + DeleteAfterSetup: []string{"testdata/diff/resources/existing-xr-deleting.yaml"}, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/modified-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml": nil, + }, + }, + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + DeleteAfterSetup: []string{"../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml"}, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/unmodified-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/environment-config-v1beta1.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-env-xr.yaml", + "testdata/diff/resources/existing-env-downstream-resource.yaml", + ), + }, + XrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + InputFiles: []string{"testdata/diff/modified-env-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/external-res-gotpl-composition.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-res-matchname-gotpl-composition.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + 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"). + 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: integration.XRDV1, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-legacy-xr.yaml", + OwnedFiles: map[string]*integration.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]*integration.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", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-sequencer-xr.yaml", + OwnedFiles: map[string]*integration.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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-composition.yaml", + "../../../internal/testutils/integration/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"). + 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 + // 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: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "../../../internal/testutils/integration/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]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/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 + 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 + // 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: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml", // still rendered: kept + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml", // no longer rendered: removed + ), + integration.Composes( + "../../../internal/testutils/integration/testdata/diff/resources/existing-other-xr.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-other-xr-downstream.yaml", + ), + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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. + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/unmodified-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "../../../internal/testutils/integration/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]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/modified-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-cluster-xr.yaml", + OwnedFiles: map[string]*integration.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]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + SetupFiles: []string{ + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + }, + CrossplaneManagedResources: []integration.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]*integration.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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml", "testdata/diff/generated-name-xr.yaml"}, + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). + WithSummary(4, 0, 0). + WithXRs( + 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": { + Reason: "Shows diff for new XR with generateName", + OutputFormat: "json", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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"}, + ExpectedStructured: simtest.Expect(). + 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( + simtest.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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/new-xr.yaml", + "../../../internal/testutils/integration/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. + 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"). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/new-xr.yaml", + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/shared-child-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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. + "../../../internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + // Add an existing XR and downstream resource to test modification + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + InputFiles: []string{ + "testdata/diff/first-xr.yaml", + "../../../internal/testutils/integration/testdata/diff/modified-xr.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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, + }, + "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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "../../../internal/testutils/integration/testdata/diff/resources/existing-xr.yaml", + "../../../internal/testutils/integration/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", "../../../internal/testutils/integration/testdata/diff/modified-xr.yaml"}, + RbacRules: simharness.LeastPrivilegeRules("get", "create", "patch"), + OutputFormat: "json", + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + UnestablishedXRDs: []string{"testdata/diff/resources/unestablished-xrd.yaml"}, + InputFiles: []string{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + OutputFormat: "json", + // The same diff NewResourceDiff shows, which has no such XRD. + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-missing-function.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"../../../internal/testutils/integration/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", + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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", + }, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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", + }, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/composition.yaml", + "../../../internal/testutils/integration/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). + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/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. + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-claim-with-claimref-composition.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + // Add existing resources for comparison + "testdata/diff/resources/existing-claim.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-claim-xr.yaml", + "testdata/diff/resources/existing-claim-downstream-resource.yaml", + ), + }, + XrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + InputFiles: []string{"testdata/diff/modified-claim.yaml"}, + 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(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/existing-claim.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-claim-xr.yaml", + "testdata/diff/resources/existing-claim-downstream-resource.yaml", + ), + }, + XrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + FieldManagerApplies: []integration.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"}, + ExpectedStructured: simtest.Expect(). + // 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-rendered-defaults-xrs.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", + "../../../internal/testutils/integration/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: []integration.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", + }, + }, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + FieldManagerApplies: []integration.FieldManagerApply{ + {File: "testdata/diff/xr-with-missing-defaults.yaml", FieldManager: "kubectl"}, + {File: "testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml", FieldManager: integration.CompositeReconcilerFieldManager}, + {File: "testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml", FieldManager: "platform-operator"}, + }, + 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 + // 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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{"../../../internal/testutils/integration/testdata/diff/new-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedStructured: simtest.Expect(). + 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, + }, + "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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-nested-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/cycle/xrds.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/cycle/compositions.yaml", + "../../../internal/testutils/integration/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, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + // Existing resources: the parent XR composes the child XR, which composes the managed resource + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + InputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + FieldManagerApplies: []integration.FieldManagerApply{ + {File: "testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml", FieldManager: "platform-operator"}, + }, + InputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + // The XR is still on v1, and its composed resource holds v1's data. + integration.Composes( + "testdata/diff/resources/existing-xr-automatic.yaml", + "testdata/diff/resources/existing-downstream-automatic.yaml", + ), + }, + InputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/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 + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-api-version-xr-rev1.yaml", + OwnedFiles: map[string]*integration.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. + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/api-version-composition-revision-v1.yaml", + "testdata/diff/resources/api-version-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-api-version-xr-rev1.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + InputFiles: []string{"testdata/diff/modified-api-version-xr-rev2-changed-field.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-xr-manual-no-ref.yaml"}, + ExpectedStructured: simtest.Expect(). + 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: integration.XRDV1, + SetupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/revision-templating-composition.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/revision-templating-composition-revision.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "../../../internal/testutils/integration/testdata/comp/resources/existing-xr-revision-ref.yaml", + "../../../internal/testutils/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml", + ), + }, + 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 + // 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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: integration.XRDV1, + SetupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml": nil, + }, + }, + }, + }, + }, + InputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/pinned-claim/existing-automatic.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml", + ), + }, + XrdAPIVersion: integration.XRDV1, // 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). + 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 + // 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/pinned-claim/existing-manual.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/pinned-claim/existing-manual-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml", + ), + }, + XrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + InputFiles: []string{"testdata/diff/modified-manual-pinned-claim.yaml"}, + 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(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml", + ), + }, + XrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + InputFiles: []string{"testdata/diff/unchanged-lagging-automatic-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + InputFiles: []string{"testdata/diff/new-manual-default-xr.yaml"}, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/selector-bound/setup.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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"}, + ExpectedStructured: simtest.Expect(). + 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", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/claim-nested/existing-claim.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + XrdAPIVersion: integration.XRDV1, // 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", + "../../../internal/testutils/integration/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: []integration.HierarchicalOwnershipRelation{ + { + // Backing XR is the root of owner ref tree + OwnerFile: "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.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]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + InputFiles: []string{"testdata/diff/modified-claim-nested.yaml"}, + ExpectedStructured: simtest.Expect(). + 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: integration.XRDV1, // 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/credentials/composition-with-creds.yaml", + "testdata/diff/resources/credentials/function-credentials-secret.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). + 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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/credentials/composition-with-creds.yaml", + // Note: NOT setting up the secret in cluster - testing CLI override + "../../../internal/testutils/integration/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. + ExpectedStructured: simtest.Expect(). + 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: simharness.LongTimeout, + OutputFormat: "json", + // eventualState deliberately false + InputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated). + ExpectedStructured: simtest.Expect(). + 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: simharness.LongTimeout, // Multiple simulation iterations need more time + OutputFormat: "json", + EventualState: true, + InputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). + ExpectedStructured: simtest.Expect(). + 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: simharness.LongTimeout, // Multiple simulation iterations need more time + OutputFormat: "json", + EventualState: true, + InputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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). + ExpectedStructured: simtest.Expect(). + 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: simharness.LongTimeout, + OutputFormat: "json", + EventualState: true, + InputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.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, + ExpectedStructured: simtest.Expect(). + 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: simharness.LongTimeout, + OutputFormat: "json", + // eventualState deliberately false + InputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/conditional-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). + ExpectedStructured: simtest.Expect(). + 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: simharness.LongTimeout, // Multiple simulation iterations need more time + OutputFormat: "json", + EventualState: true, + InputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + SetupFiles: []string{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/conditional-gating-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + }, + ExpectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added. + ExpectedStructured: simtest.Expect(). + 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: simharness.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{ + "../../../internal/testutils/integration/testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-with-envconfig-composition.yaml", + "../../../internal/testutils/integration/testdata/diff/resources/functions.yaml", + "testdata/diff/resources/environment-config-v1beta1.yaml", + }, + ExpectedExitCode: dp.ExitCodeDiffDetected, + ExpectedStructured: simtest.Expect(). + 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) { + simharness.RunIntegrationTest(t, simharness.XRTest, tt) + }) + } +} diff --git a/cmd/crossplane/xr/simulate.go b/cmd/crossplane/xr/simulate.go new file mode 100644 index 00000000..1ffc7886 --- /dev/null +++ b/cmd/crossplane/xr/simulate.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 xr + +import ( + "context" + + "github.com/alecthomas/kong" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/simulate" + "github.com/crossplane/cli/v2/internal/terminal" + + _ "embed" +) + +var ( + //go:embed help/simulate.md + simulateHelp string + //go:embed help/simulate_examples.md + simulateExamples string +) + +// simulateCmd previews how changed composite resources or claims would change +// a cluster. +type simulateCmd struct { + simulate.Flags `embed:""` + + Input []string `help:"Composite resource or claim file or directory to simulate. Takes a comma-separated list of paths, and can be repeated. - reads standard input." placeholder:"PATH" required:"" short:"f"` +} + +func (c *simulateCmd) Help() string { + return simulateHelp + simulate.Help(false) + simulateExamples +} + +// Run previews the composite resources or claims -f names against the +// cluster, with the cluster's Compositions and functions. +func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter) error { + // Anything but a Composition or project file is a candidate composite + // resource or claim; checkComposites confirms it against the cluster's XRDs. + xrs, err := simulate.LoadInputs(c.Input, func(r *un.Unstructured) error { + gvk := r.GroupVersionKind() + switch { + case gvk == apiextensionsv1.CompositionGroupVersionKind: + return simulate.RejectInput(r, simulate.XRTakes+"; composition simulate takes Composition files") + case gvk.GroupVersion().String() == projectfile.APIVersion && gvk.Kind == projectfile.Kind: + return simulate.RejectInput(r, simulate.XRTakes+"; project simulate takes a project file") + case r.IsList() || gvk.Kind == "List": + return simulate.RejectInput(r, simulate.XRTakes) + } + return nil + }) + if err != nil { + return err + } + return c.Flags.Run(sigCtx, k, logger, sp, &simulate.Inputs{Composites: xrs}) +} diff --git a/cmd/crossplane/xr/simulate_test.go b/cmd/crossplane/xr/simulate_test.go new file mode 100644 index 00000000..3cad11bd --- /dev/null +++ b/cmd/crossplane/xr/simulate_test.go @@ -0,0 +1,129 @@ +/* +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 xr + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/simulate" +) + +// 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.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) { + cases := map[string]struct { + reason string + args []string + input []string + err string + }{ + "Defaults": { + reason: "With only -f the shared defaults apply.", + args: []string{"-f", "xr.yaml"}, + input: []string{"xr.yaml"}, + }, + "RepeatedInput": { + reason: "-f repeats, splits on commas, and takes - for stdin.", + args: []string{"-f", "a,b.yaml", "--input", "-"}, + input: []string{"a", "b.yaml", "-"}, + }, + "InputRequired": { + reason: "Without -f the command fails rather than waiting on stdin.", + err: "--input", + }, + "NoSelectionFlags": { + reason: "Selection flags are for Composition changes; xr simulate doesn't define them.", + args: []string{"-f", "xr.yaml", "--name=x"}, + err: "unknown flag --name", + }, + "NoNamespace": { + reason: "--namespace is a selection flag too.", + args: []string{"-f", "xr.yaml", "-n", "team-a"}, + err: "unknown flag", + }, + "NoMaxConcurrency": { + reason: "--max-concurrency is project-only.", + args: []string{"-f", "xr.yaml", "--max-concurrency=2"}, + err: "max-concurrency", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c, err := parseSimulate(t, tc.args...) + if tc.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.err) { + t.Fatalf("\n%s\nParse(%v): want error containing %q, got %v", tc.reason, tc.args, tc.err, err) + } + return + } + if err != nil { + t.Fatalf("\n%s\nParse(%v): %v", tc.reason, tc.args, err) + } + if diff := cmp.Diff(tc.input, c.Input); diff != "" { + t.Errorf("\n%s\nInput: -want, +got:\n%s", tc.reason, diff) + } + if c.Output != "plan" || c.Timeout != 2*time.Minute { + t.Errorf("\n%s\nOutput, Timeout: want plan, 2m, got %q, %v", tc.reason, c.Output, c.Timeout) + } + }) + } +} + +func TestSimulateHelp(t *testing.T) { + h := (&simulateCmd{}).Help() + for _, want := range []string{"xr simulate", simulate.Help(false), "## Examples"} { + if !strings.Contains(h, want) { + t.Errorf("Help(): want it to contain %q", want) + } + } +} + +func TestSimulateRejectsComposition(t *testing.T) { + path := filepath.Join(t.TempDir(), "comp.yaml") + if err := os.WriteFile(path, []byte("apiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: net\n"), 0o600); err != nil { + t.Fatal(err) + } + // Run loads -f before it uses k or Docker. + err := (&simulateCmd{Input: []string{path}}).Run(context.Background(), nil, logging.NewNopLogger(), nil) + want := `-f inputs include Composition "net" (apiextensions.crossplane.io/v1); xr simulate takes composite resource or claim files; composition simulate takes Composition files` + if err == nil || err.Error() != want { + t.Errorf("Run(...): want error %q, got %v", want, err) + } +} diff --git a/cmd/crossplane/xr/testdata/README.md b/cmd/crossplane/xr/testdata/README.md new file mode 100644 index 00000000..3de563b9 --- /dev/null +++ b/cmd/crossplane/xr/testdata/README.md @@ -0,0 +1,12 @@ +# xr simulate integration tests + +`integration_test.go` runs `TestDiffIntegration`: crossplane-diff's xr cases, run as +`crossplane xr simulate -f ` against an envtest cluster. A case keeps its +crossplane-diff args; `simharness`'s `RunIntegrationTest` translates them +into the command's flags and runs it. Fixtures only this suite uses are in +`testdata/diff`; those shared with another suite are in +`internal/testutils/integration/testdata/diff` (and `crds`). + +The skips, how to run the suite and its coverage are in +`cmd/crossplane/project/testdata/README.md`. Run only this suite with +`go test -tags integration -run TestDiffIntegration ./cmd/crossplane/xr/`. diff --git a/cmd/crossplane/xr/testdata/diff/existing-xr-revision-ref-omitted.yaml b/cmd/crossplane/xr/testdata/diff/existing-xr-revision-ref-omitted.yaml new file mode 100644 index 00000000..fa56bf15 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/first-xr-with-composition-ref.yaml b/cmd/crossplane/xr/testdata/diff/first-xr-with-composition-ref.yaml new file mode 100644 index 00000000..9c567aa2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/first-xr.yaml b/cmd/crossplane/xr/testdata/diff/first-xr.yaml new file mode 100644 index 00000000..cc6c2fe4 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/generated-name-xr.yaml b/cmd/crossplane/xr/testdata/diff/generated-name-xr.yaml new file mode 100644 index 00000000..0be857b8 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/invalid-schema-xr.yaml b/cmd/crossplane/xr/testdata/diff/invalid-schema-xr.yaml new file mode 100644 index 00000000..785a6207 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml b/cmd/crossplane/xr/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml new file mode 100644 index 00000000..49be07d7 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-api-version-xr-rev2.yaml b/cmd/crossplane/xr/testdata/diff/modified-api-version-xr-rev2.yaml new file mode 100644 index 00000000..0baf92f0 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-claim-nested.yaml b/cmd/crossplane/xr/testdata/diff/modified-claim-nested.yaml new file mode 100644 index 00000000..67844ef1 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-claim-omitting-defaulted-fields.yaml b/cmd/crossplane/xr/testdata/diff/modified-claim-omitting-defaulted-fields.yaml new file mode 100644 index 00000000..3c6645d8 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-claim.yaml b/cmd/crossplane/xr/testdata/diff/modified-claim.yaml new file mode 100644 index 00000000..3116b173 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-cluster-xr.yaml b/cmd/crossplane/xr/testdata/diff/modified-cluster-xr.yaml new file mode 100644 index 00000000..dcbaf051 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-cross-ns-xr.yaml b/cmd/crossplane/xr/testdata/diff/modified-cross-ns-xr.yaml new file mode 100644 index 00000000..78068e4d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-env-xr.yaml b/cmd/crossplane/xr/testdata/diff/modified-env-xr.yaml new file mode 100644 index 00000000..3664be56 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml b/cmd/crossplane/xr/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 00000000..92f64376 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml b/cmd/crossplane/xr/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml new file mode 100644 index 00000000..12f18008 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-legacy-xr.yaml b/cmd/crossplane/xr/testdata/diff/modified-legacy-xr.yaml new file mode 100644 index 00000000..47df1975 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-manual-pinned-claim.yaml b/cmd/crossplane/xr/testdata/diff/modified-manual-pinned-claim.yaml new file mode 100644 index 00000000..be846bd7 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-nested-xr.yaml b/cmd/crossplane/xr/testdata/diff/modified-nested-xr.yaml new file mode 100644 index 00000000..842e568b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-automatic.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-automatic.yaml new file mode 100644 index 00000000..b0e59196 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 00000000..bb285ab2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml new file mode 100644 index 00000000..7216d4f3 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-manual-v1.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-manual-v1.yaml new file mode 100644 index 00000000..9ed3f2cd --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-selector-bound.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-selector-bound.yaml new file mode 100644 index 00000000..e2e6c3a2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-selector-nomatch.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-selector-nomatch.yaml new file mode 100644 index 00000000..0f45934e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-selector-stable.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-selector-stable.yaml new file mode 100644 index 00000000..b4dfec12 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-switch-to-automatic.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-switch-to-automatic.yaml new file mode 100644 index 00000000..8d721546 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/modified-xr-with-external-dep.yaml b/cmd/crossplane/xr/testdata/diff/modified-xr-with-external-dep.yaml new file mode 100644 index 00000000..eba2a6e3 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-claim-with-claimref-composition.yaml b/cmd/crossplane/xr/testdata/diff/new-claim-with-claimref-composition.yaml new file mode 100644 index 00000000..bd63e462 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-claim.yaml b/cmd/crossplane/xr/testdata/diff/new-claim.yaml new file mode 100644 index 00000000..6d153fee --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-cycle-xr.yaml b/cmd/crossplane/xr/testdata/diff/new-cycle-xr.yaml new file mode 100644 index 00000000..45f9b375 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-manual-default-xr.yaml b/cmd/crossplane/xr/testdata/diff/new-manual-default-xr.yaml new file mode 100644 index 00000000..89fbe026 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-nested-child-xr.yaml b/cmd/crossplane/xr/testdata/diff/new-nested-child-xr.yaml new file mode 100644 index 00000000..37a82217 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-nested-xr.yaml b/cmd/crossplane/xr/testdata/diff/new-nested-xr.yaml new file mode 100644 index 00000000..07ea96d7 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml b/cmd/crossplane/xr/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml new file mode 100644 index 00000000..143783ac --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-rendered-defaults-xrs.yaml b/cmd/crossplane/xr/testdata/diff/new-rendered-defaults-xrs.yaml new file mode 100644 index 00000000..b11d2e92 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-xr-manual-no-ref.yaml b/cmd/crossplane/xr/testdata/diff/new-xr-manual-no-ref.yaml new file mode 100644 index 00000000..21557827 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-xr-missing-namespace-second.yaml b/cmd/crossplane/xr/testdata/diff/new-xr-missing-namespace-second.yaml new file mode 100644 index 00000000..e5abd110 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-xr-missing-namespace.yaml b/cmd/crossplane/xr/testdata/diff/new-xr-missing-namespace.yaml new file mode 100644 index 00000000..03399959 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-xr-second.yaml b/cmd/crossplane/xr/testdata/diff/new-xr-second.yaml new file mode 100644 index 00000000..b31482df --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-xr-with-creds.yaml b/cmd/crossplane/xr/testdata/diff/new-xr-with-creds.yaml new file mode 100644 index 00000000..07c0fa53 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/new-xr-with-cross-ns-external-dep.yaml b/cmd/crossplane/xr/testdata/diff/new-xr-with-cross-ns-external-dep.yaml new file mode 100644 index 00000000..d1997d5c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/admission-policy-canary.yaml b/cmd/crossplane/xr/testdata/diff/resources/admission-policy-canary.yaml new file mode 100644 index 00000000..2130c04e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/api-version-composition-revision-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/api-version-composition-revision-v1.yaml new file mode 100644 index 00000000..887eb013 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/api-version-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/api-version-composition.yaml new file mode 100644 index 00000000..defbc52a --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-composition-with-claimref.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-composition-with-claimref.yaml new file mode 100644 index 00000000..c468c446 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-composition.yaml new file mode 100644 index 00000000..f07fb97e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/child-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/child-composition.yaml new file mode 100644 index 00000000..824162c4 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/child-definition.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/child-definition.yaml new file mode 100644 index 00000000..cc63922d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-child-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-child-xr.yaml new file mode 100644 index 00000000..4b39acb4 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-claim.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-claim.yaml new file mode 100644 index 00000000..ba4e28ca --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-managed-resource.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-managed-resource.yaml new file mode 100644 index 00000000..21a97b6d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-parent-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/existing-parent-xr.yaml new file mode 100644 index 00000000..20399443 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/parent-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/parent-composition.yaml new file mode 100644 index 00000000..f2475f2d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml new file mode 100644 index 00000000..d3e331ca --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-nested/parent-definition.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-nested/parent-definition.yaml new file mode 100644 index 00000000..d3ef4162 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/claim-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/claim-xrd.yaml new file mode 100644 index 00000000..af86a8af --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-no-namespace-patch.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-no-namespace-patch.yaml new file mode 100644 index 00000000..a4cb8739 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-revision-preview.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-revision-preview.yaml new file mode 100644 index 00000000..18d67a21 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-revision-stable.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-revision-stable.yaml new file mode 100644 index 00000000..5dff855b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-revision-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-revision-v1.yaml new file mode 100644 index 00000000..109940aa --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-v2.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-v2.yaml new file mode 100644 index 00000000..17c012c6 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-builtin-defaults.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-builtin-defaults.yaml new file mode 100644 index 00000000..c032a4cd --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-configmap.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-configmap.yaml new file mode 100644 index 00000000..d0ca422f --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml new file mode 100644 index 00000000..48f1a0af --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-defaults.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-defaults.yaml new file mode 100644 index 00000000..0eedc809 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-missing-function.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-missing-function.yaml new file mode 100644 index 00000000..cb17b40f --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml new file mode 100644 index 00000000..c0edae36 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-status.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-status.yaml new file mode 100644 index 00000000..be9332b7 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/composition-with-unserved-k8s-group.yaml b/cmd/crossplane/xr/testdata/diff/resources/composition-with-unserved-k8s-group.yaml new file mode 100644 index 00000000..17ecbf25 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/conditional-gating-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/conditional-gating-xr.yaml new file mode 100644 index 00000000..b871f6bd --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/credentials/cli-credentials.yaml b/cmd/crossplane/xr/testdata/diff/resources/credentials/cli-credentials.yaml new file mode 100644 index 00000000..73d57dbd --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/credentials/composition-with-creds.yaml b/cmd/crossplane/xr/testdata/diff/resources/credentials/composition-with-creds.yaml new file mode 100644 index 00000000..2820c988 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/credentials/function-credentials-secret.yaml b/cmd/crossplane/xr/testdata/diff/resources/credentials/function-credentials-secret.yaml new file mode 100644 index 00000000..7c070b9f --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/cross-namespace-configmap.yaml b/cmd/crossplane/xr/testdata/diff/resources/cross-namespace-configmap.yaml new file mode 100644 index 00000000..4fa3771b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/cross-namespace-fn-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/cross-namespace-fn-composition.yaml new file mode 100644 index 00000000..a1bcb0e0 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/default-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/default-composition.yaml new file mode 100644 index 00000000..b7ccde46 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/env-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/env-composition.yaml new file mode 100644 index 00000000..6583c4de --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/env-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/env-xrd.yaml new file mode 100644 index 00000000..9d137cac --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/environment-config-v1beta1.yaml b/cmd/crossplane/xr/testdata/diff/resources/environment-config-v1beta1.yaml new file mode 100644 index 00000000..ddb284c0 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml new file mode 100644 index 00000000..7caf33a2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-api-version-xr-rev1.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-api-version-xr-rev1.yaml new file mode 100644 index 00000000..558b7168 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml new file mode 100644 index 00000000..ab5d01ab --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-claim-downstream-resource.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-claim-downstream-resource.yaml new file mode 100644 index 00000000..bdea322d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-claim-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-claim-xr.yaml new file mode 100644 index 00000000..e5ed847a --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-claim.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-claim.yaml new file mode 100644 index 00000000..0de70f11 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-cluster-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-cluster-xr.yaml new file mode 100644 index 00000000..03e34f05 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-cross-ns-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-cross-ns-downstream.yaml new file mode 100644 index 00000000..45fdbc88 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-cross-ns-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-cross-ns-xr.yaml new file mode 100644 index 00000000..5830cd0e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-defaulted-downstream-composed.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-defaulted-downstream-composed.yaml new file mode 100644 index 00000000..cb92c60d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml new file mode 100644 index 00000000..04a6509a --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-automatic.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-automatic.yaml new file mode 100644 index 00000000..f8abdd1d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-manual-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-manual-v1.yaml new file mode 100644 index 00000000..8909fb23 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml new file mode 100644 index 00000000..650c6e68 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-selector-stable.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-selector-stable.yaml new file mode 100644 index 00000000..5f53076d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-with-external-dep.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-with-external-dep.yaml new file mode 100644 index 00000000..f25c13dc --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-with-generated-name.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-downstream-with-generated-name.yaml new file mode 100644 index 00000000..e8cc8c56 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-env-downstream-resource.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-env-downstream-resource.yaml new file mode 100644 index 00000000..19da0e39 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-env-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-env-xr.yaml new file mode 100644 index 00000000..1768c99a --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-generate-name-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-generate-name-xr.yaml new file mode 100644 index 00000000..72d7f866 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml new file mode 100644 index 00000000..0134d82c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml new file mode 100644 index 00000000..4999cd65 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-legacy-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-legacy-xr.yaml new file mode 100644 index 00000000..5c375919 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-namespace.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-namespace.yaml new file mode 100644 index 00000000..3fc6cd48 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-removal-test-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-removal-test-xr.yaml new file mode 100644 index 00000000..bc841ee6 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml new file mode 100644 index 00000000..8ce45267 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-stage0.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-stage0.yaml new file mode 100644 index 00000000..e84cc250 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml new file mode 100644 index 00000000..a209807c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-gating-xr.yaml new file mode 100644 index 00000000..b259f22a --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-sequencer-xr.yaml new file mode 100644 index 00000000..6c50f601 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml new file mode 100644 index 00000000..e1c4dcb2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml new file mode 100644 index 00000000..2f1b1b25 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-automatic.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-automatic.yaml new file mode 100644 index 00000000..52060d5d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-deleting.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-deleting.yaml new file mode 100644 index 00000000..b84ed1af --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml new file mode 100644 index 00000000..a68ebbd7 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-manual-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-manual-v1.yaml new file mode 100644 index 00000000..61c2c65c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-selector-stable.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-selector-stable.yaml new file mode 100644 index 00000000..bb1edeb2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-argocd.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-argocd.yaml new file mode 100644 index 00000000..af4bc154 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml new file mode 100644 index 00000000..678d6a0e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml new file mode 100644 index 00000000..6a855f8d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml new file mode 100644 index 00000000..8a000a9b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml new file mode 100644 index 00000000..7c16d606 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml new file mode 100644 index 00000000..2f09685b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep.yaml b/cmd/crossplane/xr/testdata/diff/resources/existing-xr-with-external-dep.yaml new file mode 100644 index 00000000..852cd656 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/external-named-clusterrole.yaml b/cmd/crossplane/xr/testdata/diff/resources/external-named-clusterrole.yaml new file mode 100644 index 00000000..3f6b02ef --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/external-res-fn-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/external-res-fn-composition.yaml new file mode 100644 index 00000000..dacd3b43 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/external-res-gotpl-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/external-res-gotpl-composition.yaml new file mode 100644 index 00000000..eb3c88aa --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml new file mode 100644 index 00000000..a886976c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/external-resource-configmap-other-namespace.yaml b/cmd/crossplane/xr/testdata/diff/resources/external-resource-configmap-other-namespace.yaml new file mode 100644 index 00000000..4a02772e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/external-resource-configmap.yaml b/cmd/crossplane/xr/testdata/diff/resources/external-resource-configmap.yaml new file mode 100644 index 00000000..53200b9e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/generated-name-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/generated-name-composition.yaml new file mode 100644 index 00000000..f16e10c1 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/legacy-composition-revision-v1.yaml b/cmd/crossplane/xr/testdata/diff/resources/legacy-composition-revision-v1.yaml new file mode 100644 index 00000000..8f7eec5d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/legacy-composition-v2.yaml b/cmd/crossplane/xr/testdata/diff/resources/legacy-composition-v2.yaml new file mode 100644 index 00000000..fc492bb9 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/legacy-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/legacy-xrd.yaml new file mode 100644 index 00000000..c8648a5a --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/manual-default/definitions.yaml b/cmd/crossplane/xr/testdata/diff/resources/manual-default/definitions.yaml new file mode 100644 index 00000000..11c29f88 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml new file mode 100644 index 00000000..a974f9f5 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml new file mode 100644 index 00000000..55419e07 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/child-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/child-composition.yaml new file mode 100644 index 00000000..17ae8f7d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/child-revision-templating-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/child-revision-templating-composition.yaml new file mode 100644 index 00000000..54b1faa3 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/child-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/child-xrd.yaml new file mode 100644 index 00000000..c8da4876 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml new file mode 100644 index 00000000..7004e4f1 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml new file mode 100644 index 00000000..3cef855c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/existing-child-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/existing-child-xr.yaml new file mode 100644 index 00000000..fa857d35 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml new file mode 100644 index 00000000..de74ec9e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/existing-managed-resource.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/existing-managed-resource.yaml new file mode 100644 index 00000000..1211a7e4 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/existing-parent-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/existing-parent-xr.yaml new file mode 100644 index 00000000..9ce1afe0 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/parent-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/parent-composition.yaml new file mode 100644 index 00000000..5a4ce994 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/nested/parent-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/nested/parent-xrd.yaml new file mode 100644 index 00000000..04de284e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/definitions.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/definitions.yaml new file mode 100644 index 00000000..34bff81d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml new file mode 100644 index 00000000..d0672a40 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml new file mode 100644 index 00000000..ecd3a8a9 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-automatic.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-automatic.yaml new file mode 100644 index 00000000..7d3eda85 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml new file mode 100644 index 00000000..8fca08a8 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml new file mode 100644 index 00000000..553bd228 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml new file mode 100644 index 00000000..f6d3a87d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml new file mode 100644 index 00000000..a5d186fa --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-manual.yaml b/cmd/crossplane/xr/testdata/diff/resources/pinned-claim/existing-manual.yaml new file mode 100644 index 00000000..8ece1692 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/production-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/production-composition.yaml new file mode 100644 index 00000000..1e0b06ba --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/rejecting-admission-policy.yaml b/cmd/crossplane/xr/testdata/diff/resources/rejecting-admission-policy.yaml new file mode 100644 index 00000000..ff10e3eb --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-cluster-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-cluster-composition.yaml new file mode 100644 index 00000000..766a06f8 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml new file mode 100644 index 00000000..4d6bc8d1 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-child-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-child-composition.yaml new file mode 100644 index 00000000..67995f9f --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-child-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-child-xrd.yaml new file mode 100644 index 00000000..ce48cad4 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-composition.yaml new file mode 100644 index 00000000..e8fa8909 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml new file mode 100644 index 00000000..10d846bf --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-nested-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacy-nested-xr.yaml new file mode 100644 index 00000000..e421b6f2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml new file mode 100644 index 00000000..e03c4398 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-nested-xr-managed.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-nested-xr-managed.yaml new file mode 100644 index 00000000..0bde272b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/removal-test-nested-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/removal-test-nested-xr.yaml new file mode 100644 index 00000000..6ff9ea7b --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/rendered-defaults/definitions.yaml b/cmd/crossplane/xr/testdata/diff/resources/rendered-defaults/definitions.yaml new file mode 100644 index 00000000..5976be20 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/selector-bound/existing-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/selector-bound/existing-downstream.yaml new file mode 100644 index 00000000..8e54b1b0 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/selector-bound/existing-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/selector-bound/existing-xr.yaml new file mode 100644 index 00000000..9711b44c --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/selector-bound/setup.yaml b/cmd/crossplane/xr/testdata/diff/resources/selector-bound/setup.yaml new file mode 100644 index 00000000..e33e1e16 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/sequencer-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/sequencer-composition.yaml new file mode 100644 index 00000000..3a56fdf9 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/sequencer-stage0-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/sequencer-stage0-downstream.yaml new file mode 100644 index 00000000..fa1608b9 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/sequencer-stage1-downstream.yaml b/cmd/crossplane/xr/testdata/diff/resources/sequencer-stage1-downstream.yaml new file mode 100644 index 00000000..23e1180e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/sequencer-with-envconfig-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/sequencer-with-envconfig-composition.yaml new file mode 100644 index 00000000..706f8194 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/sequencer-with-envconfig-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/sequencer-with-envconfig-xr.yaml new file mode 100644 index 00000000..52c724cd --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/sequencer-xr.yaml b/cmd/crossplane/xr/testdata/diff/resources/sequencer-xr.yaml new file mode 100644 index 00000000..de0f52ec --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/staging-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/staging-composition.yaml new file mode 100644 index 00000000..5cf736f9 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/unestablished-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/unestablished-xrd.yaml new file mode 100644 index 00000000..286e15ee --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml new file mode 100644 index 00000000..26561167 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml b/cmd/crossplane/xr/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml new file mode 100644 index 00000000..380d66c5 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/v2-xrd-with-v1-paths.yaml b/cmd/crossplane/xr/testdata/diff/resources/v2-xrd-with-v1-paths.yaml new file mode 100644 index 00000000..e150c8a9 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/xdownstreamenvresource-xrd.yaml b/cmd/crossplane/xr/testdata/diff/resources/xdownstreamenvresource-xrd.yaml new file mode 100644 index 00000000..5e2954ba --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/resources/xrd-with-defaults.yaml b/cmd/crossplane/xr/testdata/diff/resources/xrd-with-defaults.yaml new file mode 100644 index 00000000..bbd6eb8e --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/unchanged-automatic-pinned-claim.yaml b/cmd/crossplane/xr/testdata/diff/unchanged-automatic-pinned-claim.yaml new file mode 100644 index 00000000..c22ad2e2 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/unchanged-lagging-automatic-xr.yaml b/cmd/crossplane/xr/testdata/diff/unchanged-lagging-automatic-xr.yaml new file mode 100644 index 00000000..10905951 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-ambiguous-selector.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-ambiguous-selector.yaml new file mode 100644 index 00000000..86c1d6c4 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-argocd-annotations.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-argocd-annotations.yaml new file mode 100644 index 00000000..f9dcd869 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-argocd-mixed-changes.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-argocd-mixed-changes.yaml new file mode 100644 index 00000000..3a399e9f --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-composition-ref.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-composition-ref.yaml new file mode 100644 index 00000000..e417e325 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-composition-selector.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-composition-selector.yaml new file mode 100644 index 00000000..bf5bdc13 --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-missing-defaults.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-missing-defaults.yaml new file mode 100644 index 00000000..d1d9007d --- /dev/null +++ b/cmd/crossplane/xr/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/cmd/crossplane/xr/testdata/diff/xr-with-overridden-defaults.yaml b/cmd/crossplane/xr/testdata/diff/xr-with-overridden-defaults.yaml new file mode 100644 index 00000000..53f5dfc2 --- /dev/null +++ b/cmd/crossplane/xr/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 diff --git a/cmd/crossplane/xr/xr.go b/cmd/crossplane/xr/xr.go index 03786b5b..65b8e27b 100644 --- a/cmd/crossplane/xr/xr.go +++ b/cmd/crossplane/xr/xr.go @@ -21,4 +21,5 @@ package xr type Cmd struct { Generate generateCmd `cmd:"" help:"Generate a Composite Resource (XR) from a Claim."` Patch patchCmd `cmd:"" help:"Patch a Composite Resource (XR)."` + Simulate simulateCmd `cmd:"" help:"Preview what applying changed Composite Resources (XRs) or claims would do." maturity:"alpha"` } diff --git a/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index d81f45e0..eb17ca20 100644 --- a/design/one-pager-cli-simulate.md +++ b/design/one-pager-cli-simulate.md @@ -83,8 +83,8 @@ many controllers (that remains the domain of control-plane-level simulation). ## Proposal Add `simulate` as a verb in the CLI's noun-first command tree, sharing one -engine: `resource simulate`, `project simulate` and `composition simulate` -first, then `xr simulate` later in the same stack. +engine: `resource simulate`, `project simulate`, `composition simulate` and +`xr simulate`, all added in the same stack. Each command takes one kind of input, so no command sniffs its inputs or defines flags that do nothing for them. @@ -340,8 +340,8 @@ type Flags struct { `crossplane resource simulate` shares the diff server, timeout, and output flags, and takes its resources from `-f` files instead of project -discovery. Both commands enter the tree with the CLI's `maturity:"alpha"` -tag and graduate the way every other command does. +discovery. Each simulate command enters the tree with the CLI's +`maturity:"alpha"` tag and graduates the way every other command does. ### Running Diff Servers Like Render Runs Functions @@ -517,8 +517,9 @@ standalone packaging forced them to build. files instead of being discovered from a project, and `xr` is the same preview driven by a changed XR file with the Compositions coming from the cluster. They become `composition simulate` and `xr simulate`, beside - `project simulate` (`-f` for changed Compositions or XRs, `--namespace` - and `--name` to scope the cluster side), and both gain the plan layer as + `project simulate` (`-f` for changed Compositions or XRs; + `composition simulate` adds `--namespace` and `--name` to scope the + cluster side), and both gain the plan layer as their upgrade. That upgrade is strictly additive, so today's diff functionality is subsumed rather than replaced: planning is a capability simulate detects per resource, and a resource with no diff server gets diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index ec3c50df..53c2970f 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -332,8 +332,8 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit return output, errors.Join(errs...) } -// outputErrorsJoined returns errs as one error that names each one's resource, so each failed resource -// is named. +// 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 { diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index b38d23e7..ce35cf26 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -511,7 +511,11 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { xpClients := tt.setupMocks() // Create mock XR processor - mockXRProc := &tu.MockDiffProcessor{} + mockXRProc := &tu.MockDiffProcessor{ + 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) @@ -1791,9 +1795,11 @@ func TestDefaultCompDiffProcessor_DiffComposition_ReportErrorOutput(t *testing.T } } -// TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput pins that an advisory -// that can only be discovered while releasing resources is still in the report's warnings (warnings[]), -// not just on stderr. comp reaches teardown via xrProc.Cleanup. +// 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 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() diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 8c92f38e..5ff00b9e 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -46,6 +46,11 @@ const ( // DiffProcessor interface for processing resources. type DiffProcessor interface { + // 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 and, by diff key, the // unchanged renderings its diffs leave out (see DefaultDiffCalculator.CalculateNonRemovalDiffs), // which only the render-overlap check consumes. @@ -82,7 +87,7 @@ 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: The processor does no presentation; the caller renders the reports it returns. + // 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, @@ -210,6 +215,148 @@ func CleanupDetached(ctx context.Context, c Cleaner, logger logging.Logger) { } } +// 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 nil, 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 result.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() + + // unchanged holds, by group, the unchanged renderings its diffs leave out, for the overlap check. + unchanged := make([]map[string]*dt.ResourceDiff, len(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 { + 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 { + // 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 + unchanged[i] = rendered.Unchanged() + } + } + + 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 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(), + "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 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. The check sees every rendering, unchanged ones included, + // so contention is caught whether or not equal diffs are kept (with or without --skip-plan). + for _, err := range validator.RenderOverlaps(withUnchanged(groups, unchanged)) { + errs = append(errs, err) + outputErrors = append(outputErrors, dt.OutputError{Message: err.Error()}) + } + + // 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) + + // 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(), + } + + totalDiffs := 0 + for _, group := range groups { + totalDiffs += len(group.Diffs) + } + + p.config.Logger.Debug("Processing complete", + "resourceCount", len(resources), + "totalDiffs", totalDiffs, + "hasDiffs", report.HasChanges(), + "errorCount", len(errs)) + + if len(errs) > 0 { + return report, errors.Join(errs...) + } + + return report, 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. diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index 66366a10..f046f18e 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -11,12 +11,15 @@ import ( 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" "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" @@ -26,6 +29,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/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" @@ -36,6 +40,17 @@ import ( "github.com/crossplane/cli/v2/pkg/resource" ) +// 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{} @@ -65,146 +80,1178 @@ func TestDefaultDiffProcessor_removeNamespacesFromClusterScopedResources(t *test 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(), + "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{ + 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 + wantHasChanges bool + 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(), + 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(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + // 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) { + // 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(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1, resource2}, + processorOpts: testProcessorOptions(t), + // 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]"), + }, + "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(), + 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(), + 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(). + WithXRDForGVK(schema.GroupVersionKind{Group: testGroup, Version: "v1", Kind: testKind}, mainXRD). + WithXRDForGVK(schema.GroupVersionKind{Group: "cpd.org", Version: "v1", Kind: "ComposedResource"}, composedXRD). + 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, compare.Options, DryRunOn, Defaulter, func(*un.Unstructured) bool) 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 + }, + } + }), + ), + wantHasChanges: true, + 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(), + 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 the diff processor + 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) + } + report, err := processor.PerformDiff(ctx, tt.resources, compositionProvider) + + // 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 { + 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 simulate 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` + + contendedErr = `cannot combine diffs: resource "example.org/v1/Bucket/default/shared" would be controlled by ` + + `more than one XR (XR1/xr-a, XR1/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` + + // 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 + // 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 + // equalRender names an input XR whose render leaves the shared resource unchanged: an equal diff, + // controlled by that XR, kept among the diffs when keepEqual is set (as provider plans do) and + // otherwise recorded only as an unchanged rendering (as with --skip-plan). + equalRender string + keepEqual bool + 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 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", }, - 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() + // 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") }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + 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", }, - 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() + // 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")}, }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), - *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + 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}}}, }, - 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() + // 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")}, }, - setupSchema: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient(). - WithFoundCRD("example.org", "ClusterThing", clusterCRD). - Build() + 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}}, + }, }, - resources: []cpd.Unstructured{ - *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + "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}}}, }, - 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() + // 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}}}, }, - setupSchema: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient(). - WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { - return nil, errors.New("CRD not found") - }). - Build() + }, + // 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}, + }, }, - resources: []cpd.Unstructured{ - *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + // 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. + // Two XRs controlling one resource contend for it even when one of them leaves it unchanged, so + // the run fails whether that equal diff is kept for a plan or left out of the report. + "UnchangedRenderingContendsWithPlans": { + resources: []*un.Unstructured{xr("xr-a"), xr("xr-b")}, + equalRender: "xr-a", + keepEqual: true, + want: got{ + XRs: []corev1.ObjectReference{ref("xr-a"), ref("xr-b")}, + GlobalErrs: []string{contendedErr}, + Err: contendedErr, }, - 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() + "UnchangedRenderingContendsWithSkipPlan": { + resources: []*un.Unstructured{xr("xr-a"), xr("xr-b")}, + equalRender: "xr-a", + want: got{ + XRs: []corev1.ObjectReference{ref("xr-a"), ref("xr-b")}, + GlobalErrs: []string{contendedErr}, + Err: contendedErr, }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("example.org/v1", "ClusterThing", "thing").BuildUComposed(), + }, + "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, }, - 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), - }, + 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(), + Function: tu.NewMockFunctionClient().WithSuccessfulFunctionsFetch(functions).Build(), + ResourceTree: tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), } - err := processor.removeNamespacesFromClusterScopedResources(t.Context(), tt.resources) + var gotRecorded []recorded - if tt.wantErr { - if err == nil { - t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): expected error but got none", tt.reason) + 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, compare.Options, DryRunOn, Defaulter, func(*un.Unstructured) bool) DiffCalculator { + return &tu.MockDiffCalculator{ + 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() + } + + var keys types.RenderedResources + keys.AddKey(sharedKey) + + if rendered.GetName() == tt.equalRender { + diff := bucket(rendered.GetName(), "same", rendered.GetName()) + diff.DiffType = dt.DiffTypeEqual + + if tt.keepEqual { + return map[string]*dt.ResourceDiff{sharedKey: diff}, keys, nil + } + + keys.AddUnchanged(sharedKey, diff) + + return map[string]*dt.ResourceDiff{}, keys, nil + } + + controller := "parent" + if tt.equalRender != "" { + controller = rendered.GetName() + } + + return map[string]*dt.ResourceDiff{sharedKey: bucket(controller, "same", rendered.GetName())}, keys, nil + }, + } + }), + ) + + 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{ + 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...) + + 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 report.Groups { + result.XRs = append(result.XRs, g.XR) + + if g.Err == nil { + continue } - 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) + 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) } - return + if result.GroupErrs == nil { + result.GroupErrs = map[string]string{} + } + + result.GroupErrs[g.XR.Name] = g.Err.Message } - if err != nil { - t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): unexpected error: %v", tt.reason, err) + for _, e := range report.Errors { + result.GlobalErrs = append(result.GlobalErrs, e.Message) } - got := make([]string, len(tt.resources)) - for i := range tt.resources { - got[i] = (&un.Unstructured{Object: tt.resources[i].UnstructuredContent()}).GetNamespace() + if err != nil { + result.Err = err.Error() } - if diff := gcmp.Diff(tt.wantNamespace, got); diff != "" { - t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): -want namespaces, +got:\n%s", tt.reason, diff) + if diff := gcmp.Diff(tt.want, result); diff != "" { + t.Errorf("PerformDiff(...) group handoff: -want, +got:\n%s", 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() +// 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 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() - return []ProcessorOption{ - WithLogger(tu.TestLogger(t, false)), - WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { - return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil - }), + const advisory = "Some containers could not be cleaned up" + + var 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(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, + append(testProcessorOptions(t), + WithAdvisories(advisories), + // 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() + + 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") + } + + if !strings.Contains(stderr.String(), advisory) { + t.Errorf("teardown advisory should reach stderr, got:\n%s", stderr.String()) + } + + // 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, report.Warnings); diff != "" { + t.Errorf("report warnings mismatch (-want +got):\n%s", diff) + } + + 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_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 + resource := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + // 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(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, testProcessorOptions(t)...) + + // 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 + report, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, compositionProvider) + + // Should return an error + if err == nil { + t.Fatal("PerformDiff() expected error but got none") + } + + 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 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) } } @@ -279,6 +1326,120 @@ func TestDefaultDiffProcessor_warnIfDeleting(t *testing.T) { } } +// 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} + + 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() + + tests := map[string]struct { + reason string + // crds are the CRDs in the cluster. + crds []*extv1.CustomResourceDefinition + wantHasChanges bool + wantErr string + }{ + "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`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + logger := tu.TestLogger(t, false) + + crdObjs := make([]runtime.Object, 0, len(tt.crds)) + + for _, crd := range tt.crds { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(crd) + if err != nil { + t.Fatalf("cannot convert CRD: %v", err) + } + + 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, + 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() @@ -1218,6 +2379,17 @@ func hasReadyCondition(res *un.Unstructured) bool { 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() diff --git a/internal/diff/diffprocessor/errors_test.go b/internal/diff/diffprocessor/errors_test.go index e86e22fb..9ec3fda0 100644 --- a/internal/diff/diffprocessor/errors_test.go +++ b/internal/diff/diffprocessor/errors_test.go @@ -608,7 +608,7 @@ func TestIsOnlySchemaValidationErrors(t *testing.T) { want: false, }, "CrossplaneRuntimeJoinedSchemaValidationErrors": { - // This simulates the pattern where per-resource errors are joined + // This simulates the actual pattern in PerformDiff where errors are joined err: xperrors.Join( xperrors.Wrapf( xperrors.Wrap(&SchemaValidationError{Message: "validation"}, "cannot validate resources"), diff --git a/internal/diff/diffprocessor/overlap.go b/internal/diff/diffprocessor/overlap.go index a5394ac8..86d88af7 100644 --- a/internal/diff/diffprocessor/overlap.go +++ b/internal/diff/diffprocessor/overlap.go @@ -4,6 +4,7 @@ import ( "fmt" "maps" "sort" + "strconv" "strings" "k8s.io/apimachinery/pkg/api/equality" @@ -14,11 +15,224 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/diff/types" ) -// This file holds the overlap checks among a run's inputs and renders: identity of an object -// independent of API version, which input manages another, and two renders reaching one resource. -// An input with no name yet (generateName only) has no identity. +// 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 + relation := managedBy(groups[b], managed, groups[a], manager) + + // The manager's rendered keys are checked as well as managedBy's controller reference: a child + // the manager would create has no cluster copy yet, so nothing carries that reference. + 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 relation == composedBy: + // The manager's render no longer produces it, but the manager still controls it in the + // cluster, so diffing it on its own predicts nothing applying the manager would do. + rejected[b] = errors.Errorf("%s is controlled by input %s; supplying it as an input too "+ + "gives it a second writer — pass %s only", kindName(managed), kindName(manager), kindName(manager)) + case relation == claimedBy: + 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 +} // management is how one input of a run manages another; see managedBy. type management int @@ -204,14 +418,16 @@ func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) er inputs := make([]string, 0, len(producers)) var ( - controllers []string - allEqual = true - identical = true - 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] + 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 { @@ -227,6 +443,11 @@ func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) er 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 @@ -260,6 +481,10 @@ func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) er switch { case allEqual: return nil + case sharedPlaceholder: + return errors.Errorf("cannot combine diffs: inputs %s both produce resource %s only because simulate "+ + "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, "+ @@ -359,3 +584,7 @@ func refIdentity(obj *un.Unstructured, path ...string) string { 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/overlap_test.go b/internal/diff/diffprocessor/overlap_test.go index 872c4737..9c5739fe 100644 --- a/internal/diff/diffprocessor/overlap_test.go +++ b/internal/diff/diffprocessor/overlap_test.go @@ -9,6 +9,8 @@ import ( 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/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -22,7 +24,97 @@ func errMessage(err error) string { return err.Error() } -func TestManagedBy(t *testing.T) { +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") @@ -33,106 +125,298 @@ func TestManagedBy(t *testing.T) { return map[string]string{LabelClaimName: "my-claim", LabelClaimNamespace: namespace} } - // side is one input with what its own diff carries: its cluster copy and rendered form, if any. - type side struct { - res, cluster, rendered *un.Unstructured - } - group := func(s side) dt.XRDiffGroup { - if s.cluster == nil && s.rendered == nil { - return dt.XRDiffGroup{} - } - return dt.XRDiffGroup{Diffs: map[string]*dt.ResourceDiff{ - dt.MakeDiffKeyFromResource(s.res): {Current: dt.ResourceViews{Raw: s.cluster}, Desired: dt.ResourceViews{Raw: s.rendered}}, - }} + 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" + controlledErr = "XR/child is controlled by input XR/parent; 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 - xr, manager side - want management + reason string + inputs []input + want map[int]string }{ - "UnrelatedInputs": { - reason: "Inputs that neither compose nor bind one another manage nothing.", - xr: side{res: xr("b").Build()}, - manager: side{res: xr("a").Build()}, - want: notManaged, - }, - "ChildComposedByParent": { - reason: "The child's rendered form names the parent as its controller.", - xr: side{res: xr("child").Build(), rendered: xr("child").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, - manager: side{res: xr("parent").Build()}, - want: composedBy, - }, - "ChildControlledByParentInCluster": { - reason: "A child the parent still controls in the cluster is managed by it.", - xr: side{res: xr("child").Build(), cluster: xr("child").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, - manager: side{res: xr("parent").Build()}, - want: composedBy, - }, - "ControllerOfTheSameNameInAnotherNamespace": { - reason: "A namespaced parent controls only objects in its own namespace.", - xr: side{res: xr("child").InNamespace("b").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, - manager: side{res: xr("parent").InNamespace("a").Build()}, - want: notManaged, - }, - "ControllerAtAnotherAPIVersion": { - reason: "A controller reference at another version names the same object.", - xr: side{res: xr("child").WithControllerReference("XR", "parent", "example.org/v2", "uid").Build()}, - manager: side{res: xr("parent").Build()}, - want: composedBy, - }, - "ManagerWithNoName": { - reason: "A generateName-only manager has no identity, so it manages nothing.", - xr: side{res: xr("child").WithControllerReference("XR", "", "example.org/v1", "uid").Build()}, - manager: side{res: xr("").WithGenerateName("parent-").Build()}, - want: notManaged, + "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}, + }, + "ChildControlledByAParentInputIsRejected": { + reason: "A child the parent still controls in the cluster is rejected even when the parent's render drops it.", + inputs: []input{ + {res: xr("parent").Build()}, + {res: xr("child").Build(), cluster: xr("child").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, + }, + want: map[int]string{1: controlledErr}, + }, + "ControllerOfTheSameNameInAnotherNamespaceDoesNotMatch": { + reason: "A namespaced parent controls only objects in its own namespace.", + inputs: []input{ + {res: xr("parent").InNamespace("a").Build()}, + {res: xr("child").InNamespace("b").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, + }, + }, + "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.", - xr: side{res: xr("named").Build()}, - manager: side{res: xr("").WithGenerateName("gen-").Build()}, - want: notManaged, + 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.", - xr: side{res: xr("backing").Build()}, - manager: side{res: claim("my-claim").Build(), cluster: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, - want: claimedBy, + 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.", - xr: side{res: xr("backing").Build()}, - manager: side{res: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, - want: claimedBy, + 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.", - xr: side{res: xr("backing").WithSpecField("claimRef", claimRef).Build()}, - manager: side{res: claim("my-claim").Build()}, - want: claimedBy, + 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.", - xr: side{res: xr("backing").Build(), cluster: xr("backing").WithLabels(claimLabels("default")).Build()}, - manager: side{res: claim("my-claim").Build()}, - want: claimedBy, + 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) + } + }) + } +} + +// TestManagedByManagerWithNoName pins that a generateName-only manager has no identity, so it manages +// nothing even when the candidate's controller reference has an empty name. +func TestManagedByManagerWithNoName(t *testing.T) { + xr := tu.NewResource("example.org/v1", "XR", "child").WithControllerReference("XR", "", "example.org/v1", "uid").Build() + manager := tu.NewResource("example.org/v1", "XR", "").WithGenerateName("parent-").Build() + + if got := managedBy(dt.XRDiffGroup{}, xr, dt.XRDiffGroup{}, manager); got != notManaged { + t.Errorf("managedBy(...): want notManaged, got %d", got) + } + + if boundToClaim(dt.XRDiffGroup{}, xr, dt.XRDiffGroup{}, manager) { + t.Error("boundToClaim(...): want false, got true") + } +} + +// 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"}}, }, - "ClaimLabelsForAnotherNamespace": { - reason: "A claim of the same name in another namespace is a different claim.", - xr: side{res: xr("backing").WithLabels(claimLabels("elsewhere")).Build()}, - manager: side{res: claim("my-claim").Build()}, - want: notManaged, + "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) { - xg, mg := group(tt.xr), group(tt.manager) - if got := managedBy(xg, tt.xr.res, mg, tt.manager.res); got != tt.want { - t.Errorf("%s\nmanagedBy(...): want %d, got %d", tt.reason, tt.want, got) + 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) } - if got, want := boundToClaim(xg, tt.xr.res, mg, tt.manager.res), tt.want == claimedBy; got != want { - t.Errorf("%s\nboundToClaim(...): want %t, got %t", tt.reason, want, got) + + 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) } }) } diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index d00a346f..ed32ecf7 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -154,7 +154,7 @@ 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 DiffSingleResource or one + // 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. @@ -231,6 +231,9 @@ type ComponentFactories struct { // 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. @@ -424,6 +427,13 @@ func WithRequirementsProviderFactory(factory func(k8.ResourceClient, logging.Log } } +// 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) { @@ -590,4 +600,8 @@ func (c *ProcessorConfig) SetDefaultFactories() { // 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/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index fbd3cb01..b6c86bee 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -14,8 +14,9 @@ import ( ) // flattenGroups merges every group's diffs into a single map, for the human -// renderer's flat block. The only caller is the composition renderer, which hands it a single -// identity-less group, so no two inputs' diffs are merged here. +// 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. // 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) @@ -146,6 +147,21 @@ func (r *DefaultDiffRenderer) renderDiffList(diffs map[string]*dt.ResourceDiff) 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, + replaced: c.replaced + other.replaced, + 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 @@ -186,18 +202,38 @@ func (c diffCounts) summaryLine() string { return line } +// 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). +// 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 to r.diffOpts.Stdout as a single flat block. It does not +// 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 +// 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), @@ -205,20 +241,29 @@ func (r *DefaultDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.Out "useColors", r.diffOpts.UseColors, "compact", r.diffOpts.Compact) - if err := r.renderFlat(flattenGroups(groups)); err != nil { - return err - } - - // The unverified-additions summary comes from groups with an input XR only: the composition renderer - // reuses this one with an identity-less group once per composition and writes one run-wide summary itself. + // 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 g.XR.Kind != "" || g.XR.Name != "" { + 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 + } + return writeWarnings(r.diffOpts.Stderr, dt.XRDryRunWarnings(xrGroups)) } @@ -242,3 +287,111 @@ func (r *DefaultDiffRenderer) renderFlat(diffs map[string]*dt.ResourceDiff) erro 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 + xrCount int + 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 + } + + xrCount++ + + // The namespace is part of the identity: same-named XRs in different namespaces are distinct. + title := g.XR.Kind + "/" + g.XR.Name + if g.XR.Namespace != "" { + title = g.XR.Kind + "/" + g.XR.Namespace + "/" + g.XR.Name + } + + if _, err := fmt.Fprintf(stdout, "=== %s ===\n\n", title); 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(xrCount, 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 index d032ef12..46630026 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -188,6 +188,43 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { }, 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": { @@ -201,6 +238,91 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { }, 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)", + }, + }, + // Same kind and name in different namespaces: the header names the namespace. + "NamespacedXRHeaders": { + groups: []dt.XRDiffGroup{ + { + XR: corev1.ObjectReference{Kind: "XNopResource", Name: "same", Namespace: "ns-a"}, + Diffs: map[string]*dt.ResourceDiff{}, + }, + { + XR: corev1.ObjectReference{Kind: "XNopResource", Name: "same", Namespace: "ns-b"}, + Diffs: map[string]*dt.ResourceDiff{}, + }, + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/ns-a/same ===", + "=== XNopResource/ns-b/same ===", + }, + }, + // 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 2 XRs", // the identity-less group is not an XR + }, + notExpected: []string{ + "=== /", "=== (unknown)", // no header for the identity-less group + }, + }, // --- The Replaced hook --- @@ -217,6 +339,25 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { "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{ diff --git a/internal/diff/result/types.go b/internal/diff/result/types.go index 7c93e147..c0ee37fa 100644 --- a/internal/diff/result/types.go +++ b/internal/diff/result/types.go @@ -37,9 +37,37 @@ import ( // 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 - Diffs map[string]*ResourceDiff - Err *OutputError + 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 +} + +// 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 { + if group.HasChanges() { + return true + } + } + + return false } // HasChanges reports whether any diff in the group is not equal. diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go index 06694eac..aef8dbc0 100644 --- a/internal/diff/testutils/mock_builder.go +++ b/internal/diff/testutils/mock_builder.go @@ -720,6 +720,21 @@ func NewMockCompositionClient() *MockCompositionClientBuilder { } } +// 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 +} + // WithFindMatchingComposition sets the FindMatchingComposition behavior. func (b *MockCompositionClientBuilder) WithFindMatchingComposition(fn func(context.Context, *un.Unstructured) (dtypes.ResolvedComposition, error)) *MockCompositionClientBuilder { b.mock.FindMatchingCompositionFn = fn @@ -734,6 +749,13 @@ func (b *MockCompositionClientBuilder) WithSuccessfulCompositionMatch(comp *xpex }) } +// 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") + }) +} + // WithSuccessfulCompositionFetch sets up a successful composition fetch. func (b *MockCompositionClientBuilder) WithSuccessfulCompositionFetch(comp *xpextv1.Composition) *MockCompositionClientBuilder { b.mock.GetCompositionFn = func(_ context.Context, name string) (*xpextv1.Composition, error) { @@ -1142,6 +1164,13 @@ func (b *TreeNodeBuilder) Build() *resource.Resource { // endregion +// region DiffProcessor mock builder + +// ====================================================================================== +// DiffProcessor Mock Builder +// ====================================================================================== +// endregion + // region Resource builders // ====================================================================================== @@ -1226,6 +1255,15 @@ func (b *ResourceBuilder) WithSpecField(name string, value any) *ResourceBuilder 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") @@ -1670,6 +1708,11 @@ func (b *XRDBuilder) WithClaimNames(kind, plural string) *XRDBuilder { 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{ diff --git a/internal/diff/testutils/mocks.go b/internal/diff/testutils/mocks.go index eb98451f..761055d5 100644 --- a/internal/diff/testutils/mocks.go +++ b/internal/diff/testutils/mocks.go @@ -257,6 +257,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) (*dt.XRReport, error) DiffSingleResourceFn func(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, map[string]*dt.ResourceDiff, error) CleanupFn func(ctx context.Context) error } @@ -270,6 +271,15 @@ func (m *MockDiffProcessor) Initialize(ctx context.Context) error { return nil } +// PerformDiff implements the DiffProcessor.PerformDiff method. +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 &dt.XRReport{}, nil +} + // DiffSingleResource implements the DiffProcessor.DiffSingleResource method. func (m *MockDiffProcessor) DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, map[string]*dt.ResourceDiff, error) { if m.DiffSingleResourceFn != nil { @@ -343,6 +353,52 @@ func (m *MockDefaulter) Default(ctx context.Context, obj *un.Unstructured) (*un. // 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 // ============================================================================== @@ -778,3 +834,27 @@ func (m *MockDiffCalculator) CalculateRemovedResourceDiffs(ctx context.Context, return nil, nil } + +// MockFunctionProvider provides a mock implementation for FunctionProvider. +type MockFunctionProvider struct { + GetFunctionsForCompositionFn func(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) + CleanupFn func(ctx context.Context) error +} + +// GetFunctionsForComposition implements FunctionProvider. +func (m *MockFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) { + if m.GetFunctionsForCompositionFn != nil { + return m.GetFunctionsForCompositionFn(ctx, 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 +} diff --git a/internal/diff/types/types.go b/internal/diff/types/types.go index 1cb26e24..b3daf84a 100644 --- a/internal/diff/types/types.go +++ b/internal/diff/types/types.go @@ -82,6 +82,15 @@ type ResolvedComposition struct { 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. diff --git a/internal/simulate/annotate.go b/internal/simulate/annotate.go index 3e768138..3c1c4ea0 100644 --- a/internal/simulate/annotate.go +++ b/internal/simulate/annotate.go @@ -42,9 +42,29 @@ const ( diffHidden = "# diff hidden: it holds sensitive values that can't be redacted" ) +// AnnotateXRReport returns r with each resource diff annotated with its +// provider plan from plans (see ProviderPlans.For and 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. A core Secret's data and stringData values are redacted with or +// without plans. Otherwise empty plans return an equivalent copy, which +// renders exactly as r does. +func AnnotateXRReport(r *dt.XRReport, plans *ProviderPlans) *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 (see ProviderPlans.For and annotateDiff), for the -// -o diff renderer. It never mutates o. An unchanged composite +// 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 *ProviderPlans) *dt.CompDiffOutput { diff --git a/internal/simulate/help/output.md b/internal/simulate/help/output.md index ae061254..e6612f69 100644 --- a/internal/simulate/help/output.md +++ b/internal/simulate/help/output.md @@ -1,8 +1,9 @@ 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. + the composite resource it belongs to. When the run simulates a changed + Composition, the 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. Each plan carries the objects it compares, as `before` (the live object, absent for a create) and `after` (the rendered object, absent diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go index a6260e0e..973c792e 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -17,21 +17,36 @@ limitations under the License. package simulate import ( + "context" "fmt" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" ld "github.com/crossplane/cli/v2/pkg/load" ) +// XRTakes is what xr simulate takes, for RejectInput. +const XRTakes = "xr simulate takes composite resource or claim files" + // RejectInput returns the error for an -f input r that the command doesn't // take. takes describes what the command does take, e.g. "composition // simulate takes Composition files". func RejectInput(r *un.Unstructured, takes string) error { + return rejectInput(r, "", takes) +} + +// rejectInput is RejectInput with a reason, if not empty, that says why r was +// rejected and follows the input. +func rejectInput(r *un.Unstructured, reason, takes string) error { gvk := r.GroupVersionKind() - msg := fmt.Sprintf("-f inputs include %s %q (%s); %s", gvk.Kind, r.GetName(), gvk.GroupVersion(), takes) + msg := fmt.Sprintf("-f inputs include %s %q (%s)", gvk.Kind, r.GetName(), gvk.GroupVersion()) + if reason != "" { + msg += ", " + reason + } + msg += "; " + takes if r.IsList() || gvk.Kind == "List" { msg += "; pass its items as separate YAML documents instead of a List" } @@ -71,3 +86,36 @@ func RejectDuplicateNames(comps []*un.Unstructured) error { } return nil } + +// 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. It also gives a claim or namespaced composite resource with no +// namespace the kubeconfig context namespace ns, as kubectl apply would. The +// definition client caches the XRDs when it initializes, so the check costs +// no extra calls. +func checkComposites(ctx context.Context, defs xpclient.DefinitionClient, ns string, resources []*un.Unstructured) error { + for _, r := range resources { + gvk := r.GroupVersionKind() + if xrd, err := defs.GetXRDForClaim(ctx, gvk); err == nil && xrd != nil { + defaultNamespace(r, ns) + continue + } + if xrd, err := defs.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { + // Only a Namespaced scope is namespaced; Cluster and LegacyCluster are not. + if scope, _, _ := un.NestedString(xrd.Object, "spec", "scope"); scope == "Namespaced" { + defaultNamespace(r, ns) + } + continue + } + return rejectInput(r, "which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed)", XRTakes) + } + return nil +} + +// defaultNamespace sets r's namespace to ns if it has none. +func defaultNamespace(r *un.Unstructured, ns string) { + if r.GetNamespace() == "" { + r.SetNamespace(ns) + } +} diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index 837f5f62..0d08a0ce 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -17,6 +17,8 @@ limitations under the License. package simulate import ( + "context" + "errors" "os" "path/filepath" "strings" @@ -24,8 +26,11 @@ import ( "github.com/google/go-cmp/cmp" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/diff/testutils" ) func TestLoadInputs(t *testing.T) { @@ -165,7 +170,7 @@ spec: want: want{err: `-f inputs include GoTemplate "g"`}, }, "KubectlList": { - reason: "A List, as kubectl get -o yaml writes, should be rejected with a hint to unwrap it.", + reason: "A List, as kubectl get -o yaml writes, should be rejected with a hint to unwrap it and no pointer at another command.", paths: []string{list}, want: want{err: `-f inputs include List "" (v1); composition simulate takes Composition files; pass its items as separate YAML documents instead of a List`}, }, @@ -211,3 +216,122 @@ spec: }) } } + +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 in the shared template, with no other command to point at.", + 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 (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, + }, + "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), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, + }, + "ConfigMap": { + reason: "A non-Crossplane kind should be rejected.", + resources: []*un.Unstructured{res("v1", "ConfigMap", "cm")}, + err: `-f inputs include ConfigMap "cm" (v1), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, + }, + "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), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := checkComposites(context.Background(), defs, "ctx-ns", tc.resources) + if tc.err == "" { + if err != nil { + t.Fatalf("\n%s\ncheckComposites(...): %v", tc.reason, err) + } + return + } + if err == nil || err.Error() != tc.err { + t.Fatalf("\n%s\ncheckComposites(...): want error %q, got %v", tc.reason, tc.err, err) + } + }) + } +} + +func TestCheckCompositesNamespace(t *testing.T) { + xrdWithScope := func(scope string) *un.Unstructured { + return &un.Unstructured{Object: map[string]any{"spec": map[string]any{"scope": scope}}} + } + gvk := func(kind string) schema.GroupVersionKind { + return schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: kind} + } + notFound := func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return nil, errors.New("no XRD found") + } + defs := &testutils.MockDefinitionClient{ + GetXRDForXRFn: func(ctx context.Context, g schema.GroupVersionKind) (*un.Unstructured, error) { + switch g { + case gvk("XNamespaced"): + return xrdWithScope("Namespaced"), nil + case gvk("XCluster"): + return xrdWithScope("Cluster"), nil + case gvk("XLegacy"): + return xrdWithScope("LegacyCluster"), nil + } + return notFound(ctx, g) + }, + GetXRDForClaimFn: func(ctx context.Context, g schema.GroupVersionKind) (*un.Unstructured, error) { + if g == gvk("Claim") { + return xrdWithScope("LegacyCluster"), nil + } + return notFound(ctx, g) + }, + } + + cases := map[string]struct { + kind, namespace, want string + }{ + "NamespacedXRWithoutNamespace": {kind: "XNamespaced", want: "ctx-ns"}, + "NamespacedXRWithNamespace": {kind: "XNamespaced", namespace: "mine", want: "mine"}, + "ClusterXR": {kind: "XCluster", want: ""}, + "LegacyClusterXR": {kind: "XLegacy", want: ""}, + "ClaimWithoutNamespace": {kind: "Claim", want: "ctx-ns"}, + "ClaimWithNamespace": {kind: "Claim", namespace: "mine", want: "mine"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := &un.Unstructured{Object: map[string]any{"apiVersion": "example.org/v1", "kind": tc.kind, "metadata": map[string]any{"name": "r"}}} + if tc.namespace != "" { + r.SetNamespace(tc.namespace) + } + if err := checkComposites(context.Background(), defs, "ctx-ns", []*un.Unstructured{r}); err != nil { + t.Fatalf("checkComposites(...): %v", err) + } + if got := r.GetNamespace(); got != tc.want { + t.Errorf("checkComposites(...): namespace = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 3edbd084..6a6bed0e 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -36,7 +36,7 @@ import ( // plans, so its plans say nothing about the cloud resources. const noteProviderPlanNotRun = "Provider plan not run." -// Notes a caller sets on ProjectResult.Notes when no provider plans refined the +// Notes a caller sets on Result.Notes when no provider plans refined the // cluster diff, saying why. A result built from provider plans // carries no note: each approximate plan already says why it is approximate. const ( @@ -44,15 +44,53 @@ const ( NoteNoDiffServers = "Provider plan not run: no provider diff server planned these resources." ) -// FromCompReport maps a composition diff run onto a ProjectResult: a plan and a -// Compositions entry per Composition, and a Composites entry per affected -// composite, with that composite's downstream plans grouped under it. plans -// is the provider plans the run collected (nil means none ran), merged into the -// engine's plan of a diff that renders its resource as the planned one did (see -// ProviderPlans.For). keep is the filter PlanDiffs used: a kept equal diff no +// FromXRReport maps an xr or project diff run onto a 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. plans are the provider plans +// the run collected, each merged into the engine's plan of a diff that +// renders its resource as the planned one did (see ProviderPlans.For); nil +// means none ran. keep is the filter PlanDiffs used: a kept equal diff no // provider planned is dropped, as one whose provider plan is a no-op is. nil // keeps none. -func FromCompReport(o *dt.CompDiffOutput, plans *ProviderPlans, keep func(*unstructured.Unstructured) bool) ProjectResult { +func FromXRReport(r *dt.XRReport, plans *ProviderPlans, keep func(*unstructured.Unstructured) bool) Result { + if r == nil { + r = &dt.XRReport{} + } + + a := newAssembler(plans, keep) + 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 := CompositeResult{Ref: xr, Status: CompositeUnchanged} + + switch { + case g.Err != nil: + c.Status = CompositeError + a.addError(xr, *g.Err, &xr) + owned = append(owned, *g.Err) + case g.HasChanges(): + c.Status = CompositeChanged + } + + if a.addDiffs(g.Diffs, &xr) && c.Status == CompositeUnchanged { + c.Status = CompositeChanged + } + a.composites = append(a.composites, c) + } + + a.addRunErrors(r.Errors, owned) + + return a.result(slices.Concat(r.Warnings, dt.XRDryRunWarnings(r.Groups))) +} + +// FromCompReport maps a composition diff run onto a 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. plans +// and keep are used as FromXRReport uses them. +func FromCompReport(o *dt.CompDiffOutput, plans *ProviderPlans, keep func(*unstructured.Unstructured) bool) Result { if o == nil { o = &dt.CompDiffOutput{} } @@ -324,8 +362,8 @@ func (a *assembler) addRunErrors(all, owned []dt.OutputError) { } } -func (a *assembler) result(warnings []dt.OutputWarning) ProjectResult { - res := NewProjectResult(a.plans, a.composites, a.errors) +func (a *assembler) result(warnings []dt.OutputWarning) Result { + res := NewResult(a.plans, a.composites, a.errors) if a.provider == nil { res.Notes = []string{noteProviderPlanNotRun} } diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index 7d5e69f0..241ee7b3 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -117,7 +117,7 @@ func TestFromCompReport(t *testing.T) { cases := map[string]struct { reason string in *dt.CompDiffOutput - want ProjectResult + want Result }{ "CompositionAndRevisionImpact": { reason: "A changed Composition is an ungrouped plan with a Compositions entry carrying its revision impact; affected XRs group their plans.", @@ -131,7 +131,7 @@ func TestFromCompReport(t *testing.T) { Diffs: diffs(rdiff(cmGVK, "cm", dt.DiffTypeRemoved, map[string]any{}, nil)), }}, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{Update: 1, Destroy: 1}, @@ -161,7 +161,7 @@ func TestFromCompReport(t *testing.T) { filtered("deleting", dt.FilterReasonDeleting, ""), }, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1}, @@ -181,7 +181,7 @@ func TestFromCompReport(t *testing.T) { in: composition(dt.CompositionDiff{ ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusUnchanged, Pinned: true}}, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1}, @@ -197,7 +197,7 @@ func TestFromCompReport(t *testing.T) { in: composition(dt.CompositionDiff{ ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusChanged, Pinned: true, RenderedRevision: "net-6"}}, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1}, @@ -215,7 +215,7 @@ func TestFromCompReport(t *testing.T) { ImpactAnalysisSkipped: true, RevisionImpact: dt.RevisionImpact{ChangeScope: "metadata", CreatesRevision: true, RepointedComposites: 3}, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1}, @@ -234,7 +234,7 @@ func TestFromCompReport(t *testing.T) { in: composition(dt.CompositionDiff{ Error: &dt.OutputError{ResourceID: "Composition/net", Message: "cannot load"}, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Errored: true, @@ -254,7 +254,7 @@ func TestFromCompReport(t *testing.T) { {Name: "net", ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatusUnchanged}}}, {Name: "db", ImpactAnalysis: []dt.XRImpact{filtered("qa", dt.FilterReasonDeleting, "")}}, }}, - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 2}, @@ -276,7 +276,7 @@ func TestFromCompReport(t *testing.T) { in: composition(dt.CompositionDiff{ ImpactAnalysis: []dt.XRImpact{{ObjectReference: xrRef, Status: dt.XRStatus("bogus")}}, }), - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Summary: plan.Summary{NoOp: 1}, @@ -296,7 +296,7 @@ func TestFromCompReport(t *testing.T) { }}, Errors: []dt.OutputError{xrErr, {Message: "interrupted"}}, }, - want: ProjectResult{ + want: Result{ Result: plan.Result{ FormatVersion: plan.ResultFormatVersion, Errored: true, diff --git a/internal/simulate/result.go b/internal/simulate/result.go index da2182fc..1e25976d 100644 --- a/internal/simulate/result.go +++ b/internal/simulate/result.go @@ -66,7 +66,7 @@ const ( CompositeReasonUnknown CompositeReason = "unknown" ) -// CompositeResult is the per-composite outcome of a project simulate run. +// CompositeResult is the per-composite outcome of a simulate run. type CompositeResult struct { Ref plan.ResourceRef `json:"ref"` Status CompositeStatus `json:"status"` @@ -125,7 +125,7 @@ type CompositionResult struct { // 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 ProjectResult.Composites. + // revision would not reach are still listed in Result.Composites. ImpactAnalysisSkipped bool `json:"impactAnalysisSkipped,omitempty"` } @@ -141,12 +141,11 @@ type RevisionImpact struct { PredictedRevision string `json:"predictedRevision,omitempty"` } -// ProjectResult is the serialized form of a project simulate run, for -o -// json|yaml: resource simulate's plan.Result plus the composites, -// compositions, errors, warnings and notes only a project run has. Its -// summary serializes as Result.Summary plus a deferred count taken from +// Result is the serialized form of a simulate run, for -o json|yaml: +// plan.Result plus the composites, compositions, errors, warnings and notes a +// diff run has. Its summary serializes as plan.Result's Summary plus a deferred count taken from // Composites. -type ProjectResult struct { +type Result struct { plan.Result Composites []CompositeResult `json:"composites,omitempty"` @@ -158,17 +157,17 @@ type ProjectResult struct { Notes []string `json:"notes,omitempty"` } -// NewProjectResult assembles the ProjectResult for the given plans and +// NewResult assembles the Result for the given plans and // composites; errs marks the result errored. -func NewProjectResult(plans []*plan.ResourcePlan, composites []CompositeResult, errs []Error) ProjectResult { - res := ProjectResult{Result: plan.NewResult(plans), Composites: composites, Errors: errs} +func NewResult(plans []*plan.ResourcePlan, composites []CompositeResult, errs []Error) Result { + res := Result{Result: plan.NewResult(plans), Composites: composites, Errors: errs} res.Errored = res.Errored || len(errs) > 0 return res } // Deferred counts the composites pinned to a composition revision, which // applying changes nothing for. It does not count as a change. -func (r ProjectResult) Deferred() int { +func (r Result) Deferred() int { n := 0 for _, c := range r.Composites { if c.Status == CompositeDeferred { @@ -182,7 +181,7 @@ func (r ProjectResult) Deferred() int { // ahead of the plans, which can be long, and with the deferred count inside // summary. YAML goes through it too. Decoding needs no counterpart: summary // decodes into Result.Summary, and Deferred is counted from Composites. -func (r ProjectResult) MarshalJSON() ([]byte, error) { +func (r Result) MarshalJSON() ([]byte, error) { type summary struct { plan.Summary @@ -211,9 +210,9 @@ func (r ProjectResult) MarshalJSON() ([]byte, error) { }) } -// A Renderer writes a project simulate result 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, r ProjectResult) error + Render(w io.Writer, r Result) error } // NewRenderer returns the Renderer for the given output format: one of @@ -230,11 +229,11 @@ func NewRenderer(format string) (Renderer, error) { return nil, errors.Errorf("unknown output format %q", format) } -// jsonRenderer renders the ProjectResult as indented JSON. +// jsonRenderer renders the Result as indented JSON. type jsonRenderer struct{} // Render writes the result to w as JSON. -func (jsonRenderer) Render(w io.Writer, r ProjectResult) error { +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") @@ -243,11 +242,11 @@ func (jsonRenderer) Render(w io.Writer, r ProjectResult) error { return err } -// yamlRenderer renders the ProjectResult as YAML. +// yamlRenderer renders the Result as YAML. type yamlRenderer struct{} // Render writes the result to w as YAML. -func (yamlRenderer) Render(w io.Writer, r ProjectResult) error { +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") @@ -265,7 +264,7 @@ func (yamlRenderer) Render(w io.Writer, r ProjectResult) error { type planRenderer struct{} // Render writes the result to w in the human-readable plan format. -func (planRenderer) Render(w io.Writer, r ProjectResult) error { +func (planRenderer) Render(w io.Writer, r Result) error { impacts := revisionImpactLines(r.Compositions) if len(r.Composites) == 0 && !slices.ContainsFunc(r.Plans, func(p *plan.ResourcePlan) bool { return p.Composite != nil }) { for _, p := range r.Plans { @@ -316,7 +315,7 @@ func revisionImpactLines(comps []CompositionResult) map[plan.ResourceRef]string // 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 ProjectResult, impacts map[plan.ResourceRef]string) { +func renderGrouped(w io.Writer, r Result, impacts map[plan.ResourceRef]string) { var refs []plan.ResourceRef byRef := map[plan.ResourceRef][]*plan.ResourcePlan{} status := map[plan.ResourceRef]CompositeResult{} diff --git a/internal/simulate/result_test.go b/internal/simulate/result_test.go index c65078f4..8da19d12 100644 --- a/internal/simulate/result_test.go +++ b/internal/simulate/result_test.go @@ -30,7 +30,7 @@ import ( func TestRenderPlanGrouped(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} pinned := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "staging-network"} - r := ProjectResult{ + r := Result{ Result: plan.Result{ Summary: plan.Summary{Update: 1, Destroy: 1, NoOp: 1}, Plans: []*plan.ResourcePlan{ @@ -80,7 +80,7 @@ Plan: 0 to add, 1 to change, 1 to destroy. func TestRenderPlanCompositeDetail(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} - r := ProjectResult{ + r := Result{ Result: plan.Result{}, Composites: []CompositeResult{{Ref: xr, Status: CompositeUnchanged, Pinned: true, Detail: "rendered against pinned revision net-6 with the changed functions"}}, } @@ -103,7 +103,7 @@ func TestRenderPlanPinnedHeader(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} var buf bytes.Buffer r, _ := NewRenderer(plan.FormatPlan) - if err := r.Render(&buf, ProjectResult{Composites: []CompositeResult{{Ref: xr, Status: CompositeUnchanged, Pinned: true}}}); err != nil { + 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) { @@ -116,7 +116,7 @@ func TestRenderPlanCompositions(t *testing.T) { same := plan.ResourceRef{APIVersion: comp.APIVersion, Kind: comp.Kind, Name: "same"} unknown := plan.ResourceRef{APIVersion: comp.APIVersion, Kind: comp.Kind, Name: "unknown"} xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} - r := ProjectResult{ + r := Result{ Result: plan.Result{ Summary: plan.Summary{Update: 2, NoOp: 2}, Plans: []*plan.ResourcePlan{ @@ -174,7 +174,7 @@ Note: Provider plan not run. func TestRenderJSONProjectFields(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} - in := ProjectResult{ + in := Result{ Composites: []CompositeResult{{Ref: xr, Status: CompositeDeferred, Reason: "manualPolicy"}}, Compositions: []CompositionResult{{ Ref: plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "net"}, @@ -205,7 +205,7 @@ func TestRenderJSONProjectFields(t *testing.T) { if err := r.Render(&buf, in); err != nil { t.Fatalf("Render(json): unexpected error: %v", err) } - got := ProjectResult{} + got := Result{} if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("Render(json): output is not valid JSON: %v", err) } @@ -248,7 +248,7 @@ func TestRenderJSONProjectFields(t *testing.T) { func TestRenderYAMLSummaryDeferred(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} - in := NewProjectResult(nil, []CompositeResult{{Ref: xr, Status: CompositeDeferred}}, nil) + in := NewResult(nil, []CompositeResult{{Ref: xr, Status: CompositeDeferred}}, nil) var buf bytes.Buffer r, _ := NewRenderer(plan.FormatYAML) if err := r.Render(&buf, in); err != nil { @@ -260,7 +260,7 @@ func TestRenderYAMLSummaryDeferred(t *testing.T) { } } -func TestNewProjectResult(t *testing.T) { +func TestNewResult(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} pinned := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "staging-network"} plans := []*plan.ResourcePlan{{APIVersion: "v1", Kind: "ConfigMap", Name: "cm", Action: plan.ActionCreate, Composite: &xr}} @@ -293,12 +293,12 @@ func TestNewProjectResult(t *testing.T) { } for name, tc := range cases { t.Run(name, func(t *testing.T) { - got := NewProjectResult(plans, composites, tc.errs) + got := NewResult(plans, composites, tc.errs) if diff := cmp.Diff(tc.want, want{summary: got.Summary, deferred: got.Deferred(), errored: got.Errored}, cmp.AllowUnexported(want{})); diff != "" { - t.Errorf("\n%s\nNewProjectResult(...): -want, +got:\n%s", tc.reason, diff) + t.Errorf("\n%s\nNewResult(...): -want, +got:\n%s", tc.reason, diff) } if got.FormatVersion != plan.ResultFormatVersion { - t.Errorf("\n%s\nNewProjectResult(...).FormatVersion: want %q, got %q", tc.reason, plan.ResultFormatVersion, got.FormatVersion) + t.Errorf("\n%s\nNewResult(...).FormatVersion: want %q, got %q", tc.reason, plan.ResultFormatVersion, got.FormatVersion) } }) } diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 1e396ab5..f442d656 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -229,6 +229,29 @@ func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp } } +// diffComposites returns the diff simulate runs for composite resources and +// claims: each rendered with the cluster's Compositions and functions, as in +// 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). +func (c *Flags) diffComposites(k *kong.Context, logger logging.Logger, proc dp.DiffProcessor, xp *xpclient.Clients, ns string, xrs []*un.Unstructured) func(context.Context) (bool, error) { + return func(ctx context.Context) (bool, error) { + if err := checkComposites(ctx, xp.Definition, ns, xrs); err != nil { + return false, err + } + report, err := proc.PerformDiff(ctx, xrs, xp.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. + changed := report != nil && report.HasChanges() + if report != nil { + changed, err = c.renderXRReport(k, logger, report, err) + } + return changed, errors.Wrap(err, "cannot simulate composite resources") + } +} + // 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 { @@ -431,6 +454,17 @@ func (c *Flags) notes() []string { return planNotes(c.SkipPlan, true, c.xrPlanner.Routed(), c.xrPlanner.Considered()) } +// renderXRReport writes an xr run's report in the format -o selects, as +// renderReport does. +func (c *Flags) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport, runErr error) (bool, error) { + plans := c.providerPlans() + return c.renderReport(k, logger, runErr, FromXRReport(r, plans, c.keep), r.HasChanges(), dt.XRDryRunWarnings(r.Groups), func(opts renderer.DiffOptions) error { + // Annotated even without plans, so a Secret's values are redacted. + r = AnnotateXRReport(r, plans) + return renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) + }) +} + // renderCompReport writes a composition run's report in the format -o // selects, as renderReport does. func (c *Flags) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput, runErr error) (bool, error) { @@ -448,7 +482,7 @@ func (c *Flags) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.C // whether the run changed anything, provider plans included, and runErr // with any render failure joined to it, so neither is lost. changed is // whether the report itself has changes. -func (c *Flags) renderReport(k *kong.Context, logger logging.Logger, runErr error, res ProjectResult, changed bool, warnings []dt.OutputWarning, renderDiff func(renderer.DiffOptions) error) (bool, error) { +func (c *Flags) renderReport(k *kong.Context, logger logging.Logger, runErr error, res Result, changed bool, warnings []dt.OutputWarning, renderDiff func(renderer.DiffOptions) error) (bool, error) { opts := c.diffOptions(k) plans := c.providerPlans() if plans != nil { @@ -478,7 +512,7 @@ func (c *Flags) renderReport(k *kong.Context, logger logging.Logger, runErr erro // 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 *Flags) renderPlan(k *kong.Context, res ProjectResult, derived []dt.OutputWarning) error { +func (c *Flags) renderPlan(k *kong.Context, res Result, derived []dt.OutputWarning) error { r, err := NewRenderer(c.Output) if err != nil { return err @@ -518,7 +552,8 @@ func isTerminal(w io.Writer) bool { } // Run previews in against the cluster: a project's Compositions, with its -// functions built locally, or Composition files, with the cluster's +// functions built locally, Composition files, with the cluster's functions, +// or composite resource or claim files, with the cluster's Compositions and // functions. func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, in *Inputs) error { if in.Selection != nil { @@ -547,27 +582,13 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg return err } - built := &Built{Compositions: in.Compositions} msg := "cannot simulate Compositions" if project { msg = "cannot simulate project" } - if project { - if built, err = in.Build(sigCtx); err != nil { - return err - } - } else { - var ics []pkgv1beta1.ImageConfig - if ics, err = c.flagImageConfigs(); err != nil { - return err - } - built.Packages = &Packages{ICs: ics} - // Only planning fetches packages, so --skip-plan needs no cache. - if !c.SkipPlan { - if built.Packages.Client, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { - return errors.Wrap(err, "cannot create xpkg client") - } - } + built, err := c.build(sigCtx, in) + if err != nil { + return err } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages @@ -597,10 +618,33 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg opts = append(opts, planOpts...) } xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) + if in.Composites != nil { + return c.simulate(sigCtx, logger, clients.Initialize, xrProc, c.diffComposites(k, logger, xrProc, &clients.XpClients, ns, in.Composites)) + } proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, msg)) } +// build builds in's project, or for file inputs (Compositions or composite +// resources) takes imageConfigs only from --image-configs. +func (c *Flags) build(ctx context.Context, in *Inputs) (*Built, error) { + if in.Build != nil { + return in.Build(ctx) + } + ics, err := c.flagImageConfigs() + if err != nil { + return nil, err + } + pkgs := &Packages{ICs: ics} + // Only planning fetches packages, so --skip-plan needs no cache. + if !c.SkipPlan { + if pkgs.Client, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { + return nil, errors.Wrap(err, "cannot create xpkg client") + } + } + return &Built{Compositions: in.Compositions, Packages: pkgs}, nil +} + // flagImageConfigs returns the ImageConfigs from --image-configs, or none. // Unlike ImageConfigs it never falls back to a project file in the current // directory: a command that takes files rather than a project uses only what @@ -643,13 +687,19 @@ type Built struct { type Inputs struct { // Build builds the project. Run calls it after loading the kubeconfig, // so a bad context fails before the slow build. - // Build is nil for a command that takes Compositions instead. + // Build is nil for a command that takes Compositions or composite + // resources instead. Build func(ctx context.Context) (*Built, error) // Compositions are the Compositions to simulate, with the cluster's // functions, when Build is nil. Compositions []*un.Unstructured + // Composites are the composite resources or claims to simulate, with the + // cluster's Compositions and functions, when Build is nil. When set, + // Compositions is unused. + Composites []*un.Unstructured + // Selection limits the run to the composite resources it selects. A // command without CompositionFlags leaves it nil, which selects them all. Selection *CompositionFlags diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index 56ef07c9..f65061e0 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -29,9 +29,11 @@ import ( "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" "github.com/google/go-containerregistry/pkg/name" + "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" "k8s.io/client-go/rest" "sigs.k8s.io/yaml" @@ -43,10 +45,12 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + 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/diff/testutils" + difftypes "github.com/crossplane/cli/v2/internal/diff/types" "github.com/crossplane/cli/v2/internal/plan" xprender "github.com/crossplane/cli/v2/pkg/render" ) @@ -340,26 +344,33 @@ func TestSimulateDiffOptions(t *testing.T) { } // TestSimulateRenderReports pins which renderer each -o value selects, for -// both report types: ProjectResult for plan, json and yaml, and the engine's +// both report types: 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"}}} + 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 ProjectResult naming the composite. + // isPlanResult checks out decodes as a 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 ProjectResult + var r Result if err := unmarshal([]byte(out), &r); err != nil { - t.Fatalf("output is not a ProjectResult: %v\n%s", err, out) + t.Fatalf("output is not a 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 ProjectResult for composite prod, got:\n%s", out) + t.Errorf("want a Result for composite prod, got:\n%s", out) } } } @@ -377,26 +388,31 @@ func TestSimulateRenderReports(t *testing.T) { cases := map[string]struct { reason string cmd Flags + 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: Flags{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 ProjectResult as JSON.", + reason: "-o json should print Result as JSON.", cmd: Flags{Output: "json"}, + wantXR: isPlanResult(json.Unmarshal), wantComp: isPlanResult(json.Unmarshal), }, "YAML": { - reason: "-o yaml should print ProjectResult as YAML.", + reason: "-o yaml should print Result as YAML.", cmd: Flags{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: Flags{Output: "diff"}, + wantXR: contains("+++ ConfigMap/cm", "Summary: 1 added"), wantComp: contains("=== Composition Changes ===", "=== Affected Composite Resources ==="), }, } @@ -408,6 +424,10 @@ func TestSimulateRenderReports(t *testing.T) { render func(*kong.Context) error want func(*testing.T, string) }{ + "xr": {func(k *kong.Context) error { + _, err := tc.cmd.renderXRReport(k, logging.NewNopLogger(), xrReport, nil) + return err + }, tc.wantXR}, "comp": {func(k *kong.Context) error { _, err := tc.cmd.renderCompReport(k, logging.NewNopLogger(), compReport, nil) return err @@ -941,20 +961,98 @@ func parseFlags(t *testing.T, args ...string) (*Flags, error) { return &cli.Simulate, err } -func TestFlagImageConfigsIgnoresProjectFile(t *testing.T) { - // A Composition run inside a project directory must not read its - // project file: an unparseable one would otherwise fail the run. +func TestBuildFileInputsIgnoreProjectFile(t *testing.T) { + // Composition and composite resource runs inside a project directory + // must not read its project file: an unparseable one would otherwise + // fail the run. dir := t.TempDir() if err := os.WriteFile(filepath.Join(dir, "crossplane-project.yaml"), []byte("{not: [yaml"), 0o600); err != nil { t.Fatal(err) } t.Chdir(dir) - ics, err := (&Flags{}).flagImageConfigs() - if err != nil { - t.Errorf("flagImageConfigs(): want no error, got %v", err) + for name, in := range map[string]*Inputs{ + "Compositions": {Compositions: []*un.Unstructured{{}}}, + "Composites": {Composites: []*un.Unstructured{{}}}, + } { + t.Run(name, func(t *testing.T) { + built, err := (&Flags{}).build(context.Background(), in) + if err != nil { + t.Fatalf("build(): want no error, got %v", err) + } + if len(built.Packages.ICs) != 0 { + t.Errorf("build(): want no ImageConfigs, got %v", built.Packages.ICs) + } + }) + } +} + +func TestDiffComposites(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XThing"} + defs := &testutils.MockDefinitionClient{ + GetXRDForXRFn: func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if gvk == xrGVK { + return &un.Unstructured{}, nil + } + return nil, errors.New("no XRD found") + }, + GetXRDForClaimFn: func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { + return nil, errors.New("no XRD found") + }, + } + res := func(apiVersion, kind string) *un.Unstructured { + return &un.Unstructured{Object: map[string]any{"apiVersion": apiVersion, "kind": kind, "metadata": map[string]any{"name": "n"}}} } - if len(ics) != 0 { - t.Errorf("flagImageConfigs(): want no ImageConfigs, got %v", ics) + + cases := map[string]struct { + reason string + xrs []*un.Unstructured + diffErr error + wantDiffs bool + wantErr string + wantOut string + }{ + "RejectedBeforeDiff": { + reason: "An input no XRD defines should be rejected, unwrapped, before PerformDiff runs.", + xrs: []*un.Unstructured{res("example.org/v1", "XThing"), res("v1", "ConfigMap")}, + wantErr: `-f inputs include ConfigMap "n" (v1), which no XRD`, + }, + "DiffErrorWrapsAndRenders": { + reason: "A PerformDiff error should be wrapped, and the report rendered anyway.", + xrs: []*un.Unstructured{res("example.org/v1", "XThing")}, + diffErr: errors.New("boom"), + wantDiffs: true, + wantErr: "cannot simulate composite resources: boom", + wantOut: "XThing", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + diffed := false + proc := &testutils.MockDiffProcessor{PerformDiffFn: func(context.Context, []*un.Unstructured, difftypes.CompositionProvider) (*dt.XRReport, error) { + diffed = true + ref := corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XThing", Name: "n"} + g := dt.XRDiffGroup{XR: ref} + if tc.diffErr != nil { + g.Err = &dt.OutputError{Message: tc.diffErr.Error()} + } + return &dt.XRReport{Groups: []dt.XRDiffGroup{g}}, tc.diffErr + }} + c := &Flags{Output: "plan", NoColor: true} + stdout := &bytes.Buffer{} + k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: &bytes.Buffer{}}} + xp := &xpclient.Clients{Definition: defs, Composition: &testutils.MockCompositionClient{}} + + _, err := c.diffComposites(k, logging.NewNopLogger(), proc, xp, "", tc.xrs)(context.Background()) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("\n%s\ndiffComposites(...): want error containing %q, got %v", tc.reason, tc.wantErr, err) + } + if diffed != tc.wantDiffs { + t.Errorf("\n%s\ndiffComposites(...): PerformDiff called = %t, want %t", tc.reason, diffed, tc.wantDiffs) + } + if !strings.Contains(stdout.String(), tc.wantOut) { + t.Errorf("\n%s\ndiffComposites(...): want output containing %q, got:\n%s", tc.reason, tc.wantOut, stdout.String()) + } + }) } } diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 48fe549e..e3582028 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -18,16 +18,15 @@ import ( "github.com/crossplane/cli/v2/internal/simulate" ) -// The assertions below read composition simulate's structured output, simulate.ProjectResult, behind the -// builder API in structured_assertions.go. The builders speak crossplane-diff's vocabulary -// (added/modified/removed changes, impactAnalysis[] entries); this file maps each onto its -// simulate.ProjectResult home: +// The assertions below read project simulate's structured output, simulate.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 simulate.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, or, for a pair whose sides are equal, from the plan's // before and after objects; -// - an impactAnalysis[] entry is a Composites entry, its changes the plans grouped 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. @@ -46,26 +45,57 @@ func changeAction(changeType string) plan.Action { } // statusFiltered is crossplane-diff's status for a composite left out of impact analysis, which -// simulate.ProjectResult splits into deferred (Manual policy) and skipped (any other reason). +// simulate.Result splits into deferred (Manual policy) and skipped (any other reason). const statusFiltered = "filtered" -// AssertStructured checks composition simulate's -o json output against expected, running each -// section only if it was expected. Accepts any builder level, so callers don't need -// And()/AndXR()/AndComp(). +// AssertStructured checks a simulate.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() - var res simulate.ProjectResult + var res simulate.Result if err := json.Unmarshal([]byte(jsonOutput), &res); err != nil { t.Fatalf("failed to parse plan result JSON output: %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, res.Summary.Replace) + } + + 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)) + } + + usedErrs := make([]bool, len(res.Errors)) + + for _, want := range expected.errors { + got := findMatchingError(res.Errors, usedErrs, want.resourceID, nil) + 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) + for _, id := range expected.errorIDs { - if findMatchingError(res.Errors, id) == nil { + if findMatchingError(res.Errors, usedErrs, id, nil) == nil { t.Errorf("Expected top-level error with resourceID %q not found. Actual error IDs: %v", id, errorIDs(res.Errors)) } } @@ -75,7 +105,7 @@ func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { } } -func assertComposition(t *testing.T, res simulate.ProjectResult, want *CompositionExpectation) { +func assertComposition(t *testing.T, res simulate.Result, want *CompositionExpectation) { t.Helper() i := slices.IndexFunc(res.Compositions, func(c simulate.CompositionResult) bool { return c.Ref.Name == want.name }) @@ -112,6 +142,8 @@ func assertComposition(t *testing.T, res simulate.ProjectResult, want *Compositi 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, nil) } } @@ -202,11 +234,11 @@ func assertRevisionImpact(t *testing.T, label string, got *simulate.RevisionImpa } } -func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, composites []simulate.CompositeResult, want *XRImpactExpectation) { +func assertXRImpact(t *testing.T, res simulate.Result, label string, composites []simulate.CompositeResult, want *XRImpactExpectation) { t.Helper() i := slices.IndexFunc(composites, func(c simulate.CompositeResult) bool { - return refMatches(c.Ref, want.kind, want.name, want.namespace) + return refMatches(c.Ref, want.kind, want.name, want.namespace, nil, false) }) if i < 0 { t.Errorf("%s: Expected XR impact %s/%s (ns=%s) not found. Actual XRs: %v", @@ -239,8 +271,10 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp } } + usedErrs := make([]bool, len(errs)) + for _, id := range want.errorIDs { - if findMatchingError(errs, id) == nil { + if findMatchingError(errs, usedErrs, id, nil) == nil { t.Errorf("%s: expected error with resourceID %q not found. Actual error IDs: %v", label, id, errorIDs(errs)) } } @@ -252,8 +286,10 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp assertSummary(t, label+": downstream summary", s, create, update, destroy, replace) } + usedPlans := make([]bool, len(downstream)) + for _, wantRes := range want.downstreamResources { - p := findMatchingPlan(downstream, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace) + p := findMatchingPlan(downstream, usedPlans, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace, wantRes.namePattern) 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)) @@ -267,7 +303,7 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp } } -// statusMatches compares a composite's status with crossplane-diff's, whose "filtered" simulate.ProjectResult +// statusMatches compares a composite's status with crossplane-diff's, whose "filtered" simulate.Result // splits into deferred and skipped. func statusMatches(got simulate.CompositeStatus, want string) bool { if want == statusFiltered { @@ -278,23 +314,113 @@ func statusMatches(got simulate.CompositeStatus, want string) bool { return string(got) == want } -// assertFields checks a plan's field expectations. values read the planned side, or the actual side -// of a delete, which carries what is removed. changes compare both sides. +// 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 simulate.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)) + usedErrs := make([]bool, len(res.Errors)) + + for _, want := range expected { + i := -1 + for j, c := range res.Composites { + if !used[j] && refMatches(c.Ref, want.kind, want.name, want.namespace, nil, 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, replace := countActions(changes) + assertSummary(t, "xr "+label+": summary", s, create, update, destroy, replace) + } + + 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 { + if findMatchingError(res.Errors, usedErrs, id, &got.Ref) == nil { + 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 + ": " + } + + used := make([]bool, len(changes)) + + for _, want := range resources { + p := findMatchingPlan(changes, used, want.changeType, want.kind, want.name, want.namespace, want.namePattern) + 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) + } +} + +// assertFields checks a plan's field expectations. values 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) { t.Helper() planned := changeType != dt.DiffTypeWordRemoved for path, want := range values { - // A field that is absent is not an explicit null, so WithField(path, nil) needs it present. - if got, found := valueAt(t, p.Changes, planned, path); !found || !valuesEqual(got, want) { - t.Errorf("%s: field %s: expected %v, got %v (present: %t)", id, path, want, got, found) - } + assertFieldValue(t, id, p, planned, path, want) } for path, want := range changes { if want[0] == nil && want[1] == nil { - t.Fatalf("%s: field %s: WithFieldChange(path, nil, nil) asserts nothing", id, path) + 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 @@ -323,6 +449,65 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri } } +// assertFieldValue checks one WithField or WithFieldAbsent expectation against the planned side, or +// the actual side of a delete. +func assertFieldValue(t *testing.T, id string, p *plan.ResourcePlan, planned bool, path string, want any) { + t.Helper() + + got, found := valueAt(t, p.Changes, planned, path) + + if _, ok := want.(absentField); ok { + if found { + t.Errorf("%s: field %s: expected absent, got %v", id, path, got) + } + + if planned { + assertAbsentInAfter(t, id, p, path) + } else { + assertAbsentInBefore(t, id, p, path) + } + + return + } + + // A field that is absent is not an explicit null, so WithField(path, nil) needs it present. + if !found || !valuesEqual(got, want) { + t.Errorf("%s: field %s: expected %v, got %v (present: %t)", id, path, want, got, found) + } + + if want == nil && planned { + assertAbsentInAfter(t, id, p, path) + } +} + +// 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) + } +} + +// assertAbsentInBefore requires path to be absent from the plan's Before object, not only from its leaf +// changes, when Before is set: an empty map or list leaves no leaf change. +func assertAbsentInBefore(t *testing.T, id string, p *plan.ResourcePlan, path string) { + t.Helper() + + if p.Before == nil { + return + } + + if got, ok := objectValueAt(t, p.Before, path); ok { + t.Errorf("%s: field %s: expected absent, but before 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. @@ -334,6 +519,11 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx return } + if want.mustBePerformed && want.storageVersion != "" { + t.Errorf("%s: WithDryRunPerformed and WithDryRunStorageVersion(%q) contradict each other; a performed dry-run reports no storage version", id, want.storageVersion) + return + } + 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)", @@ -343,7 +533,12 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx return } - if want.skipReason == "" { + if want.skipReason == "" && want.storageVersion == "" { + return + } + + if got == nil && want.skipReason == "" { + t.Errorf("%s: expected dryRun.storageVersion %q, but no dryRun was reported", id, want.storageVersion) return } @@ -354,7 +549,7 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx return } - if got.SkipReason != want.skipReason { + if want.skipReason != "" && got.SkipReason != want.skipReason { t.Errorf("%s: dryRun.skipReason: expected %q, got %q", id, want.skipReason, got.SkipReason) } @@ -362,10 +557,14 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx 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. Without the +// 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 []simulate.Warning, want []warningMatch, noWarnings bool) { @@ -416,6 +615,102 @@ func assertWarningExpectation(t *testing.T, got []simulate.Warning, used []bool, 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 simulate.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) + } + + usedVFs := make([]bool, len(got.ValidationFailures)) + + for _, wantVF := range want.validationFailures { + i := firstUnused(usedVFs, func(i int) bool { + v := got.ValidationFailures[i] + + 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 + } + + usedVFs[i] = true + + 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 simulate.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) + } + + usedFEs := make([]bool, len(got.Errors)) + + for _, wantFE := range want.errors { + i := firstUnused(usedFEs, func(i int) bool { + return got.Errors[i].Type == wantFE.errType && got.Errors[i].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 + } + + usedFEs[i] = true + 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)) + } +} + +// firstUnused returns the index of the first unused element for which match is true, or -1. +func firstUnused(used []bool, match func(i int) bool) int { + for i := range used { + if !used[i] && match(i) { + return i + } + } + + return -1 +} + func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy, replace int) { t.Helper() @@ -477,16 +772,18 @@ func findPlan(plans []*plan.ResourcePlan, ref plan.ResourceRef, composite *plan. return nil } -// findMatchingPlan returns the first plan with changeType's action, kind, name and (when given) -// namespace. -func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namespace string) *plan.ResourcePlan { - for _, p := range plans { - if p.Action != changeAction(changeType) { +// findMatchingPlan returns the first unused plan with changeType's action, kind and (when given) namespace, +// whose name is any, matches namePattern, or equals name. +func findMatchingPlan(plans []*plan.ResourcePlan, used []bool, changeType, kind, name, namespace string, namePattern *regexp.Regexp) *plan.ResourcePlan { + for i, p := range plans { + if used[i] || p.Action != changeAction(changeType) { continue } ref := plan.ResourceRef{Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} - if refMatches(ref, kind, name, namespace) { + if refMatches(ref, kind, name, namespace, namePattern, false) { + used[i] = true + return p } } @@ -494,15 +791,32 @@ func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namesp return nil } -// refMatches reports whether ref has kind, name and, unless namespace is empty, namespace. -func refMatches(ref plan.ResourceRef, kind, name, namespace string) bool { - return ref.Kind == kind && ref.Name == name && (namespace == "" || ref.Namespace == namespace) +// 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, exactNamespace bool) bool { + if ref.Kind != kind || ((exactNamespace || namespace != "") && ref.Namespace != namespace) { + return false + } + + switch { + 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 []simulate.Error, resourceID string) *simulate.Error { +// findMatchingError returns the first unused error whose resource is resourceID ("Kind/name") and, when +// ref is set, whose Ref equals it, and marks it used so one error satisfies one expectation. +func findMatchingError(errs []simulate.Error, used []bool, resourceID string, ref *plan.ResourceRef) *simulate.Error { for i := range errs { - if errorID(errs[i]) == resourceID { + if used[i] || errorID(errs[i]) != resourceID { + continue + } + + if ref == nil || (errs[i].Ref != nil && *errs[i].Ref == *ref) { + used[i] = true + return &errs[i] } } diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index c4350b49..f59f9cbf 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -1,8 +1,10 @@ -// Package simtest asserts on composition simulate's structured output, simulate.ProjectResult, through -// crossplane-diff's composition expectation builders. +// Package simtest asserts on project simulate's structured output, simulate.Result, through +// crossplane-diff's expectation builders. package simtest import ( + "regexp" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) @@ -12,20 +14,25 @@ type Expectation interface { expectation() *Expected } -// Expected is a fluent builder for test expectations on a simulate.ProjectResult: its compositions, -// errors and warnings. Each section is asserted only if it was expected. +// Expected is a fluent builder for test expectations on a simulate.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 - warnings []*WarningExpectation + 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 } -// warningMatch describes one expected entry in warnings[]. Matched +// 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 { @@ -33,7 +40,7 @@ type warningMatch struct { context map[string]string } -// WarningExpectation describes one expected entry in warnings[]. +// WarningExpectation describes one expected entry in an xr diff's warnings[]. type WarningExpectation struct { warningMatch @@ -44,12 +51,21 @@ func (w *WarningExpectation) expectation() *Expected { return w.parent } func (e *Expected) expectation() *Expected { return e } -// ResourceExpectation defines expectations for a single downstream resource change under an XR impact. +type expectedSummary struct { + added int + modified int + removed int +} + +// ResourceExpectation defines expectations for a single resource change. type ResourceExpectation struct { - parent *XRImpactExpectation - changeType string // "added", "modified", "removed" + parent *Expected + 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 namespace string fieldValues map[string]any // For added/removed: field path -> expected value fieldChanges map[string][2]any // For modified: field path -> [old, new] @@ -57,7 +73,10 @@ type ResourceExpectation struct { dryRun dryRunExpectation } -// dryRunExpectation holds a downstream change's dryRun assertions. +// 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 @@ -67,16 +86,115 @@ type ResourceExpectation struct { type dryRunExpectation struct { skipReason string detailSubstring string + storageVersion string mustBePerformed bool } -func (r *ResourceExpectation) expectation() *Expected { return r.parent.expectation() } +// expectation returns the root builder. Only meaningful for a flat top-level +// 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 +} // Expect creates a new Expected builder. func Expect() *Expected { - return &Expected{compositions: make([]*CompositionExpectation, 0)} + return &Expected{ + resources: make([]*ResourceExpectation, 0), + compositions: make([]*CompositionExpectation, 0), + } } +// WithSummary sets the expected summary counts. +func (e *Expected) WithSummary(added, modified, removed int) *Expected { + e.summary = &expectedSummary{ + added: added, + modified: modified, + removed: removed, + } + + return e +} + +// WithAddedResource adds an expectation for an added resource. +func (e *Expected) 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), + } + e.resources = append(e.resources, r) + + return r +} + +// WithModifiedResource adds an expectation for a modified resource. +func (e *Expected) 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), + } + 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 *Expected + resourceID string + messageContains string + validationFailures []*ValidationFailureExpectation +} + +func (e *ErrorExpectation) expectation() *Expected { 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() *Expected { 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() *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 *Expected) WithWarning(messageContains string) *WarningExpectation { @@ -106,12 +224,138 @@ func (w *WarningExpectation) WithWarningContext(context map[string]string) *Warn // And returns to the parent builder. 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 *Expected) 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 Expected to chain another +// WithError call, mirroring the AndXR / AndComp helpers elsewhere in +// this file. +func (e *ErrorExpectation) AndError() *Expected { + 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 *Expected) 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), + } + 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 } +// absentField is the fieldValues entry WithFieldAbsent stores, which assertFields tells from the +// explicit null WithField(path, nil) stores. +type absentField struct{} + +// 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] = absentField{} + 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} @@ -131,6 +375,15 @@ func (r *ResourceExpectation) WithDryRunSkipped(reason, detailSubstring string) 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 @@ -141,8 +394,123 @@ func (r *ResourceExpectation) WithDryRunPerformed() *ResourceExpectation { return r } -// WithErrorID asserts the top-level errors[] contains an error with the given resourceID. It pins -// neither the error's details nor the error count. +// WithNamePattern matches resource name against a regex pattern instead of exact name. +func (r *ResourceExpectation) WithNamePattern(pattern string) *ResourceExpectation { + r.namePattern = regexp.MustCompile(pattern) + return r +} + +// 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() + 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 +// 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 Expected.WithXRs. +type XRExpectation struct { + kind string + name string + namespace string + 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 Expected.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 } + +// WithXRs attaches per-input-XR expectations (built via XR(...)) to assert +// against the xrs[] grouped view, matched regardless of order. +func (e *Expected) WithXRs(xrs ...xrSpec) *Expected { + for _, x := range xrs { + e.xrs = append(e.xrs, x.xrExpectation()) + } + + return e +} + +// Build terminates the chain and returns the root expectation. Optional — +// AssertStructured accepts an *Expected directly — but it reads as an +// explicit "done building" marker. +func (e *Expected) Build() *Expected { 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 +} + +// 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), + } + x.changes = append(x.changes, r) + + return r +} + +// 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 @@ -158,9 +526,8 @@ type CompositionExpectation struct { affectedWithErrors *int affectedFilteredByDeletion *int xrImpacts []*XRImpactExpectation - // compositionChangeType, when set, is the composition's own change: "modified", the only one a - // composition makes. - compositionChangeType string + // Composition changes expectations + compositionChangeType string // "modified" - composition changes are always modifications // impactAnalysisSkipped, when set, asserts the composites were deliberately not evaluated. impactAnalysisSkipped *bool // revisionImpact, when set, asserts what applying the composition does to CompositionRevisions. @@ -200,20 +567,13 @@ type XRImpactExpectation struct { status string // "changed", "unchanged", "error", "filtered" 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 simulate.ProjectResult errors[] attributed to the XR must contain + errorIDs []string // resourceIDs the simulate.Result errors[] attributed to the XR must contain downstreamSummary *expectedSummary downstreamResources []*ResourceExpectation } func (x *XRImpactExpectation) expectation() *Expected { return x.parent.parent } -// expectedSummary is the expected count of each change type. -type expectedSummary struct { - added int - modified int - removed int -} - // WithComposition adds an expectation for a composition. func (e *Expected) WithComposition(name string) *CompositionExpectation { c := &CompositionExpectation{ @@ -287,6 +647,7 @@ func (c *CompositionExpectation) WithoutPredictedRevisionName() *CompositionExpe // WithCompositionModified asserts that the composition itself is modified. func (c *CompositionExpectation) WithCompositionModified() *CompositionExpectation { c.compositionChangeType = dt.DiffTypeWordModified + return c } @@ -339,7 +700,7 @@ func (x *XRImpactExpectation) WithDownstreamSummary(added, modified, removed int // Use this to assert on specific field-level changes in downstream resources. func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, namespace string) *ResourceExpectation { r := &ResourceExpectation{ - parent: x, + impactParent: x, changeType: changeType, kind: kind, name: name, @@ -354,7 +715,14 @@ func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, nam // 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. -func (r *ResourceExpectation) AndXR() *XRImpactExpectation { return r.parent } +// 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") + } + + return r.impactParent +} // AndComp returns to parent to chain more XR impact expectations. // Not needed at the end of a chain - AssertStructured accepts XRImpactExpectation directly. @@ -362,6 +730,12 @@ 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 - AssertStructured accepts CompositionExpectation directly. +func (c *CompositionExpectation) And() *Expected { + 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 @@ -369,5 +743,22 @@ func (x *XRImpactExpectation) AndComp() *CompositionExpectation { // 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 +// ResourceExpectation.AndComposition. +func (x *XRImpactExpectation) AndComposition(name string) *CompositionExpectation { + return x.expectation().WithComposition(name) +} + +// AndComposition starts an expectation for another composition. See +// 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 86ca0408..9462375b 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -12,8 +12,8 @@ import ( "github.com/crossplane/cli/v2/internal/simulate" ) -// resultJSON serializes r the way composition simulate's -o json does. -func resultJSON(t *testing.T, r simulate.ProjectResult) string { +// resultJSON serializes r the way simulate's -o json does. +func resultJSON(t *testing.T, r simulate.Result) string { t.Helper() out, err := json.Marshal(r) @@ -38,6 +38,174 @@ func checkAssertion(t *testing.T, name string, wantFail bool, assert func(*testi } } +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"} +) + +// 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() simulate.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")}, + }, + }, + { + 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")}, + }, + }, + { + 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}, + } + + return simulate.Result{ + Result: plan.Result{ + Summary: plan.Summarize(plans), + Plans: plans, + }, + Composites: []simulate.CompositeResult{ + {Ref: xrA, Status: simulate.CompositeChanged}, + {Ref: xrB, Status: simulate.CompositeUnchanged}, + {Ref: xrC, Status: simulate.CompositeError}, + }, + Errors: []simulate.Error{{Ref: &xrC, Message: "cannot get composition"}}, + Warnings: []simulate.Warning{{Message: "something advisory", Context: map[string]string{"composition": "c"}}}, + } +} + +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() *Expected { + return Expect().WithAddedResource("ConfigMap", "cm-x7k2p", "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 Expectation + wantFail bool + }{ + "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"). + 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: Expect(). + WithModifiedResource("Bucket", "bucket-a", "default").And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "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, + }, + "WrongOldValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "eu-west-1", "us-west-2"), + wantFail: true, + }, + "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 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, + }, + "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: 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"). + Summary(1, 1, 1). + Change("modified", "Bucket", "bucket-a", "default"). + 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()}, + "GroupedWrongStatus": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default").Status("unchanged"), + XR("XBucket", "unchanged-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + // Two expectations cannot both consume the same entry. + "GroupedDuplicate": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default"), + XR("XBucket", "changed-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + "GroupedErrorOnWrongXR": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default").Err("XBucket/broken-xr"), + XR("XBucket", "unchanged-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(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"} @@ -48,8 +216,8 @@ var ( // 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() simulate.ProjectResult { - return simulate.ProjectResult{ +func compResult() simulate.Result { + return simulate.Result{ Result: plan.Result{ Plans: []*plan.ResourcePlan{ { @@ -255,3 +423,87 @@ func TestAssertFieldsAndWarnings(t *testing.T) { checkAssertion(t, name, tc.wantFail, tc.assert) } } + +// TestAssertStructuredCombined checks that xr-style and composition-style sections of one Expect() +// are asserted against a single simulate.Result, and that each still fails where it did on its own. +func TestAssertStructuredCombined(t *testing.T) { + res := xrResult() + res.Compositions = []simulate.CompositionResult{{Ref: compA, RevisionImpact: &simulate.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}}} + res.Composites = append(res.Composites, simulate.CompositeResult{Ref: xr1, Status: simulate.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").And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "default").And().WithRemovedResource("Secret", "gone", "default").And(), + wantFail: true, + }, + } + + for name, tc := range cases { + 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() }, + "AndComposition on XR-scoped": func() { + XR("X", "x", "ns").Change("added", "K", "n", "ns").AndComposition("c") + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: expected panic, not a nil parent", name) + } + }() + fn() + }() + } +} diff --git a/internal/testutils/integration/simharness/cases.go b/internal/testutils/integration/simharness/cases.go new file mode 100644 index 00000000..1718c570 --- /dev/null +++ b/internal/testutils/integration/simharness/cases.go @@ -0,0 +1,397 @@ +//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 simharness + +import ( + "bytes" + "fmt" + "strconv" + "strings" + "testing" + "time" + + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/client-go/rest" + + tu "github.com/crossplane/cli/v2/internal/diff/testutils" + "github.com/crossplane/cli/v2/internal/simulate/simtest" + "github.com/crossplane/cli/v2/internal/testutils/integration" +) + +// TestdataDir is the shared testdata, relative to a suite's package directory under cmd/crossplane. +const TestdataDir = "../../../internal/testutils/integration/testdata" + +// TestType is the command a case runs against: its testdata subdirectory, and the simulate command its +// crossplane-diff args translate to. +type TestType string + +// The test types: XRTest runs `xr simulate`, CompositionTest `composition simulate`. +const ( + XRTest TestType = "diff" + CompositionTest TestType = "comp" +) + +// 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 integration.Composes() for an XR without nested XRs. Put a + // composed resource in setupFiles instead only when it deliberately has no controlling XR. + CrossplaneManagedResources []integration.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 []integration.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 + 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 integration.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. + OutputFormat string // "json" or "" (default=visual diff) + // 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 +} + +// RunIntegrationTest runs a single integration test case for both XR and composition diff commands. +// +//nolint:gocognit // One flow every case funnels through; splitting it would scatter the case semantics. +func RunIntegrationTest(t *testing.T, testType TestType, tt IntegrationTestCase) { + t.Helper() + + t.Parallel() // Enable parallel test execution + + // 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 + } + + // 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)) + + // 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 == CompositionTest { + 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") + } + + // 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 == CompositionTest { + 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...) + + // 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 + } + + // Start fails, rather than skips, a run without envtest's binaries or Crossplane's CRDs. + cluster := integration.Start(t, integration.Setup{ + CRDDirs: []string{TestdataDir + "/" + string(testType) + "/crds"}, + SetupFiles: tt.SetupFiles, + UnestablishedXRDs: tt.UnestablishedXRDs, + CrossplaneManagedResources: tt.CrossplaneManagedResources, + XRDAPIVersion: tt.XrdAPIVersion, + FieldManagerApplies: tt.FieldManagerApplies, + DeleteAfterSetup: tt.DeleteAfterSetup, + RBACRules: tt.RbacRules, + }) + + // 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 := integration.AwaitAdmissionDenial(cluster.Context, 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(runCfg *rest.Config) (int, error) { + stdout.Reset() + stderr.Reset() + + return runSimulate(t, runCfg, testType, args, &stdout, &stderr) + } + + exitCode, err := runOnce(cluster.Config) + + if tt.AssertDeterministicAcrossRuns { + first := stdout.String() + + exitCode, err = runOnce(cluster.Config) + + 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.UserConfig != nil { + admin, adminErr := stdout.String(), err + + exitCode, err = runOnce(cluster.UserConfig) + + 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) + } + + // 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.ExpectedStructured != 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.ExpectedStructured == nil { + return + } + + // Check the output + outputStr := stdout.String() + + // Handle JSON output format with structured assertions + if tt.OutputFormat == "json" && tt.ExpectedStructured != nil { + simtest.AssertStructured(t, outputStr, tt.ExpectedStructured) + 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) + } +} + +// 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. +// +//nolint:goconst // Verbs read as RBAC rules, not as shared constants. +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"}}, + } +} diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/internal/testutils/integration/simharness/harness.go similarity index 75% rename from cmd/crossplane/project/integration_simulate_harness_test.go rename to internal/testutils/integration/simharness/harness.go index ceca5659..2b54d56d 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/internal/testutils/integration/simharness/harness.go @@ -16,12 +16,12 @@ 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 +// This package is the simulate harness: the one function every integration // case funnels through. The cases are crossplane-diff's (origin/main // https://github.com/crossplane-contrib/crossplane-diff/commit/6a140b65a6bdb428add78680e6caee9f08b7f04b, plus later upstream additions through // https://github.com/crossplane-contrib/crossplane-diff/commit/d93eb87c6315cb2b44e4790e369eef3590c31dff), written as its comp and xr commands' CLI args plus an expected // exit code and output. runSimulate translates those args into -// `crossplane composition simulate` args and runs the real command through +// `crossplane composition simulate` or `crossplane xr simulate` args and runs the real command through // kong against the case's envtest cluster, started by // internal/testutils/integration, so no case table changes. // @@ -32,10 +32,11 @@ limitations under the License. // without a Docker daemon no rendering case can run; cases that would not // render cannot be told apart upfront, so they skip too. // -// testdata/README.md describes how a case runs, the skips, and how to run -// the suite and read its coverage. +// Each suite's testdata/README.md describes how its cases run, the skips, +// and how to run the suite and read its coverage. -package project +// Package simharness is the simulate harness shared by the integration suites of `composition`, `xr` and `project`. +package simharness import ( "context" @@ -54,6 +55,8 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/cli/v2/cmd/crossplane/composition" + "github.com/crossplane/cli/v2/cmd/crossplane/project" + "github.com/crossplane/cli/v2/cmd/crossplane/xr" "github.com/crossplane/cli/v2/internal/config" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/ref" @@ -64,12 +67,16 @@ import ( clixpkg "github.com/crossplane/cli/v2/internal/xpkg" ) -// defaultTimeout is the --timeout the cases pass to simulate. -const defaultTimeout = 60 * time.Second +// DefaultTimeout is the --timeout the cases pass to simulate. +const DefaultTimeout = 60 * time.Second + +// LongTimeout is the --timeout for cases with multiple iterations, such as +// eventual state. +const LongTimeout = 180 * time.Second // simArgs mirrors the crossplane-diff flags the integration cases pass, // plus each command's positional files, so runSimulate can translate -// them into composition simulate's flags. +// them into composition or xr simulate's flags. 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"` @@ -77,6 +84,7 @@ type simArgs struct { 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"` 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:","` @@ -88,7 +96,7 @@ type simArgs struct { // 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 { +func parseSimulateArgs(t *testing.T, testType TestType, args []string) *simArgs { t.Helper() var a simArgs @@ -105,20 +113,25 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA } // simulateArgv translates the parsed crossplane-diff args into composition -// simulate's: the input files as -f, each --resource as a --name (and +// or xr 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 // defaults to -o plan, and json or yaml as simulate's -o json/yaml result type. -func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { +func simulateArgv(t *testing.T, testType TestType, a *simArgs) []string { t.Helper() - // NOTE: only Composition cases run here; xr simulate adds its type. - if testType != CompositionDiffTest { + var cmd string + switch testType { + case CompositionTest: + cmd = "composition" + case XRTest: + cmd = "xr" + default: t.Fatalf("simulateArgv: no simulate command for test type %q", testType) } argv := []string{ - "composition", "simulate", + cmd, "simulate", "--timeout=" + a.Timeout.String(), "--output=" + a.Output, "--dry-run-on=" + a.DryRunOn, @@ -145,6 +158,9 @@ func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { 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") } @@ -163,45 +179,47 @@ func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { } // runSimulate runs one case's args through the real `crossplane -// composition simulate` command against cfg, and returns the exit code the command +// composition simulate` or `crossplane xr simulate` command against cfg, and returns the exit code the command // 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 restricted -// RBAC rerun relies on it. -func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []string, stdout, stderr io.Writer) (int, error) { +// client caches just as a second CLI invocation would; the determinism +// re-run and the restricted RBAC rerun rely on it. +func runSimulate(t *testing.T, cfg *rest.Config, testType TestType, args []string, stdout, stderr io.Writer) (int, error) { t.Helper() a := parseSimulateArgs(t, testType, args) - return runSimulateArgv(t, cfg, simulateArgv(t, testType, a), a.CrossplaneRenderBinary, stdout, stderr) + return RunSimulateArgv(t, cfg, simulateArgv(t, testType, a), a.CrossplaneRenderBinary, nil, stdout, stderr) } -// runSimulateArgv runs `crossplane 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) { +// RunSimulateArgv runs `crossplane simulate` with argv against cfg, +// rendering with renderBinary when it is set, as runSimulate does, and routing provider +// plans through router, when it is non-nil, instead of started diff servers. +func RunSimulateArgv(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, withRenderBinary(argv, renderBinary), &simulate.Hooks{}, stdout, stderr) + return RunSimulateArgvWith(t, cfg, WithRenderBinary(argv, renderBinary), &simulate.Hooks{Router: router}, stdout, stderr) } -// withRenderBinary returns argv rendering with the local crossplane binary +// WithRenderBinary returns argv rendering with the local crossplane binary // bin, or argv itself, rendering with the render image, when bin is empty. -func withRenderBinary(argv []string, bin string) []string { +func WithRenderBinary(argv []string, bin string) []string { if bin == "" { return argv } return append(slices.Clip(argv), "--crossplane-binary="+bin) } -// runSimulateArgvWith runs `crossplane simulate` with argv against +// RunSimulateArgvWith runs `crossplane simulate` with argv against // cfg and hooks, whose RESTConfig and Terminal it sets. -func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *simulate.Hooks, stdout, stderr io.Writer) (int, error) { +func RunSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *simulate.Hooks, stdout, stderr io.Writer) (int, error) { t.Helper() var cli struct { - Project Cmd `cmd:""` + Project project.Cmd `cmd:""` Composition composition.Cmd `cmd:""` + XR xr.Cmd `cmd:""` } parser, err := kong.New(&cli, kong.Name("crossplane"), @@ -237,6 +255,7 @@ func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *s hooks.Terminal = true cli.Project.Simulate.Hooks = hooks cli.Composition.Simulate.Hooks = hooks + cli.XR.Simulate.Hooks = hooks err = kctx.Run() // Exit as main does: a silent ExitError, such as changes found, exits @@ -260,16 +279,18 @@ func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *s } } -// dockerAvailable reports whether the local Docker daemon answers, which +// 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 { +// +//nolint:gochecknoglobals // Memoizes one daemon probe for every case. +var DockerAvailable = sync.OnceValue(func() bool { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return docker.Check(ctx) == nil }) -// localCrossplaneBinary returns the absolute path to a locally built +// LocalCrossplaneBinary returns the absolute path to a locally built // `crossplane` binary at _output/bin/crossplane in the repo root, or "" when // there is none. With a binary, the render engine runs `crossplane internal // render` directly, which is faster; without one, callers pass "" and it @@ -284,7 +305,7 @@ var dockerAvailable = sync.OnceValue(func() bool { // Callers pass the path to the command as --crossplane-binary= rather // than through a process-global env var, so parallel tests don't race on // shared state. -func localCrossplaneBinary(t *testing.T) string { +func LocalCrossplaneBinary(t *testing.T) string { t.Helper() if override := os.Getenv("CROSSPLANE_RENDER_BINARY"); override != "" { diff --git a/cmd/crossplane/project/testdata/comp/crds/xapimigrateresource-crd.yaml b/internal/testutils/integration/testdata/comp/crds/xapimigrateresource-crd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/crds/xapimigrateresource-crd.yaml rename to internal/testutils/integration/testdata/comp/crds/xapimigrateresource-crd.yaml diff --git a/cmd/crossplane/project/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml b/internal/testutils/integration/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml rename to internal/testutils/integration/testdata/comp/crds/xdownstreamresource-cluster-crd.yaml diff --git a/cmd/crossplane/project/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml b/internal/testutils/integration/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml rename to internal/testutils/integration/testdata/comp/crds/xdownstreamresource-legacycluster-crd.yaml diff --git a/cmd/crossplane/project/testdata/comp/crds/xdownstreamresource-ns-crd.yaml b/internal/testutils/integration/testdata/comp/crds/xdownstreamresource-ns-crd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/crds/xdownstreamresource-ns-crd.yaml rename to internal/testutils/integration/testdata/comp/crds/xdownstreamresource-ns-crd.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-1.yaml b/internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-1.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-downstream-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-2.yaml b/internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-2.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-downstream-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-manual.yaml b/internal/testutils/integration/testdata/comp/resources/existing-downstream-manual.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-manual.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-downstream-manual.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-revision-ref.yaml b/internal/testutils/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-downstream-revision-ref.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-downstream-revision-ref.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-1.yaml b/internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-1.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-xr-1.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-2.yaml b/internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-2.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-xr-2.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-manual.yaml b/internal/testutils/integration/testdata/comp/resources/existing-xr-manual.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-manual.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-xr-manual.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-revision-ref.yaml b/internal/testutils/integration/testdata/comp/resources/existing-xr-revision-ref.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/existing-xr-revision-ref.yaml rename to internal/testutils/integration/testdata/comp/resources/existing-xr-revision-ref.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/functions.yaml b/internal/testutils/integration/testdata/comp/resources/functions.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/functions.yaml rename to internal/testutils/integration/testdata/comp/resources/functions.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition.yaml b/internal/testutils/integration/testdata/comp/resources/original-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/original-composition.yaml rename to internal/testutils/integration/testdata/comp/resources/original-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-revision.yaml b/internal/testutils/integration/testdata/comp/resources/revision-templating-composition-revision.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-revision.yaml rename to internal/testutils/integration/testdata/comp/resources/revision-templating-composition-revision.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition.yaml b/internal/testutils/integration/testdata/comp/resources/revision-templating-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/revision-templating-composition.yaml rename to internal/testutils/integration/testdata/comp/resources/revision-templating-composition.yaml diff --git a/cmd/crossplane/project/testdata/comp/resources/xrd.yaml b/internal/testutils/integration/testdata/comp/resources/xrd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/resources/xrd.yaml rename to internal/testutils/integration/testdata/comp/resources/xrd.yaml diff --git a/cmd/crossplane/project/testdata/comp/updated-composition.yaml b/internal/testutils/integration/testdata/comp/updated-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/comp/updated-composition.yaml rename to internal/testutils/integration/testdata/comp/updated-composition.yaml diff --git a/internal/testutils/integration/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml b/internal/testutils/integration/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml new file mode 100644 index 00000000..1d9370bb --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xapimigrateresource-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xapimigrateresource-crd.yaml new file mode 100644 index 00000000..3005cf3c --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..78cbb0ed --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml new file mode 100644 index 00000000..d26f917d --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml new file mode 100644 index 00000000..49c1db14 --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xdownstreamresource-ns-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xdownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..1d5e07ff --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xstatusresource-ns-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xstatusresource-ns-crd.yaml new file mode 100644 index 00000000..b8ddfe4f --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml b/internal/testutils/integration/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..16873804 --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/modified-xr.yaml b/internal/testutils/integration/testdata/diff/modified-xr.yaml new file mode 100644 index 00000000..8cef439a --- /dev/null +++ b/internal/testutils/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/testutils/integration/testdata/diff/new-xr.yaml b/internal/testutils/integration/testdata/diff/new-xr.yaml new file mode 100644 index 00000000..57dd706b --- /dev/null +++ b/internal/testutils/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/cmd/crossplane/project/testdata/diff/resources/cluster-xrd.yaml b/internal/testutils/integration/testdata/diff/resources/cluster-xrd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/cluster-xrd.yaml rename to internal/testutils/integration/testdata/diff/resources/cluster-xrd.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/composition.yaml b/internal/testutils/integration/testdata/diff/resources/composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/composition.yaml rename to internal/testutils/integration/testdata/diff/resources/composition.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/conditional-gating-composition.yaml b/internal/testutils/integration/testdata/diff/resources/conditional-gating-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/conditional-gating-composition.yaml rename to internal/testutils/integration/testdata/diff/resources/conditional-gating-composition.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/cycle/compositions.yaml b/internal/testutils/integration/testdata/diff/resources/cycle/compositions.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/cycle/compositions.yaml rename to internal/testutils/integration/testdata/diff/resources/cycle/compositions.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/cycle/xrds.yaml b/internal/testutils/integration/testdata/diff/resources/cycle/xrds.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/cycle/xrds.yaml rename to internal/testutils/integration/testdata/diff/resources/cycle/xrds.yaml diff --git a/internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml b/internal/testutils/integration/testdata/diff/resources/existing-downstream-resource.yaml new file mode 100644 index 00000000..3dc89919 --- /dev/null +++ b/internal/testutils/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/cmd/crossplane/project/testdata/diff/resources/existing-other-xr-downstream.yaml b/internal/testutils/integration/testdata/diff/resources/existing-other-xr-downstream.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/existing-other-xr-downstream.yaml rename to internal/testutils/integration/testdata/diff/resources/existing-other-xr-downstream.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-other-xr.yaml b/internal/testutils/integration/testdata/diff/resources/existing-other-xr.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/existing-other-xr.yaml rename to internal/testutils/integration/testdata/diff/resources/existing-other-xr.yaml diff --git a/internal/testutils/integration/testdata/diff/resources/existing-xr.yaml b/internal/testutils/integration/testdata/diff/resources/existing-xr.yaml new file mode 100644 index 00000000..38e8c85c --- /dev/null +++ b/internal/testutils/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/cmd/crossplane/project/testdata/diff/resources/functions.yaml b/internal/testutils/integration/testdata/diff/resources/functions.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/functions.yaml rename to internal/testutils/integration/testdata/diff/resources/functions.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-composition.yaml b/internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/removal-test-composition.yaml rename to internal/testutils/integration/testdata/diff/resources/removal-test-composition.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml b/internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml rename to internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml b/internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml rename to internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml b/internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml rename to internal/testutils/integration/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-gating-composition.yaml b/internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/sequencer-gating-composition.yaml rename to internal/testutils/integration/testdata/diff/resources/sequencer-gating-composition.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/shared-child-composition.yaml b/internal/testutils/integration/testdata/diff/resources/shared-child-composition.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/shared-child-composition.yaml rename to internal/testutils/integration/testdata/diff/resources/shared-child-composition.yaml diff --git a/cmd/crossplane/project/testdata/diff/resources/xrd.yaml b/internal/testutils/integration/testdata/diff/resources/xrd.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/resources/xrd.yaml rename to internal/testutils/integration/testdata/diff/resources/xrd.yaml diff --git a/cmd/crossplane/project/testdata/diff/shared-child-xrs.yaml b/internal/testutils/integration/testdata/diff/shared-child-xrs.yaml similarity index 100% rename from cmd/crossplane/project/testdata/diff/shared-child-xrs.yaml rename to internal/testutils/integration/testdata/diff/shared-child-xrs.yaml diff --git a/internal/testutils/integration/testdata/diff/unmodified-xr.yaml b/internal/testutils/integration/testdata/diff/unmodified-xr.yaml new file mode 100644 index 00000000..9d00b3d4 --- /dev/null +++ b/internal/testutils/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/nix/apps.nix b/nix/apps.nix index 43998fa5..89304216 100644 --- a/nix/apps.nix +++ b/nix/apps.nix @@ -77,9 +77,11 @@ in echo "integration-test: found no packages with //go:build integration test files" >&2 exit 1 fi + # -p 1: each package's cases already run in parallel, and several packages' + # rendering through Docker at once starve each other into timeouts. # -coverpkg credits the tests' coverage to the packages they exercise; # without it the profile only covers the test packages themselves. - go test -tags integration -covermode=count \ + go test -tags integration -p 1 -covermode=count \ -coverpkg=./... -coverprofile=coverage-integration.txt "''${pkgs[@]}" "$@" ''; }