diff --git a/cmd/crossplane/composition/composition.go b/cmd/crossplane/composition/composition.go index 693b2ff0..c07a19d1 100644 --- a/cmd/crossplane/composition/composition.go +++ b/cmd/crossplane/composition/composition.go @@ -27,4 +27,5 @@ type Cmd struct { Convert convert.Cmd `cmd:"" help:"Convert a Composition to a newer version." maturity:"beta"` Generate generateCmd `cmd:"" help:"Generate a Composition for a CompositeResourceDefinition (XRD)." maturity:"beta"` Render xr.Cmd `cmd:"" help:"Render a composite resource (XR)."` + Simulate simulateCmd `cmd:"" help:"Preview what applying changed Compositions would do." maturity:"alpha"` } diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md new file mode 100644 index 00000000..a5665bbe --- /dev/null +++ b/cmd/crossplane/composition/help/simulate.md @@ -0,0 +1,22 @@ +The `composition simulate` command previews how applying changed Compositions +would change the composite resources in a cluster. It renders each composite +resource that uses one of the Compositions with the changed version, 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 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`. +Functions come from the cluster. `--cache-dir` sets where provider package +contents are cached. + diff --git a/cmd/crossplane/composition/help/simulate_examples.md b/cmd/crossplane/composition/help/simulate_examples.md new file mode 100644 index 00000000..d26e5daf --- /dev/null +++ b/cmd/crossplane/composition/help/simulate_examples.md @@ -0,0 +1,27 @@ + +## Examples + +Simulate a changed Composition against the current context: + +```shell +crossplane composition simulate -f composition.yaml +``` + +Simulate two Compositions, only for composite resources in one namespace: + +```shell +crossplane composition simulate -f network.yaml -f database.yaml -n team-a +``` + +Simulate a Composition read from standard input, for a single composite +resource: + +```shell +cat composition.yaml | crossplane composition simulate -f - --name=team-a/my-database +``` + +Print the plan as JSON, without provider plans: + +```shell +crossplane composition simulate -f composition.yaml -o json --skip-plan +``` diff --git a/cmd/crossplane/composition/simulate.go b/cmd/crossplane/composition/simulate.go new file mode 100644 index 00000000..e2a05728 --- /dev/null +++ b/cmd/crossplane/composition/simulate.go @@ -0,0 +1,71 @@ +/* +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 + +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/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 Compositions would change the composite +// resources in a cluster. +type simulateCmd struct { + simulate.Flags `embed:""` + simulate.CompositionFlags `embed:""` + + Input []string `help:"Composition 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(true) + simulateExamples +} + +// Run previews the Compositions -f names against the cluster, with the +// cluster's functions. +func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter) error { + comps, err := simulate.LoadInputs(c.Input, func(r *un.Unstructured) error { + if r.GroupVersionKind() != apiextensionsv1.CompositionGroupVersionKind { + return simulate.RejectInput(r, "composition simulate takes Composition files") + } + return nil + }) + if err != nil { + return err + } + if err := simulate.RejectDuplicateNames(comps); err != nil { + return err + } + return c.Flags.Run(sigCtx, k, logger, sp, &simulate.Inputs{Compositions: comps, Selection: &c.CompositionFlags}) +} diff --git a/cmd/crossplane/composition/simulate_test.go b/cmd/crossplane/composition/simulate_test.go new file mode 100644 index 00000000..08732e81 --- /dev/null +++ b/cmd/crossplane/composition/simulate_test.go @@ -0,0 +1,124 @@ +/* +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 + +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", "comp.yaml"}, + input: []string{"comp.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", + }, + "BadName": { + reason: "A malformed --name fails CompositionFlags' Validate.", + args: []string{"-f", "comp.yaml", "--name=a/b/c"}, + err: "--name", + }, + "NoMaxConcurrency": { + reason: "--max-concurrency is project-only.", + args: []string{"-f", "comp.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{"composition simulate", simulate.Help(true), "## Examples"} { + if !strings.Contains(h, want) { + t.Errorf("Help(): want it to contain %q", want) + } + } +} + +func TestSimulateRejectsNonComposition(t *testing.T) { + path := filepath.Join(t.TempDir(), "xr.yaml") + if err := os.WriteFile(path, []byte("apiVersion: example.org/v1\nkind: XNetwork\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 XNetwork "net" (example.org/v1); 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/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index bee110e5..8b523937 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -1,7 +1,18 @@ 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. +doesn't change the cluster. To simulate Composition files against a cluster +without a project, use `crossplane composition simulate`. + +> **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 build and 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. The `-f` flag names the project file, `crossplane-project.yaml` by default. The command builds the project's embedded functions and loads them into the local @@ -12,14 +23,6 @@ the project's functions in place of the cluster's functions of the same name. `--max-concurrency` caps the concurrent function builds, and `--cache-dir` sets where package contents are cached. -The command connects to the cluster in your current Kubernetes context, or the -one `--context` names. - -The command needs Docker, to build and run functions, and read access to the -cluster's Crossplane resources. It verifies the rendered resources with -server-side dry-run requests, so it also needs the `create` and `patch` verbs on -them. Use `--as`, `--as-group`, and `--as-uid` to simulate as another user. - For example, a run with `--skip-plan` whose project changes a Composition prints a plan like this: diff --git a/cmd/crossplane/project/integration_plan_output_test.go b/cmd/crossplane/project/integration_plan_output_test.go index ddd27bab..38ca236e 100644 --- a/cmd/crossplane/project/integration_plan_output_test.go +++ b/cmd/crossplane/project/integration_plan_output_test.go @@ -21,10 +21,14 @@ package project import ( "bytes" "encoding/json" + "os" + "slices" "strings" "testing" "github.com/google/go-cmp/cmp" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/plan" @@ -56,13 +60,70 @@ 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"} + cases := map[string]struct { reason string - // args follow the plain project's -f. + // args follow the plain project's -f, or with compArgv set, `composition simulate`. args []string + // compArgv runs `composition simulate` with args instead. + compArgv bool + // wantText is the -o plan output; empty for a json case. + wantText string // wantResult is the -o json output, reduced by summarize. wantResult *planShape }{ + "CompPlanText": { + reason: "-o plan prints the Composition's plan with its revision impact, then each affected XR's plans under it.", + compArgv: true, + args: compArgs, + wantText: `[~] Composition.apiextensions.crossplane.io xnopresources.diff.example.org (update) + Applying this creates a new CompositionRevision, which 2 composites would adopt. + spec: + pipeline[0]: + input: + inline: + ~ template: "apiVersion: ns.nop.example.org/v1alpha1\nkind: XDownstreamResource\nmetadata:\n name: {{ .observed.composite.resource.metadata.name }}\n namespace: {{ .observed.composite.resource.metadata.namespace }}\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: nop-resource\nspec:\n forProvider:\n configData: {{ .observed.composite.resource.spec.coolField }}\n resourceTier: basic\n" -> "apiVersion: ns.nop.example.org/v1alpha1\nkind: XDownstreamResource\nmetadata:\n name: {{ .observed.composite.resource.metadata.name }}\n namespace: {{ .observed.composite.resource.metadata.namespace }}\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: nop-resource\nspec:\n forProvider:\n configData: updated-{{ .observed.composite.resource.spec.coolField }}\n resourceTier: premium\n" + +XR: XNopResource.ns.diff.example.org default/another-resource + [~] XDownstreamResource.ns.nop.example.org default/another-resource (update) + spec: + forProvider: + ~ configData: "another-existing-value" -> "updated-another-existing-value" + ~ resourceTier: "basic" -> "premium" + +XR: XNopResource.ns.diff.example.org default/test-resource + [~] XDownstreamResource.ns.nop.example.org default/test-resource (update) + spec: + forProvider: + ~ configData: "existing-value" -> "updated-existing-value" + ~ resourceTier: "basic" -> "premium" + +Plan: 0 to add, 3 to change, 0 to destroy. + +Note: 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.", + compArgv: true, + args: append([]string{"-o", "json"}, compArgs...), + wantResult: &planShape{ + Summary: plan.Summary{Update: 3, NoOp: 2}, + Composites: []simulate.CompositeResult{{Ref: another, Status: simulate.CompositeChanged, Composition: &comp}, {Ref: xnop, Status: simulate.CompositeChanged, Composition: &comp}}, + Compositions: []simulate.CompositionResult{{ + Ref: comp, + RevisionImpact: &simulate.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2}, + }}, + Plans: []planEntry{ + {Kind: "Composition", Name: comp.Name, Action: plan.ActionUpdate}, + {Kind: "XDownstreamResource", Name: "another-resource", Action: plan.ActionUpdate, Composite: "another-resource"}, + {Kind: "XNopResource", Name: "another-resource", Action: plan.ActionNoOp, Composite: "another-resource"}, + {Kind: "XDownstreamResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + {Kind: "XNopResource", Name: "test-resource", Action: plan.ActionNoOp, Composite: "test-resource"}, + }, + }, + }, "NameRepeatedScopesToEach": { reason: "repeated --name with --namespace scopes the run to each named composite, as the unscoped run covers both.", args: []string{"-o", "json", "-n", "default", "--name=another-resource", "--name=test-resource"}, @@ -113,6 +174,9 @@ func TestSimulatePlanOutput(t *testing.T) { var stdout, stderr bytes.Buffer argv := append(plainProjectArgv(t), tc.args...) + if tc.compArgv { + argv = append([]string{"composition", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) + } code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) if err != nil { t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) @@ -121,6 +185,13 @@ func TestSimulatePlanOutput(t *testing.T) { t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, dp.ExitCodeDiffDetected, code) } + if tc.wantResult == nil { + if diff := cmp.Diff(tc.wantText, stdout.String()); diff != "" { + t.Errorf("\n%s\nsimulate -o plan: -want, +got:\n%s", tc.reason, diff) + } + return + } + var res simulate.ProjectResult 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()) @@ -183,6 +254,130 @@ func integrationEnvGap() string { return "" } +// TestSimulatePinnedChangedFunction runs a project simulate whose Manual XR +// is pinned to its Composition's current revision. The project embeds +// example-itprojectrender, and the cluster's Function of that name is another +// package, so the build marks it changed: a pinned revision that calls it +// renders the XR against that revision; one that does not leaves it deferred. +func TestSimulatePinnedChangedFunction(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + reason string + // composition is the cluster's copy of the project's Composition, + // whose current revision the XR is pinned to. + composition string + // wantDeferred is whether the XR stays deferred; otherwise it is + // rendered against its pinned revision. + wantDeferred bool + }{ + "PinnedXRWithChangedFunctionRendersPinnedRevision": { + reason: "A Manual XR whose pinned revision calls a changed function is rendered against that revision, not deferred.", + composition: "testdata/project/embedded/apis/composition.yaml", + }, + "PinnedXRWithUnchangedFunctionDeferred": { + reason: "A Manual XR whose pinned revision calls no changed function stays deferred.", + composition: "testdata/comp/resources/original-composition.yaml", + wantDeferred: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + cluster := integration.Start(t, integration.Setup{ + CRDDirs: []string{"testdata/comp/crds"}, + SetupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + tc.composition, + "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", + ), + }, + }) + cfg := cluster.Config + + // The harness pins the XR to its Composition's current revision. + xr := &un.Unstructured{} + xr.SetAPIVersion("ns.diff.example.org/v1alpha1") + xr.SetKind("XNopResource") + if err := cluster.Client.Get(cluster.Context, client.ObjectKey{Namespace: "default", Name: "manual-resource"}, xr); err != nil { + t.Fatalf("cannot get the pinned XR: %v", err) + } + rev, _, _ := un.NestedString(xr.Object, "spec", "crossplane", "compositionRevisionRef", "name") + + base := withRenderBinary([]string{ + "project", "simulate", "--timeout=" + defaultTimeout.String(), + "-f", copyProjectFixture(t, "embedded"), + "--cache-dir=" + t.TempDir(), "--skip-plan", "-n", "default", + }, localCrossplaneBinary(t)) + + var stdout, stderr bytes.Buffer + code, err := 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 + 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()) + } + if len(res.Composites) != 1 { + t.Fatalf("\n%s\nsimulate -o json: want 1 composite, got %+v", tc.reason, res.Composites) + } + c := res.Composites[0] + + if tc.wantDeferred { + if c.Status != simulate.CompositeDeferred || strings.HasPrefix(c.Detail, "rendered against") { + t.Errorf("\n%s\nsimulate -o json: want a deferred pinned composite, got %+v", tc.reason, c) + } + return + } + + if code != dp.ExitCodeDiffDetected { + t.Errorf("\n%s\nsimulate: want exit code %d, got %d", tc.reason, dp.ExitCodeDiffDetected, code) + } + if c.Status != simulate.CompositeChanged || !c.Pinned || c.Detail != "rendered against pinned revision "+rev+" with the changed functions" { + t.Errorf("\n%s\nsimulate -o json: want a changed pinned composite rendered against %s, got %+v", tc.reason, rev, c) + } + // The plan is rendered by the project's build of the function the + // pinned revision calls, which prefixes configData with "embedded-". + var found bool + for _, p := range res.Plans { + for _, ch := range p.Changes { + if ch.Path == "spec.forProvider.configData" && ch.Planned != nil && ch.Planned.Value == "embedded-manual-existing-value" { + found = true + } + } + } + if !found { + t.Errorf("\n%s\nsimulate -o json: want a configData change to the embedded function's output, got plans %+v", tc.reason, res.Plans) + } + + var text bytes.Buffer + if _, err := runSimulateArgvWith(t, cfg, base, &simulate.Hooks{}, &text, &stderr); err != nil { + t.Fatalf("simulate -o plan: unexpected error: %v", err) + } + if os.Getenv("SIMULATE_DEMO") != "" { + j, _ := json.MarshalIndent(c, "", " ") + t.Logf("-o plan:\n%s\n-o json composite:\n%s", text.String(), j) + } + if !strings.Contains(text.String(), "rendered against pinned revision "+rev) || strings.Contains(text.String(), "(deferred)") { + t.Errorf("\n%s\nsimulate -o plan: want the rendered-against note and no deferral, got:\n%s", tc.reason, text.String()) + } + }) + } +} + // TestSimulateNameRejected runs the --name scopes simulate refuses: one // naming a composite no supplied Composition reaches, and a namespace/name // combined with --namespace. diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index ac237b65..ceca5659 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -16,12 +16,24 @@ See the License for the specific language governing permissions and limitations under the License. */ -// This file is the simulate harness: runSimulateArgvWith runs the real -// `crossplane project simulate` command through kong against a case's -// envtest cluster, started by internal/testutils/integration. A case that -// the environment cannot run (no Docker daemon) skips with an env:. +// This file 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 +// kong against the case's envtest cluster, started by +// internal/testutils/integration, so no case table changes. // -// testdata/README.md describes how to run the suite and read its coverage. +// # Environment skips +// +// A case the environment lacks something for is skipped with an +// env:. The render engine runs functions as Docker containers, so +// without a Docker daemon no rendering case can run; cases that would not +// render cannot be told apart upfront, so they skip too. +// +// testdata/README.md describes how a case runs, the skips, and how to run +// the suite and read its coverage. package project @@ -41,8 +53,10 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/cli/v2/cmd/crossplane/composition" "github.com/crossplane/cli/v2/internal/config" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + "github.com/crossplane/cli/v2/internal/diff/ref" tu "github.com/crossplane/cli/v2/internal/diff/testutils" "github.com/crossplane/cli/v2/internal/docker" "github.com/crossplane/cli/v2/internal/simulate" @@ -53,8 +67,118 @@ import ( // defaultTimeout is the --timeout the cases pass to simulate. const defaultTimeout = 60 * time.Second -// runSimulateArgv runs `crossplane project simulate` with argv against cfg, -// rendering with renderBinary when it is set. +// 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. +type simArgs struct { + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + CrossplaneRenderBinary string `help:"Local crossplane binary to render with." name:"crossplane-render-binary"` + Namespace string `help:"Namespace of the composite resources."` + Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` + NoColor bool `help:"Disable colorized output." name:"no-color"` + IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` + EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` + Resource []string `help:"Composite resources to scope to." sep:","` + IncludeManual bool `help:"Include composites with a manual update policy." name:"include-manual"` + AnalyzeUnchanged bool `help:"Analyze compositions identical to their in-cluster version." name:"analyze-unchanged"` + AnalyzeOn string `help:"Smallest composition change that triggers impact analysis." name:"analyze-on"` + Files []string `arg:"" help:"Input files." optional:""` +} + +// parseSimulateArgs parses one case's CLI-style args. A parse failure is +// fatal, as it was when kong parsed crossplane-diff's own commands. +func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simArgs { + t.Helper() + + var a simArgs + parser, err := kong.New(&a, kong.Name(string(testType))) + if err != nil { + t.Fatalf("failed to create kong parser: %v", err) + } + + if _, err := parser.Parse(args); err != nil { + t.Fatalf("failed to parse args %v: %v", args, err) + } + + return &a +} + +// simulateArgv translates the parsed crossplane-diff args into composition +// 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 { + t.Helper() + + // NOTE: only Composition cases run here; xr simulate adds its type. + if testType != CompositionDiffTest { + t.Fatalf("simulateArgv: no simulate command for test type %q", testType) + } + argv := []string{ + "composition", "simulate", + "--timeout=" + a.Timeout.String(), + "--output=" + a.Output, + "--dry-run-on=" + a.DryRunOn, + } + for _, f := range a.Files { + argv = append(argv, "-f", f) + } + + for _, v := range a.Resource { + r, err := ref.Parse(v) + if err != nil { + t.Fatalf("cannot parse --resource %q: %v", v, err) + } + argv = append(argv, "--name="+ref.Format(r)) + } + ns := a.Namespace + if ns != "" { + argv = append(argv, "--namespace="+ns) + } + + if a.NoColor { + argv = append(argv, "--no-color") + } + for _, p := range a.IgnorePaths { + argv = append(argv, "--ignore-paths="+p) + } + if a.EventualState { + argv = append(argv, "--eventual-state") + } + if a.IncludeManual { + argv = append(argv, "--include-pinned") + } + + switch { + case a.AnalyzeOn != "": + argv = append(argv, "--analyze-on="+a.AnalyzeOn) + case a.AnalyzeUnchanged: + argv = append(argv, "--analyze-on=always") + } + + return argv +} + +// runSimulate runs one case's args through the real `crossplane +// composition 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) { + t.Helper() + + a := parseSimulateArgs(t, testType, args) + + return runSimulateArgv(t, cfg, simulateArgv(t, testType, a), a.CrossplaneRenderBinary, 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) { t.Helper() @@ -70,13 +194,14 @@ func withRenderBinary(argv []string, bin string) []string { return append(slices.Clip(argv), "--crossplane-binary="+bin) } -// runSimulateArgvWith runs `crossplane project 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) { t.Helper() var cli struct { - Project Cmd `cmd:""` + Project Cmd `cmd:""` + Composition composition.Cmd `cmd:""` } parser, err := kong.New(&cli, kong.Name("crossplane"), @@ -111,6 +236,7 @@ func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *s hooks.RESTConfig = cfg hooks.Terminal = true cli.Project.Simulate.Hooks = hooks + cli.Composition.Simulate.Hooks = hooks err = kctx.Run() // Exit as main does: a silent ExitError, such as changes found, exits diff --git a/cmd/crossplane/project/integration_test.go b/cmd/crossplane/project/integration_test.go new file mode 100644 index 00000000..9da6d1ca --- /dev/null +++ b/cmd/crossplane/project/integration_test.go @@ -0,0 +1,2828 @@ +//go:build integration + +package project + +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" +) + +// 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. +func TestCompDiffIntegration(t *testing.T) { + t.Parallel() + + tests := map[string]IntegrationTestCase{ + "CompositionChangeImpactsXRs": { + reason: "Validates composition change impacts existing XRs", + // Set up existing XRs that use the original composition + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use the composition + crossplaneManagedResources: []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", + ), + }, + // New composition files that will be diffed + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/another-resource (namespace: default) + ⚠ XNopResource/test-resource (namespace: default) + +Summary: 2 resources with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/another-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: another-resource + name: another-resource + namespace: default + spec: + forProvider: +- configData: another-existing-value +- resourceTier: basic ++ configData: updated-another-existing-value ++ resourceTier: premium + +--- +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Issue #452: an XR with a deletionTimestamp is on Crossplane's teardown path and will never + // adopt the diffed composition's revision. Rendering it yields no composed resources (the + // composite reconciler takes its deletion path), so including it produces a meaningless + // impact analysis. It must be excluded and counted, leaving only the live XR evaluated. + "CompositionDiffExcludesDeletingXRs": { + reason: "A deleting XR is excluded from impact analysis and counted as filteredByDeletion; the live XR is still evaluated", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-xr-1.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-1.yaml": nil, + }, + }, + { + OwnerFile: "testdata/comp/resources/existing-xr-deleting.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-deleting.yaml": nil, + }, + }, + }, + deleteAfterSetup: []string{"testdata/comp/resources/existing-xr-deleting.yaml"}, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed + // Total counts both discovered XRs; only the live one is evaluated (1 with changes), and + // the deleting one is reported via filteredByDeletion. In default-discovery mode filtered + // XRs are counted but not surfaced as impact entries, so only test-resource appears. + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 1, 0, 0). + WithFilteredByDeletion(1). + WithXRImpact("XNopResource", "test-resource", "default", "changed"), + }, + // Issue #452, --resource mode: filtered XRs are surfaced explicitly there, so a user who names + // a deleting composite is told why it was skipped rather than getting silence. + "ResourceFilterSurfacesDeletingXR": { + reason: "--resource matching a deleting composite surfaces it as filtered with reason deleting", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-xr-deleting.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-deleting.yaml": nil, + }, + }, + }, + deleteAfterSetup: []string{"testdata/comp/resources/existing-xr-deleting.yaml"}, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/deleting-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // the composition itself changed + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithFilteredByDeletion(1). + WithXRImpact("XNopResource", "deleting-resource", "default", "filtered"). + WithFilterReason("deleting"), + }, + // Issue #495, `comp` side: impact analysis renders each affected XR through the same observed- + // resource fetch as `xr`, so a composed resource deleted out of band is tolerated with the same + // warning. Being genuinely absent, it is then reported as something the XR will create. + "CompositionDiffComposedResourceDeletedOutOfBandWarns": { + reason: "comp against an XR whose resourceRefs name a deleted composed resource warns and reports it as an addition", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-xr-1.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-downstream-1.yaml": nil, + }, + }, + }, + deleteAfterSetup: []string{"testdata/comp/resources/existing-downstream-1.yaml"}, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithWarning("was not found in the cluster"). + WithWarningContext(map[string]string{ + "resource": "XDownstreamResource/test-resource", + "namespace": "default", + }). + And(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(1, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"), + }, + // Regression test for #552: + // the updated composition stops rendering resource2, which the XR controls, so it is removed; + // the XR's resourceRefs also name a resource another XR controls and one with no controller, + // which the composition doesn't render either, and neither of those is removed. + "CompositionDropsResourceRemovingOnlyWhatTheXRControls": { + reason: "A composition that stops rendering resources removes only those the XR controls", + // What this case pins: test-resource's resourceRefs still name two resources it doesn't control. + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/removal-test-original-composition.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/comp/resources/functions.yaml", + // Deliberately not in crossplaneManagedResources: its controller reference was removed, so + // no XR controls it, though test-resource's resourceRefs still name it. + "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", + }, + crossplaneManagedResources: []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 + ), + integration.Composes( + "testdata/diff/resources/existing-other-xr.yaml", + "testdata/diff/resources/existing-other-xr-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/resources/removal-test-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("removal-test-composition"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(0, 0, 1). + WithDownstreamResource("removed", "XDownstreamResource", "resource-to-be-removed", "default"), + }, + "CompositionDiffIgnorePaths": { + reason: "Validates that ArgoCD annotations are ignored in composition diffs", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XR with ArgoCD annotations + crossplaneManagedResources: []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", + // The composition is unchanged, so impact analysis would be skipped by default (issue + // #453). This case is about XR/downstream-level ignore paths, so it opts back in. + analyzeUnchanged: true, + ignorePaths: []string{ + "metadata.annotations[argocd.argoproj.io/tracking-id]", + "metadata.labels[argocd.argoproj.io/instance]", + }, + expectedOutput: ` +=== Composition Changes === + +No changes detected in composition xnopresources.diff.example.org + +=== Affected Composite Resources === + + ✓ XNopResource/test-resource (namespace: default) + +Summary: 1 resource unchanged + +=== Impact Analysis === + +All composite resources are up-to-date. No downstream resource changes detected. + +`, + expectedError: false, + noColor: true, + }, + // Issue #453: any revision an unchanged composition produced would carry the same spec, so no XR + // renders differently and the affected-XR / impact-analysis sections are replaced by a skip note. + // + // These fixtures are deliberately ones that DO produce a downstream delta when evaluated (see + // UnchangedCompositionAnalyzeUnchangedEvaluatesXRs, which asserts exactly that against the same + // setup). So this case proves two things at once: the skip suppresses a report that would + // otherwise be emitted, and the exit code is 0 rather than the 3 that report would have caused + // — a delta not attributable to any composition change must not gate a CI pipeline. + "UnchangedCompositionSkipsImpactAnalysis": { + reason: "An unchanged composition skips impact analysis entirely and says so", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +No changes detected in composition xnopresources.diff.example.org + +Impact analysis skipped: this composition is identical to the cluster's, so applying it creates no new CompositionRevision and no composite resource could change as a result. Pass --analyze-on=always to evaluate them anyway. + +`, + expectedError: false, + // No composition change and no evaluated XRs, so nothing was detected. + expectedExitCode: dp.ExitCodeSuccess, + noColor: true, + }, + // Issue #467: the cluster composition was applied with a client-side `kubectl apply`, so it + // carries kubectl.kubernetes.io/last-applied-configuration; the file being diffed never does. + // That annotation is suppressed from the rendered diff — it is a multi-KB serialization of the + // object, useless to show — but Crossplane's Composition.Hash() covers annotations, so applying + // this DOES create a new CompositionRevision that composites re-point to, and whose name a + // template can read off the XR's compositionRevisionRef. A suppression made for readability + // must not decide that away, so the composites are evaluated. Contrast the sibling above, which + // is byte-identical and needs --analyze-unchanged to evaluate anything. + // + // This runs the real CLI wiring, which the unit coverage in + // TestDefaultCompDiffProcessor_calculateCompositionDiff cannot reach: it pins that + // defaultProcessorOptions does not fold the annotation into --ignore-paths, and that the + // display-only suppression does not leak into the change verdict. + // + // The evaluated composite renders unchanged, because the new revision's spec is identical, so + // nothing renders differently and the exit code is 0 (as with AnalyzeOnSpecChangeSkipsMetadataOnlyChange). + // What proves evaluation is that the composite is counted, rather than impact analysis being skipped. + "CompositionAppliedWithKubectlEvaluatesXRs": { + reason: "A composition differing only in kubectl's last-applied-configuration still counts as changed, because applying it creates a new CompositionRevision", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition-kubectl-applied.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1-kubectl-applied.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStderrContains: []string{ + // Issue #474: the composites are still evaluated, but they are rendered with their existing + // compositionRevisionRef, because the name of the revision this would create cannot be + // predicted for a client-side-applied composition. Saying so is the honest position: if this + // composition's template read the revision name, the resulting change would go undetected. + "Could not predict the name of the CompositionRevision", + }, + 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"). + WithWarningContext(map[string]string{"composition": "xnopresources.diff.example.org"}). + And(). + WithComposition("xnopresources.diff.example.org"). + // createsRevision stays true while the name is withheld: the annotation delta is real, and a + // stale annotation genuinely would be rewritten and mint a revision. Only the identity is + // unknowable, which is why the guard makes the weak claim rather than "no revision". + WithRevisionImpact("metadata", true, 1). + WithoutPredictedRevisionName(). + WithAffectedResources(1, 0, 1, 0). + WithXRImpact("XNopResource", "test-resource", "default", "unchanged"), + }, + // Issue #500: the same asymmetry, but the cluster's last-applied-configuration is the one a + // client-side `kubectl apply` of this very file would write. Re-applying it computes the same + // annotation, the patch is empty and kubectl writes nothing, so no CompositionRevision is created. + // The sibling above differs only in that its annotation is stale, which a re-apply does rewrite. + "CompositionReappliedWithKubectlCreatesNoRevision": { + reason: "A composition whose live last-applied-configuration already describes the file is unchanged, because re-applying it writes nothing", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition-kubectl-applied-current.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStructured: simtest.Expect(). + WithNoWarnings(). + WithComposition("xnopresources.diff.example.org"). + WithImpactAnalysisSkipped(). + WithRevisionImpact("none", false, 0), + }, + // Issue #474: the case that makes "creates a revision but renders identically" a claim to be + // verified rather than assumed. The composition edit is metadata-only — one added label, identical + // spec — so the CompositionRevision it mints carries a byte-identical spec. But the template reads + // the revision's own name off the composite and propagates it into the composed resource, so the + // rendered output genuinely changes. + // + // Before the fix, the composite was rendered with its EXISTING compositionRevisionRef: configData + // came out as the old revision's name, matched the cluster, and the tool reported unchanged with + // exit 0. Seeding the predicted ref is what turns that into the exit 3 below. This is the + // regression test for the whole feature — stop comp's composition provider resolving the predicted + // revision, or stop the processor writing the resolved revision to the ref, and this is what fails. + // + // Note also what is NOT in the output: the composite's own + // spec.crossplane.compositionRevisionRef diff. The predicted ref is written only to the render input, + // never to the composite's dry-run payload, which is why the exit code reflects rendering rather than + // bookkeeping; predictedRevisionName is what keeps the downstream change interpretable without it. + "RevisionNamePropagatesToComposedResource": { + reason: "A metadata-only composition edit whose template reads the CompositionRevision name changes rendered output, and is detected rather than assumed away", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-revision-ref.yaml", + ), + }, + inputFiles: []string{"testdata/comp/revision-templating-updated-composition.yaml"}, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + // The name was predictable, so the could-not-predict advisory must stay silent. + WithNoWarnings(). + WithComposition("xrevisionrefs.diff.example.org"). + WithRevisionImpact("metadata", true, 1). + // Matched by pattern, not literally: the suffix is the first 7 hex digits of the + // composition's hash, so pinning it would make any edit to the fixture a test failure. The + // derivation is pinned exactly by TestRevisionIdentity, which is where that belongs. + WithPredictedRevisionNamePattern(`^xrevisionrefs\.diff\.example\.org-[0-9a-f]{7}$`). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "revision-ref-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "revision-ref-resource", "default"). + // The old value is the revision the composite currently tracks; the new one is the predicted + // revision, i.e. the thing this whole feature exists to surface. Both are real revision names, + // so both match the pattern above: only the exact values tell them apart. + WithFieldChange("spec.forProvider.configData", + "xrevisionrefs.diff.example.org-e36719b", "xrevisionrefs.diff.example.org-ec5496c"), + }, + // Issue #536 / #479: the same edit with --include-manual, which keeps a Manual composite for analysis + // without making it adopt the new revision. Only the Automatic composite is rendered with the + // predicted revision name; the Manual one stays pinned, so its template-read name, and with it its + // composed resource, is unchanged. Giving every composite the name would report the pinned one as + // changing to a revision it never uses. + "RevisionNameReachesOnlyRepointingCompositesWithIncludeManual": { + reason: "Under --include-manual, the predicted revision name reaches an Automatic composite but not a Manual one pinned to an older revision", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/comp/resources/revision-templating-composition-older-revision.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-revision-ref.yaml", + "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(). + WithNoWarnings(). + WithComposition("xrevisionrefs.diff.example.org"). + // One composite re-points; the Manual one is kept but adopts nothing. + WithRevisionImpact("metadata", true, 1). + WithPredictedRevisionNamePattern(`^xrevisionrefs\.diff\.example\.org-[0-9a-f]{7}$`). + WithAffectedResources(2, 1, 1, 0). + WithXRImpact("XNopResource", "pinned-revision-ref-resource", "default", "unchanged"). + WithDownstreamSummary(0, 0, 0). + AndComp(). + WithXRImpact("XNopResource", "revision-ref-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "revision-ref-resource", "default"). + WithFieldChange("spec.forProvider.configData", + "xrevisionrefs.diff.example.org-e36719b", "xrevisionrefs.diff.example.org-ec5496c"), + }, + // Issue #498, the claim variant of RevisionNamePropagatesToComposedResource. A claim is rendered from its backing XR with the + // claim's spec synced in. Under Automatic the claim's own ref is never propagated (the XR controller + // owns the backing XR's), so seeding the claim would reach nothing: comp's composition provider is + // handed the backing XR the render consumes, and resolves that to the predicted revision. Without it, + // the backing XR's stale ref renders and the change goes unreported. + "ClaimRevisionNamePropagatesToComposedResource": { + reason: "A metadata-only composition edit is rendered for an Automatic claim with the revision its backing XR would re-point to", + setupFiles: []string{ + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-revision-templating-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/test-namespace.yaml", + "testdata/comp/resources/existing-claim-revision-ref.yaml", + }, + crossplaneManagedResources: []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(). + // The claim's composed resource is labelled with its backing XR's name, which is the claim's + // owner, so no "already belongs to another composite" advisory is raised (#534). + WithNoWarnings(). + WithComposition("xrevisionrefclaims.diff.example.org"). + WithRevisionImpact("metadata", true, 1). + WithPredictedRevisionNamePattern(`^xrevisionrefclaims\.diff\.example\.org-[0-9a-f]{7}$`). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("NopClaim", "revision-ref-claim", "test-namespace", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "revision-ref-claim-xr", ""). + // The old value is the revision the backing XR tracks now; the new one is the predicted name. + WithFieldChange("spec.forProvider.configData", + "xrevisionrefclaims.diff.example.org-98b484d", "xrevisionrefclaims.diff.example.org-55c3a3b"), + }, + // Issue #498, the other half: when nothing is seeded, an Automatic claim renders against the + // revision its backing XR tracks now, not against no revision at all. Nothing is seeded here + // because the composition differs from the cluster's only by a stale kubectl annotation, so the + // name of the revision that would mint is unknowable (see CompositionAppliedWithKubectlEvaluatesXRs); + // the claim is still rendered, so the backing XR's ref is what the template reads. + "ClaimRendersWithBackingXRRevisionWhenNothingIsSeeded": { + reason: "An Automatic claim with nothing seeded renders with its backing XR's compositionRevisionRef, so a template reading the revision name shows no spurious change", + setupFiles: []string{ + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/test-namespace.yaml", + "testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml", + }, + crossplaneManagedResources: []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(). + // The could-not-predict advisory is the only warning: the claim's composed resource is labelled + // with its backing XR's name, so it raises no ownership advisory (#534). + WithWarning("Could not predict the name of the CompositionRevision"). + WithWarningContext(map[string]string{"composition": "xrevisionrefclaims.diff.example.org"}). + And(). + WithComposition("xrevisionrefclaims.diff.example.org"). + WithRevisionImpact("metadata", true, 1). + WithoutPredictedRevisionName(). + WithAffectedResources(1, 0, 1, 0). + WithXRImpact("NopClaim", "revision-ref-claim", "test-namespace", "unchanged"). + WithDownstreamSummary(0, 0, 0), + }, + // Issue #472: the same metadata-only change, with the user opting out of paying a render per + // composite for it. The composites go unevaluated — but the mutative consequence is still + // reported, which is what keeps --analyze-on a cost knob rather than a correctness mode. So the + // skip is recorded, and revisionImpact says a CompositionRevision is created and one composite + // would adopt it. Exit code 0: nothing renders differently, and a GitOps loop re-applying the + // same manifests must not fail its gate forever. + "AnalyzeOnSpecChangeSkipsMetadataOnlyChange": { + reason: "--analyze-on=spec-change leaves a metadata-only change unevaluated, while still reporting that it creates a CompositionRevision", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition-kubectl-applied.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1-kubectl-applied.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + analyzeOn: "spec-change", + outputFormat: "json", + expectedExitCode: dp.ExitCodeSuccess, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithImpactAnalysisSkipped(). + WithRevisionImpact("metadata", true, 1). + // Issue #474: no predicted name here, and that absence is the point. The compositions + // differ by nothing but kubectl's last-applied-configuration, whose post-apply value is a + // function of how the user applies rather than of the file — so the hash, and therefore the + // revision's name, is unknowable. createsRevision stays true; only the identity is withheld. + WithoutPredictedRevisionName(), + }, + // The same setup with --analyze-unchanged evaluates the XRs after all (the pre-edit + // convergence-baseline workflow). Here the composed resource has drifted out of band, so the + // evaluation reports a downstream modification even though the composition is byte-identical + // to the cluster's. That delta is not caused by this composition — it is exactly the class of + // finding the default skip keeps out of the "impact of your composition change" report, and why + // opting in is explicit. + "UnchangedCompositionAnalyzeUnchangedEvaluatesXRs": { + reason: "--analyze-unchanged evaluates affected XRs even though the composition is unchanged, surfacing deltas not caused by it", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-drifted.yaml", + ), + }, + inputFiles: []string{"testdata/comp/composition-no-changes.yaml"}, + namespace: "default", + analyzeUnchanged: true, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "drifted-value", "existing-value"), + }, + "CompositionDiffCustomNamespace": { + reason: "Validates composition diff with custom namespace", + // Set up existing XRs in both custom and default namespaces to validate filtering + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + // Create the custom namespace first + "testdata/comp/resources/custom-namespace.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + // Add existing XRs in custom namespace (should be included in output) + integration.Composes( + "testdata/comp/resources/existing-custom-ns-xr.yaml", + "testdata/comp/resources/existing-custom-ns-downstream.yaml", + ), + // Add existing XRs in default namespace (should be filtered out) + integration.Composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + // New composition files that will be diffed + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "custom-namespace", + // Expected output should only show custom-namespace resources, NOT default namespace resources + // This validates that namespace filtering works correctly + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/custom-namespace-resource (namespace: custom-namespace) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/custom-namespace-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: custom-namespace-resource + name: custom-namespace-resource + namespace: custom-namespace + spec: + forProvider: +- configData: custom-ns-existing-value +- resourceTier: basic ++ configData: updated-custom-ns-existing-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Two compositions that BOTH genuinely change, each with its own affected XR. This is the + // aggregation case: one CompositionDiff entry per input, each carrying its own impact analysis, + // with hasDiffs ORed across them. MultipleCompositionDiffImpact below covers the mixed + // changed/unchanged variant; before this case existed, nothing covered two compositions that + // both report impacts — the second composition there has no affected XR at all. + "MultipleChangedCompositionsBothReportImpact": { + reason: "Two changed compositions each report their own affected XR's downstream changes", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/original-composition-2.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + // XR using composition 1 + integration.Composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + // XR using composition 2 + integration.Composes( + "testdata/comp/resources/existing-xr-v2.yaml", + "testdata/comp/resources/existing-downstream-v2.yaml", + ), + }, + inputFiles: []string{ + "testdata/comp/updated-composition.yaml", + "testdata/comp/updated-composition-2-changed.yaml", + }, + namespace: "default", + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"). + WithFieldChange("spec.forProvider.resourceTier", "basic", "premium"). + AndComposition("xnopresources-v2.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "v2-resource", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "v2-resource", "default"). + WithFieldChange("spec.forProvider.configData", "v2-updated-v2-existing-value", "v2-changed-v2-existing-value"). + WithFieldChange("spec.forProvider.resourceTier", "enterprise", "premium-plus"), + }, + // Note the second input composition (xnopresources-v2.diff.example.org) is byte-identical to its + // in-cluster version, so this case doubles as the mixed changed/unchanged scenario: the changed + // composition reports its impact while the unchanged one is skipped per-composition (issue + // #453). It is not a broken fixture. + "MultipleCompositionDiffImpact": { + reason: "Validates multiple compositions in one invocation: the changed one reports impact on existing XRs, the unchanged one is skipped", + // Set up existing XRs that use both compositions + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/original-composition-2.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use the compositions + crossplaneManagedResources: []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", + ), + }, + // Multiple composition files that will be diffed + inputFiles: []string{ + "testdata/comp/updated-composition.yaml", + "testdata/comp/updated-composition-2.yaml", + }, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/another-resource (namespace: default) + ⚠ XNopResource/test-resource (namespace: default) + +Summary: 2 resources with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/another-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: another-resource + name: another-resource + namespace: default + spec: + forProvider: +- configData: another-existing-value +- resourceTier: basic ++ configData: updated-another-existing-value ++ resourceTier: premium + +--- +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 2 modified + +================================================================================ + +=== Composition Changes === + +No changes detected in composition xnopresources-v2.diff.example.org + +Impact analysis skipped: this composition is identical to the cluster's, so applying it creates no new CompositionRevision and no composite resource could change as a result. Pass --analyze-on=always to evaluate them anyway. +`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionDiffFiltersManualXRs": { + reason: "Validates composition diff filters Manual policy XRs by default", + // Set up existing XRs - one with Automatic policy and one with Manual policy + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []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", + ), + // 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", + ), + }, + // Updated composition + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + // Expected output should only show test-resource (Automatic), NOT manual-resource (Manual) + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/test-resource (namespace: default) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionUpgradesResourceAPIVersion": { + // NOTE: This test validates that resources are correctly matched across API version changes, + // avoiding delete/recreate. The composition template changes from v1beta1 to v1beta2, but + // Kubernetes automatically converts resources between served API versions. When we query for + // the v1beta2 resource, Kubernetes finds the v1beta1 resource and returns it auto-converted + // to v1beta2. From Kubernetes' perspective, the resource exists as both versions simultaneously, + // so there's no apiVersion field change to show in the diff. The important thing is that the + // resource is matched, preventing delete/recreate operations: since the stored object is + // identical at either version it renders unchanged, where an unmatched one would show as a + // +++ addition and a --- removal. The composition diff itself WILL show the template change + // from v1beta1 to v1beta2. + // + // The XR composes the + // resource as Crossplane leaves it, named in its resourceRefs at v1beta1, so this also checks + // that it is not reported as removed (#552). + reason: "Validates composition upgrade that changes resource API version shows as update not remove/add", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + // NOTE: xapimigrate CRD is auto-loaded from testdata/comp/crds/ + // We apply no XRD for XApiMigrateResource because it is a regular managed resource + // (not a composite), and an XRD would make it composite, causing infinite recursion. + "testdata/comp/resources/api-version-original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/existing-api-version-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/comp/api-version-updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xapimigrateresources.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xapimigrateresources.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | +- apiVersion: comp.example.org/v1beta1 ++ apiVersion: comp.example.org/v1beta2 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + kind: GoTemplate + source: Inline + step: generate-api-versioned-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ✓ XNopResource/test-api-version (namespace: default) + +Summary: 1 resource unchanged + +=== Impact Analysis === + +All composite resources are up-to-date. No downstream resource changes detected. +`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "NetNewCompositionNoImpact": { + reason: "Validates net-new composition with no downstream impact", + // Set up cluster with existing resources but no composition that matches the new one + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use different compositions (won't be affected) + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + // Net-new composition file that doesn't exist in cluster and targets different XR type + inputFiles: []string{"testdata/comp/net-new-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + ++++ Composition/xnewresources.diff.example.org ++ apiVersion: apiextensions.crossplane.io/v1 ++ kind: Composition ++ metadata: ++ labels: ++ environment: staging ++ provider: aws ++ name: xnewresources.diff.example.org ++ spec: ++ compositeTypeRef: ++ apiVersion: staging.diff.example.org/v1alpha1 ++ kind: XNewResource ++ mode: Pipeline ++ pipeline: ++ - functionRef: ++ name: function-go-templating ++ input: ++ apiVersion: template.fn.crossplane.io/v1beta1 ++ inline: ++ template: | ++ apiVersion: staging.nop.example.org/v1alpha1 ++ kind: XStagingResource ++ metadata: ++ name: {{ .observed.composite.resource.metadata.name }} ++ namespace: {{ .observed.composite.resource.metadata.namespace }} ++ annotations: ++ gotemplating.fn.crossplane.io/composition-resource-name: staging-resource ++ spec: ++ forProvider: ++ environment: staging ++ configData: new-{{ .observed.composite.resource.spec.newField }} ++ resourceTier: premium ++ kind: GoTemplate ++ source: Inline ++ step: generate-new-resources ++ - functionRef: ++ name: function-auto-ready ++ step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 added + +No XRs found using composition xnewresources.diff.example.org`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionChangeWithUnchangedDownstreamResources": { + reason: "Validates status indicators for unchanged downstream resources", + // Set up existing XRs that use the original composition + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/status-indicator-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + // Add existing XRs that use the composition + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/status-xr-1.yaml", + "testdata/comp/resources/status-downstream-1.yaml", + ), + integration.Composes( + "testdata/comp/resources/status-xr-2.yaml", + "testdata/comp/resources/status-downstream-2.yaml", + ), + }, + // Updated composition with only metadata change (no downstream impact) + inputFiles: []string{"testdata/comp/status-indicator-updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xstatus.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: ++ labels: ++ environment: production + name: xstatus.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-value + resourceTier: standard + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ✓ XNopResource/status-test-xr-1 (namespace: default) + ✓ XNopResource/status-test-xr-2 (namespace: default) + +Summary: 2 resources unchanged + +=== Impact Analysis === + +All composite resources are up-to-date. No downstream resource changes detected.`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CompositionChangeWithMixedStatusAndColors": { + reason: "Validates status indicators with colorization for mixed changed/unchanged XRs", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/mixed-status-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + // XR 1 with downstream resources (standard tier - will change) + integration.Composes( + "testdata/comp/resources/mixed-xr-1.yaml", + "testdata/comp/resources/mixed-downstream-db-1.yaml", + "testdata/comp/resources/mixed-downstream-storage-1.yaml", + "testdata/comp/resources/mixed-downstream-network-1.yaml", + ), + // XR 2 with downstream resources (standard tier - will change) + integration.Composes( + "testdata/comp/resources/mixed-xr-2.yaml", + "testdata/comp/resources/mixed-downstream-db-2.yaml", + "testdata/comp/resources/mixed-downstream-storage-2.yaml", + "testdata/comp/resources/mixed-downstream-network-2.yaml", + ), + // XR 3 with downstream resources (already premium tier - no change) + integration.Composes( + "testdata/comp/resources/mixed-xr-3.yaml", + "testdata/comp/resources/mixed-downstream-db-3.yaml", + "testdata/comp/resources/mixed-downstream-storage-3.yaml", + "testdata/comp/resources/mixed-downstream-network-3.yaml", + ), + }, + inputFiles: []string{"testdata/comp/mixed-status-updated-composition.yaml"}, + namespace: "default", + expectedOutput: strings.Join([]string{ + ` +=== Composition Changes === + +~~~ Composition/xmixed.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xmixed.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-database + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + resourceType: database +`, tu.Red(`- tier: standard`), ` +`, tu.Green(`+ tier: premium`), ` +`, tu.Green(`+ backupEnabled: true`), ` + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-storage + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: storage + spec: + forProvider: + resourceType: storage + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-network + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: network + spec: + forProvider: + resourceType: network + tier: standard + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 3 composites would adopt. + +=== Affected Composite Resources === + +`, tu.Yellow(` ⚠ XNopResource/mixed-test-xr-1 (namespace: default)`), ` +`, tu.Yellow(` ⚠ XNopResource/mixed-test-xr-2 (namespace: default)`), ` +`, tu.Green(` ✓ XNopResource/mixed-test-xr-3 (namespace: default)`), ` + +Summary: 2 resources with changes, 1 resource unchanged + +=== Impact Analysis === + +~~~ XDownstreamResource/mixed-test-xr-1-database + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: database + labels: + crossplane.io/composite: mixed-test-xr-1 + name: mixed-test-xr-1-database + namespace: default + spec: + forProvider: +`, tu.Red(`- resourceType: database`), ` +`, tu.Red(`- tier: standard`), ` +`, tu.Green(`+ backupEnabled: true`), ` +`, tu.Green(`+ resourceType: database`), ` +`, tu.Green(`+ tier: premium`), ` + + + +--- +~~~ XDownstreamResource/mixed-test-xr-2-database + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: database + labels: + crossplane.io/composite: mixed-test-xr-2 + name: mixed-test-xr-2-database + namespace: default + spec: + forProvider: +`, tu.Red(`- resourceType: database`), ` +`, tu.Red(`- tier: standard`), ` +`, tu.Green(`+ backupEnabled: true`), ` +`, tu.Green(`+ resourceType: database`), ` +`, tu.Green(`+ tier: premium`), ` + + + +--- + +Summary: 2 modified +`, + }, ""), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: false, + }, + "CompositionChangeImpactsClaims": { + reason: "Validates composition change impacts existing Claims (issue #120)", + // Set up existing Claims and their XRs that use the original composition + setupFiles: []string{ + // XRD, composition, and functions + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-composition.yaml", + "testdata/comp/resources/functions.yaml", + // Test namespace + "testdata/comp/resources/test-namespace.yaml", + // Existing Claims and their corresponding XRs + "testdata/comp/resources/existing-claim-1.yaml", + "testdata/comp/resources/existing-claim-2.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-claim-1-xr.yaml", + "testdata/comp/resources/existing-claim-downstream-1.yaml", + ), + integration.Composes( + "testdata/comp/resources/existing-claim-2-xr.yaml", + "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 + // Updated composition that will be diffed + inputFiles: []string{"testdata/comp/updated-claim-composition.yaml"}, + namespace: "test-namespace", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/nopclaims.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: nopclaims.diff.example.org + spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 2 composites would adopt. + +=== Affected Composite Resources === + + ⚠ NopClaim/test-claim-1 (namespace: test-namespace) + ⚠ NopClaim/test-claim-2 (namespace: test-namespace) + +Summary: 2 resources with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/test-claim-1-xr + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/claim-name: test-claim-1 + crossplane.io/claim-namespace: test-namespace + crossplane.io/composite: test-claim-1-xr + name: test-claim-1-xr + spec: + forProvider: +- configData: claim-value-1 +- resourceTier: basic ++ configData: updated-claim-value-1 ++ resourceTier: premium + +--- +~~~ XDownstreamResource/test-claim-2-xr + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/claim-name: test-claim-2 + crossplane.io/claim-namespace: test-namespace + crossplane.io/composite: test-claim-2-xr + name: test-claim-2-xr + spec: + forProvider: +- configData: claim-value-2 +- resourceTier: basic ++ configData: updated-claim-value-2 ++ resourceTier: premium + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // 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{ + "testdata/comp/resources/claim-xrd.yaml", + "testdata/comp/resources/claim-composition.yaml", + "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{ + integration.Composes( + "testdata/comp/resources/existing-claim-1-xr.yaml", + "testdata/comp/resources/existing-claim-downstream-1.yaml", + ), + integration.Composes( + "testdata/comp/resources/existing-claim-2-xr.yaml", + "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(). + WithComposition("nopclaims.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 2, 0, 0). + WithXRImpact("NopClaim", "test-claim-1", "test-namespace", "changed").AndComp(). + WithXRImpact("NopClaim", "test-claim-2", "test-namespace", "changed"), + }, + // Composition mode checks the composites it renders for overlaps as xr mode checks its inputs: + // 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", + "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 ` + + `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 ` + + `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)", + // Set up XRD, functions, and original composition with the removable field + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/field-removal/composition-with-field.yaml", + }, + // XR and downstream resource applied via SSA with Crossplane field manager + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/field-removal/existing-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/field-removal/existing-downstream.yaml": nil, + }, + }, + }, + // Updated composition that removes the removableField + inputFiles: []string{"testdata/comp/field-removal-updated-composition.yaml"}, + namespace: "default", + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xfieldremoval.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xfieldremoval.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} +- removableField: will-be-removed + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/field-removal-test (namespace: default) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/field-removal-test + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: field-removal-test + name: field-removal-test + namespace: default + spec: + forProvider: + configData: test-value +- removableField: will-be-removed + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "SHA256DigestFunctionReference": { + reason: "Validates composition diff works with functions referenced by SHA256 digest instead of tag", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/sha256-composition.yaml", + "testdata/comp/resources/functions-sha256.yaml", + }, + crossplaneManagedResources: []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: ` +=== Composition Changes === + +~~~ Composition/xnopresources-sha256.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources-sha256.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating-sha256 + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +Applying this composition creates a new CompositionRevision, which 1 composite would adopt. + +=== Affected Composite Resources === + + ⚠ XNopResource/sha256-test-resource (namespace: default) + +Summary: 1 resource with changes + +=== Impact Analysis === + +~~~ XDownstreamResource/sha256-test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: sha256-test-resource + name: sha256-test-resource + namespace: default + spec: + forProvider: +- configData: sha256-value +- resourceTier: basic ++ configData: updated-sha256-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Issue #485, comp's half. comp re-renders each discovered composite through DiffSingleResource, + // the same recursion xr uses, so one fix covers both — this pins that for comp rather than + // assuming it. comp's own error only counts the failures, so the assertion that matters is that + // the depth message reaches the failing composite's impact entry: without it, a user would be + // told something failed but not that the composition recurses into itself. + "CyclicCompositionStopsAtMaxNestedDepth": { + reason: "comp shares xr's recursion path (it enters via DiffSingleResource), so a composition cycle must stop at --max-nested-depth here too, and say so on the failing composite (#485)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/cycle/xrds.yaml", + "testdata/diff/resources/cycle/compositions.yaml", + "testdata/comp/resources/functions.yaml", + // Composed nothing: with a cyclic composition, Crossplane would nest XRs without end, so + // the case models the XR before its first compose rather than an endless tree. + "testdata/comp/resources/cycle/existing-cycle-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-cycle-composition.yaml"}, + namespace: "default", + expectedError: true, + expectedErrorContains: "impact analysis failed for 1 XR(s)", + expectedExitCode: dp.ExitCodeToolError, + expectedStructured: simtest.Expect(). + WithComposition("xcycleas.cycle.example.org"). + WithAffectedResources(1, 0, 0, 1). + WithXRImpact("XCycleA", "test-cycle", "default", "error"). + WithErrorContaining("maximum nesting depth exceeded"), + }, + // 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", + // 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(). + WithErrorID("XCycleA/test-cycle"). + WithComposition("xcycleas.cycle.example.org"). + WithAffectedResources(1, 0, 0, 1). + WithXRImpact("XCycleA", "test-cycle", "default", "error"). + WithErrorContaining("maximum nesting depth exceeded"). + WithErrorResourceID("XCycleA/test-cycle"), + }, + "NestedXRUsesOwnComposition": { + reason: "Validates that nested XRs use their own composition from the cluster, not the parent's CLI composition", + // Set up XRDs, compositions, and functions + setupFiles: []string{ + // Child XRD and composition (XNopResource uses xnopresources.diff.example.org) + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + // Parent XRD and composition + "testdata/comp/resources/nested-xr/parent-xrd.yaml", + "testdata/comp/resources/nested-xr/parent-composition.yaml", + }, + // Use crossplaneManagedResources to set up proper ownership hierarchy + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + // Parent XR owns both direct downstream and nested child XR + OwnerFile: "testdata/comp/resources/nested-xr/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + // Direct downstream owned by parent XR + "testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml": nil, + // Nested child XR owned by parent XR, which owns its own downstream + "testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + // Child's downstream owned by nested child XR + "testdata/comp/resources/nested-xr/existing-child-downstream.yaml": nil, + }, + }, + }, + }, + }, + // Diff the updated parent composition + inputFiles: []string{"testdata/comp/updated-parent-composition.yaml"}, + namespace: "default", + // Expected: Only the parent's direct downstream shows changes (tier change). + // The nested child's downstream should NOT change because: + // 1. The nested XR (XNopResource) uses its own composition (xnopresources.diff.example.org) + // 2. That child composition was NOT modified + // Bug scenario: If nested XR incorrectly uses parent composition, we'd see wrong changes to child's downstream. + // Expected output: Only the parent's direct downstream should show in the Impact Analysis. + // The child's downstream (XDownstreamResource/test-parent-nested) should NOT appear here. + // If it did, it would mean the nested XR incorrectly used the parent's CLI composition. + expectedOutput: `=== Impact Analysis === + +~~~ XDownstreamResource/test-parent-direct + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: direct-resource + labels: + crossplane.io/composite: test-parent + name: test-parent-direct + namespace: default + spec: + forProvider: + configData: parent-parent-value +- resourceTier: parent-tier ++ resourceTier: premium-tier + +--- + +Summary: 1 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + "CrossNamespaceResourceCollision": { + reason: "Validates that resources with the same name in different namespaces are correctly distinguished", + skip: true, + skipReason: "Blocked on https://github.com/crossplane-contrib/function-extra-resources/issues/106. " + + "function-extra-resources (v0.2.0/v0.3.0) emits ResourceSelector{Namespace=\"\"} for " + + "by-name references that omit `ref.namespace` — it only forwards user-yaml verbatim, " + + "never inferring from the XR's namespace. Crossplane v2.3+'s render binary then does " + + "InMemoryClient.Get(name, namespace=\"\"), a strict (GVK, ns, name) match that doesn't " + + "find our resolved ConfigMap (stored at namespace=ns-{a,b}). By-label selectors aren't " + + "affected — List(InNamespace(\"\")) lists across all namespaces — which is why our " + + "other extra-resources tests still pass. Re-enable once a function release defaults " + + "selector.Namespace to the XR's namespace.", + timeout: longTimeout, // Test involves multiple namespaces and can be slow with Docker contention + // This test catches a bug where the cache key didn't include namespace, causing + // ConfigMaps with the same name in different namespaces to collide. + // The XR in ns-a should get ConfigMap from ns-a (value: from-ns-a) + // The XR in ns-b should get ConfigMap from ns-b (value: from-ns-b) + // Bug: Both XRs would get the same ConfigMap (whichever was cached first) + setupFiles: []string{ + // Create namespaces first + "testdata/comp/resources/namespace-collision/namespace-ns-a.yaml", + "testdata/comp/resources/namespace-collision/namespace-ns-b.yaml", + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/namespace-collision/collision-composition.yaml", + "testdata/comp/resources/namespace-collision/collision-composition-revision.yaml", + // ConfigMaps with same name in different namespaces + "testdata/comp/resources/namespace-collision/configmap-ns-a.yaml", + "testdata/comp/resources/namespace-collision/configmap-ns-b.yaml", + }, + // XRs and their downstream resources in different namespaces + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml": nil, + }, + }, + { + OwnerFile: "testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/comp/updated-collision-composition.yaml"}, + // Expected: Each XR's downstream should show the correct prefix applied to its namespace's ConfigMap value. + // XR in ns-a: from-ns-a → prefix-from-ns-a + // XR in ns-b: from-ns-b → prefix-from-ns-b + // Bug scenario: If namespace collision occurs, ns-b would incorrectly show: + // from-ns-b → prefix-from-ns-a (wrong ConfigMap cached from ns-a) + expectedOutput: `=== Impact Analysis === + +~~~ XDownstreamResource/xr-in-ns-a + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-a + name: xr-in-ns-a + namespace: ns-a + spec: + forProvider: +- configData: from-ns-a ++ configData: prefix-from-ns-a + +--- +~~~ XDownstreamResource/xr-in-ns-b + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-b + name: xr-in-ns-b + namespace: ns-b + spec: + forProvider: +- configData: from-ns-b ++ configData: prefix-from-ns-b + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Paired comp sequencer-gating tests — mirror of the XR pair. Verifies that + // --eventual-state on `comp` surfaces downstream resources hidden by + // function-sequencer in the impact analysis. + "CompSequencerGatesLaterStageWithoutEventualState": { + reason: "`comp` without --eventual-state: sequencer-gated stage1 is absent from the impact analysis", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR references sequencer-gating-composition by name, so comp discovers it. It has + // composed stage0, which isn't Ready, so stage1 stays gated. + crossplaneManagedResources: []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, + // Expect: impact shows stage0 modified (its configData changed); stage1 is absent from + // downstream impact because sequencer gates it. + 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, + }, + "CompSequencerGatedStageAppearsWithEventualState": { + reason: "`comp` with --eventual-state: sequencer-gated stage1 surfaces in the impact analysis", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR has composed stage0, which isn't Ready, so stage1 stays gated until eventual + // state synthesizes stage0's Ready (#564). + crossplaneManagedResources: []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, + // Expect: impact shows stage0 modified and stage1 added (eventual-state unlocks stage1). + expectedStructured: simtest.Expect(). + WithComposition("sequencer-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "sequencer-gating-test", "default", "changed"). + WithDownstreamSummary(1, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"). + AndXR(). + WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). + // dryRun absence through `comp`: a downstream addition that reached the apiserver + // carries no dryRun object. Piggy-backed on an existing case rather than given its + // own envtest instance, since it is a pure additional assertion. + WithDryRunPerformed(). + WithField("spec.forProvider.configData", "updated-existing-value"), + expectedError: false, + }, + // Paired comp composition-driven gating tests — gating encoded in go-templating, + // no function-sequencer. Mirrors the XR composition-driven pair. + "CompConditionalGatingHidesLaterStageWithoutEventualState": { + reason: "`comp` without --eventual-state: template-gated stage1 is absent from the impact analysis", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR has composed stage0, which isn't Ready, so stage1 stays gated. + crossplaneManagedResources: []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(). + WithComposition("conditional-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"), + expectedError: false, + }, + "CompConditionalGatedStageAppearsWithEventualState": { + reason: "`comp` with --eventual-state: template-gated stage1 surfaces in the impact analysis", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The XR has composed stage0, which isn't Ready, so stage1 stays gated until eventual + // state synthesizes stage0's Ready (#564). + crossplaneManagedResources: []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(). + WithComposition("conditional-gating-composition"). + WithCompositionModified(). + WithXRImpact("XNopResource", "conditional-gating-test", "default", "changed"). + WithDownstreamSummary(1, 1, 0). + WithDownstreamResource("modified", "XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "updated-existing-value"). + AndXR(). + WithDownstreamResource("added", "XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "updated-existing-value"), + expectedError: false, + }, + // dryRun presence through `comp`. dryRun lives on ChangeDetail, which `xr` reaches via + // changes[] and `comp` via impactAnalysis[].downstreamChanges.changes[] — one shared wire + // shape, but only an assertion through the comp renderer proves comp actually populates it. + // The composed resource is deliberately absent from the setup so the impact is an ADDITION, + // which is the only kind of change the dryRun field applies to: the XR has its composition + // refs, but its first compose has not created its resource. + "CompDownstreamAdditionReportsDryRunSkippedWhenDryRunOnExisting": { + reason: "`comp` structured output carries dryRun.skipReason on a downstream addition when --dry-run-on=existing", + outputFormat: "json", + dryRunOn: "existing", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + // existing-downstream-1.yaml is intentionally NOT applied: with no composed resource + // in the cluster, the impact analysis reports an addition rather than a modification. + "testdata/comp/resources/existing-xr-1.yaml", + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + WithDownstreamSummary(1, 0, 0). + WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.forProvider.configData", "updated-existing-value"), + expectedError: false, + }, + // The unverified-addition warning through `comp`: one run-wide summary in the top-level + // warnings[], counting the downstream additions behind it, rather than one per composition. + // A cluster-scoped XR is what lets the composition reach a namespace that does not exist. + "CompDownstreamAdditionsInMissingNamespaceAreCountedInOneWarning": { + reason: "`comp` summarises downstream additions it could not verify as one warning per GVK + namespace + cause, with a count", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/cluster-xrd.yaml", + "testdata/comp/resources/unverified-cluster-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/existing-unverified-cluster-xr.yaml", + }, + inputFiles: []string{"testdata/comp/updated-unverified-cluster-composition.yaml"}, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStderrContains: []string{ + `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=/v1, Kind=ConfigMap, namespace=nonexistent-namespace)`, + }, + expectedStructured: simtest.Expect(). + WithWarning("their namespace does not exist yet"). + WithWarningContext(map[string]string{ + "gvk": "/v1, Kind=ConfigMap", + "namespace": "nonexistent-namespace", + "cause": `namespaces "nonexistent-namespace" not found`, + "count": "2", + }). + And(). + WithComposition("xnopresources-unverified.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "unverified-xr", "", "changed"). + WithDownstreamSummary(2, 0, 0). + WithDownstreamResource("added", "ConfigMap", "unverified-xr-first", "nonexistent-namespace"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"). + AndXR(). + WithDownstreamResource("added", "ConfigMap", "unverified-xr-second", "nonexistent-namespace"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"), + expectedError: false, + }, + // --resource flag tests (issue #321) + "ResourceFilterScopesAffectedXRs": { + reason: "--resource limits impact analysis to the named composites and ignores the rest", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( // test-resource (default ns) + "testdata/comp/resources/existing-xr-1.yaml", + "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", + ), + }, + inputFiles: []string{"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", + }, + 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", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + // list on the XR type: without --resource, comp finds the composition's XRs by listing them. + rbacRules: leastPrivilegeRules("get", "list", "create", "patch"), + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(2, 2, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + AndComp(). + WithXRImpact("XNopResource", "another-resource", "default", "changed"), + }, + "NameScopedRunSucceedsWithoutCompositeList": { + reason: "--resource looks its composites up by name, so a user who cannot list the XR type gets the admin's impact analysis", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []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", + ), + }, + 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(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithAffectedResources(1, 1, 0, 0). + WithXRImpact("XNopResource", "test-resource", "default", "changed"), + }, + "ResourceFilterCommaSeparated": { + reason: "--resource accepts comma-separated values via kong's auto-parsing", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []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", + ), + }, + inputFiles: []string{"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). + WithXRImpact("XNopResource", "test-resource", "default", "changed"). + AndComp(). + WithXRImpact("XNopResource", "another-resource", "default", "changed"), + }, + "ResourceFilterUnmatched_FailsBeforeRendering": { + reason: "--resource naming a non-existent composite fails fast with an error before any rendering", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/does-not-exist"}, + expectedError: true, + expectedErrorContains: "does-not-exist", + expectedExitCode: dp.ExitCodeToolError, + }, + "ResourceFilterRespectsManualPolicy_WithoutIncludeManual": { + reason: "--resource matching a Manual-policy composite surfaces it as filtered (reason manual_policy) when --include-manual is unset", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-manual.yaml", + "testdata/comp/resources/existing-downstream-manual.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/manual-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed + 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", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-manual.yaml", + "testdata/comp/resources/existing-downstream-manual.yaml", + ), + }, + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + resources: []string{"default/manual-resource"}, + includeManual: true, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "manual-resource", "default", "changed"), + }, + // Issue #388 (Bug A): an Automatic-policy XR whose compositionRevisionSelector does not match + // the edited composition's labels would not adopt the resulting revision, so it must be + // surfaced as filtered (reason revision_selector_mismatch), NOT evaluated for changes. + "ResourceFilterRespectsRevisionSelectorMismatch": { + reason: "--resource matching an Automatic XR whose compositionRevisionSelector does not match the composition's labels surfaces it as filtered (reason revision_selector_mismatch)", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/existing-xr-selector-mismatch.yaml", + }, + inputFiles: []string{"testdata/comp/updated-composition-labeled.yaml"}, + resources: []string{"default/selector-mismatch-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, // composition itself changed + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "selector-mismatch-resource", "default", "filtered"). + WithFilterReason("revisionSelectorMismatch"), + // Note: that --include-manual does NOT rescue a selector-mismatched Automatic XR is + // proven at the unit level (TestDefaultCompDiffProcessor_partitionXRsByUpdatePolicy / + // IncludeManualTrue_StillDropsSelectorMismatchedAutomaticXRs). A second slow + // docker-render integration case for that flag interaction would be redundant. + }, + // Issue #388 (Bug A, positive): an Automatic XR whose compositionRevisionSelector DOES match + // the edited composition's labels is kept and evaluated for downstream changes (not filtered). + "ResourceFilterRespectsRevisionSelectorMatch": { + reason: "--resource matching an Automatic XR whose compositionRevisionSelector matches the composition's labels keeps it and reports downstream changes", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/functions.yaml", + "testdata/comp/resources/existing-xr-selector-match.yaml", + }, + inputFiles: []string{"testdata/comp/updated-composition-labeled.yaml"}, + resources: []string{"default/selector-match-resource"}, + outputFormat: "json", + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithComposition("xnopresources.diff.example.org"). + WithCompositionModified(). + WithXRImpact("XNopResource", "selector-match-resource", "default", "changed"), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + runIntegrationTest(t, CompositionDiffTest, tt) + }) + } +} diff --git a/cmd/crossplane/project/simulate.go b/cmd/crossplane/project/simulate.go index 274c2282..7efa7e50 100644 --- a/cmd/crossplane/project/simulate.go +++ b/cmd/crossplane/project/simulate.go @@ -92,6 +92,10 @@ func (c *simulateCmd) buildProject(ctx context.Context, logger logging.Logger, s return nil, errors.Errorf("no Compositions found under the project's APIs path %q", proj.Spec.Paths.APIs) } + if err := simulate.RejectDuplicateNames(comps); err != nil { + return nil, errors.Wrap(err, "cannot load compositions from project") + } + ics := proj.Spec.ImageConfigs if c.ImageConfigs != "" { if ics, err = simulate.ImageConfigs(c.ImageConfigs, ""); err != nil { diff --git a/cmd/crossplane/project/simulate_test.go b/cmd/crossplane/project/simulate_test.go index 311e9395..b8c160be 100644 --- a/cmd/crossplane/project/simulate_test.go +++ b/cmd/crossplane/project/simulate_test.go @@ -396,6 +396,11 @@ func TestSimulateBuildProjectErrors(t *testing.T) { project: "nocomps", want: "no Compositions found", }, + "DuplicateCompositionNames": { + reason: "Two files under the APIs path defining one Composition name would be diffed against the same live Composition.", + project: "dupcomps", + want: `Composition "dup" is defined more than once`, + }, } for name, tc := range cases { diff --git a/cmd/crossplane/project/testdata/README.md b/cmd/crossplane/project/testdata/README.md index 5818da65..11bc26cd 100644 --- a/cmd/crossplane/project/testdata/README.md +++ b/cmd/crossplane/project/testdata/README.md @@ -3,7 +3,10 @@ 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`. +`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`. ## How a case runs @@ -13,6 +16,11 @@ through kong with `runSimulateArgvWith` in 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 +files as `-f`, `--resource` as `--name`, `--include-manual` as +`--include-pinned`) and runs the command the same way. + ## Skips A case the environment cannot run skips with an `env:`: the diff --git a/cmd/crossplane/project/testdata/comp/api-version-updated-composition.yaml b/cmd/crossplane/project/testdata/comp/api-version-updated-composition.yaml new file mode 100644 index 00000000..02748913 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/api-version-updated-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapimigrateresources.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: comp.example.org/v1beta2 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/claim-revision-templating-updated-composition.yaml b/cmd/crossplane/project/testdata/comp/claim-revision-templating-updated-composition.yaml new file mode 100644 index 00000000..60bfc213 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/claim-revision-templating-updated-composition.yaml @@ -0,0 +1,37 @@ +# A metadata-only edit of resources/claim-revision-templating-composition.yaml: one added label, +# identical spec. Applying it mints a new CompositionRevision, which an Automatic claim's backing XR +# re-points to — and the template reads that revision's name, so the composed resource changes. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefclaims.diff.example.org + labels: + channel: preview +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/composition-no-changes.yaml b/cmd/crossplane/project/testdata/comp/composition-no-changes.yaml new file mode 100644 index 00000000..556cf7bf --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/composition-no-changes.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/field-removal-updated-composition.yaml b/cmd/crossplane/project/testdata/comp/field-removal-updated-composition.yaml new file mode 100644 index 00000000..7cff974f --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/field-removal-updated-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xfieldremoval.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/mixed-status-updated-composition.yaml b/cmd/crossplane/project/testdata/comp/mixed-status-updated-composition.yaml new file mode 100644 index 00000000..3e01b1e8 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/mixed-status-updated-composition.yaml @@ -0,0 +1,59 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xmixed.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-database + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + resourceType: database + tier: premium + backupEnabled: true + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-storage + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: storage + spec: + forProvider: + resourceType: storage + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-network + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: network + spec: + forProvider: + resourceType: network + tier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/net-new-composition.yaml b/cmd/crossplane/project/testdata/comp/net-new-composition.yaml new file mode 100644 index 00000000..4bfa487f --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/net-new-composition.yaml @@ -0,0 +1,37 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnewresources.diff.example.org + labels: + environment: staging + provider: aws +spec: + compositeTypeRef: + apiVersion: staging.diff.example.org/v1alpha1 + kind: XNewResource + mode: Pipeline + pipeline: + - step: generate-new-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: staging.nop.example.org/v1alpha1 + kind: XStagingResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: staging-resource + spec: + forProvider: + environment: staging + configData: new-{{ .observed.composite.resource.spec.newField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/comp/resources/api-version-original-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/api-version-original-composition.yaml new file mode 100644 index 00000000..00221d82 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/api-version-original-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapimigrateresources.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: comp.example.org/v1beta1 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/claim-composition.yaml new file mode 100644 index 00000000..58219d58 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/claim-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: nopclaims.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml b/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml new file mode 100644 index 00000000..f7a6b7c3 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml @@ -0,0 +1,41 @@ +# claim-revision-templating-composition.yaml as the cluster holds it after a client-side +# `kubectl apply` of an older file: identical content, plus a last-applied-configuration annotation +# that no longer describes the file. Diffing claim-revision-templating-composition.yaml against this is +# a change whose only delta is that annotation, so the name of the revision it would mint cannot be +# predicted, nothing is seeded, and every composite renders against the revision it tracks now. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefclaims.diff.example.org + annotations: + # Deliberately stale: it describes some earlier file, not the one diffed against this. + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition","metadata":{"name":"xrevisionrefclaims.diff.example.org"},"spec":{"mode":"Pipeline"}} +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition.yaml new file mode 100644 index 00000000..0f5cd92c --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition.yaml @@ -0,0 +1,39 @@ +# The claim-side counterpart of revision-templating-composition.yaml: the in-cluster copy of a +# composition whose template reads the CompositionRevision's own name off the composite and propagates +# it into a composed resource. XNop is a legacy (claim-capable) XR, so the ref is at the v1 path, +# spec.compositionRevisionRef. +# +# Paired with ../claim-revision-templating-updated-composition.yaml (a label-only edit) and with +# claim-revision-templating-composition-kubectl-applied.yaml (this, plus a stale kubectl annotation). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefclaims.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/claim-xrd.yaml b/cmd/crossplane/project/testdata/comp/resources/claim-xrd.yaml new file mode 100644 index 00000000..b2b8dfa7 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/claim-xrd.yaml @@ -0,0 +1,27 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnops.diff.example.org +spec: + group: diff.example.org + names: + kind: XNop + plural: xnops + claimNames: + kind: NopClaim + plural: nopclaims + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + writeConnectionSecretsToNamespace: + type: string diff --git a/cmd/crossplane/project/testdata/comp/resources/custom-namespace.yaml b/cmd/crossplane/project/testdata/comp/resources/custom-namespace.yaml new file mode 100644 index 00000000..bb71c9a0 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/custom-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: custom-namespace \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/comp/resources/cycle/existing-cycle-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/cycle/existing-cycle-xr.yaml new file mode 100644 index 00000000..38d622c3 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/cycle/existing-cycle-xr.yaml @@ -0,0 +1,12 @@ +# An existing XR on the XCycleA composition, which comp discovers and re-renders against the +# updated composition. Its composition starts the XCycleA -> XCycleB -> XCycleA cycle. +apiVersion: ns.cycle.example.org/v1alpha1 +kind: XCycleA +metadata: + name: test-cycle + namespace: default +spec: + cycleField: "cycle-value" + crossplane: + compositionRef: + name: xcycleas.cycle.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml new file mode 100644 index 00000000..8b63f58b --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml @@ -0,0 +1,10 @@ +apiVersion: comp.example.org/v1beta1 +kind: XApiMigrateResource +metadata: + name: test-api-version-api-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: api-migrate-resource +spec: + forProvider: + configData: test-value diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-api-version-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-api-version-xr.yaml new file mode 100644 index 00000000..05f4d7b2 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-api-version-xr.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionUpdatePolicy: Automatic + coolField: test-value diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-1-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-1-xr.yaml new file mode 100644 index 00000000..24de708a --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-1-xr.yaml @@ -0,0 +1,21 @@ +# The backing XR Crossplane's claim syncer created for existing-claim-1.yaml. Its composed resource is +# existing-claim-downstream-1.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: test-claim-1-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: test-claim-1 + namespace: test-namespace + coolField: claim-value-1 + compositeDeletePolicy: Background + compositionUpdatePolicy: Automatic + compositionRef: + name: nopclaims.diff.example.org + resourceRefs: + - apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + name: test-claim-1-xr diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-1.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-1.yaml new file mode 100644 index 00000000..dac0a2b9 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-1.yaml @@ -0,0 +1,18 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim-1 + namespace: test-namespace +spec: + coolField: claim-value-1 + compositeDeletePolicy: Background + compositionRef: + name: nopclaims.diff.example.org + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: nopclaims.diff.example.org-e164b8b + # Bound to its backing XR, existing-claim-1-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: test-claim-1-xr diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-2-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-2-xr.yaml new file mode 100644 index 00000000..de2703cb --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-2-xr.yaml @@ -0,0 +1,21 @@ +# The backing XR Crossplane's claim syncer created for existing-claim-2.yaml. Its composed resource is +# existing-claim-downstream-2.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: test-claim-2-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: test-claim-2 + namespace: test-namespace + coolField: claim-value-2 + compositeDeletePolicy: Background + compositionUpdatePolicy: Automatic + compositionRef: + name: nopclaims.diff.example.org + resourceRefs: + - apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + name: test-claim-2-xr diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-2.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-2.yaml new file mode 100644 index 00000000..7f7b89e1 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-2.yaml @@ -0,0 +1,18 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim-2 + namespace: test-namespace +spec: + coolField: claim-value-2 + compositeDeletePolicy: Background + compositionRef: + name: nopclaims.diff.example.org + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: nopclaims.diff.example.org-e164b8b + # Bound to its backing XR, existing-claim-2-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: test-claim-2-xr diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-1.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-1.yaml new file mode 100644 index 00000000..23fd1f94 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-1.yaml @@ -0,0 +1,10 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-claim-1-xr +spec: + forProvider: + configData: claim-value-1 + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-2.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-2.yaml new file mode 100644 index 00000000..f0848c7b --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-2.yaml @@ -0,0 +1,10 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-claim-2-xr +spec: + forProvider: + configData: claim-value-2 + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml new file mode 100644 index 00000000..4803e305 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml @@ -0,0 +1,14 @@ +# The composed resource of existing-claim-revision-ref-xr.yaml, rendered from the revision that XR +# tracks, so configData holds that revision's name. Carries crossplane.io/composition-resource-name (and, +# from the harness, the composite and claim labels) because the render adds all of them: a fixture missing +# any is reported as modified on every run, which would hide whether the revision name propagated. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: revision-ref-claim-xr + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefclaims.diff.example.org-98b484d + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml new file mode 100644 index 00000000..865fbace --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml @@ -0,0 +1,12 @@ +# existing-claim-revision-ref-downstream.yaml as rendered from the revision +# existing-claim-revision-ref-kubectl-applied-xr.yaml tracks, so configData holds that revision's name. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: revision-ref-claim-xr + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefclaims.diff.example.org-329a7cd + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml new file mode 100644 index 00000000..9aa0011f --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml @@ -0,0 +1,15 @@ +# existing-claim-revision-ref-xr.yaml where the composition was applied with a client-side `kubectl +# apply`. Composition.Hash() covers annotations, so the revision it tracks has a different name. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: revision-ref-claim-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: revision-ref-claim + namespace: test-namespace + coolField: revision-ref-claim-value + compositionRef: + name: xrevisionrefclaims.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml new file mode 100644 index 00000000..76a758b2 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml @@ -0,0 +1,20 @@ +# existing-claim-revision-ref.yaml where the composition was applied with a client-side `kubectl apply` +# (claim-revision-templating-composition-kubectl-applied.yaml). Composition.Hash() covers annotations, +# so that composition's revision has another name, which the claim carries like its backing XR, +# existing-claim-revision-ref-kubectl-applied-xr.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: revision-ref-claim + namespace: test-namespace +spec: + coolField: revision-ref-claim-value + compositeDeletePolicy: Background + compositionRef: + name: xrevisionrefclaims.diff.example.org + compositionRevisionRef: + name: xrevisionrefclaims.diff.example.org-329a7cd + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: revision-ref-claim-xr diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-xr.yaml new file mode 100644 index 00000000..09cb9326 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-xr.yaml @@ -0,0 +1,15 @@ +# The backing XR of existing-claim-revision-ref.yaml, tracking the current revision of +# claim-revision-templating-composition.yaml as Crossplane's composite reconciler leaves it. +apiVersion: diff.example.org/v1alpha1 +kind: XNop +metadata: + name: revision-ref-claim-xr +spec: + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: revision-ref-claim + namespace: test-namespace + coolField: revision-ref-claim-value + compositionRef: + name: xrevisionrefclaims.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref.yaml new file mode 100644 index 00000000..6bb6c655 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref.yaml @@ -0,0 +1,20 @@ +# A Claim at the default (Automatic) update policy, bound to existing-claim-revision-ref-xr.yaml. It +# carries that XR's compositionRevisionRef, because under Automatic the claim syncer copies the XR's ref +# back onto the claim. Going the other way the claim's ref is not propagated: under Automatic the XR +# controller owns the backing XR's ref, so that is the one render must see (issue #498). +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: revision-ref-claim + namespace: test-namespace +spec: + coolField: revision-ref-claim-value + compositeDeletePolicy: Background + compositionRef: + name: xrevisionrefclaims.diff.example.org + compositionRevisionRef: + name: xrevisionrefclaims.diff.example.org-98b484d + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + name: revision-ref-claim-xr diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-stage0.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-stage0.yaml new file mode 100644 index 00000000..2e0c9b2c --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-stage0.yaml @@ -0,0 +1,12 @@ +# The stage0 resource conditional-gating-composition.yaml composed for existing-conditional-gating-xr.yaml. +# It has no Ready condition yet, so stage1 stays gated until --eventual-state synthesizes one. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-xr.yaml new file mode 100644 index 00000000..0a9b0167 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: conditional-gating-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: conditional-gating-composition diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-downstream.yaml new file mode 100644 index 00000000..f22ee1bf --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-downstream.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: custom-namespace-resource + namespace: custom-namespace + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: custom-ns-existing-value + resourceTier: basic \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-xr.yaml new file mode 100644 index 00000000..c8c5d766 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: custom-namespace-resource + namespace: custom-namespace +spec: + coolField: custom-ns-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-deleting.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-deleting.yaml new file mode 100644 index 00000000..3dd7c99d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-deleting.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: deleting-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: existing-value + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-drifted.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-drifted.yaml new file mode 100644 index 00000000..347c4221 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-drifted.yaml @@ -0,0 +1,14 @@ +# existing-downstream-1.yaml after an out-of-band edit: someone changed configData in the cluster, so +# it no longer matches what the composition renders for existing-xr-1.yaml. Re-rendering an unchanged +# composition therefore surfaces a delta the composition did not cause. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: drifted-value + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-manual.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-manual.yaml new file mode 100644 index 00000000..34dda8a3 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-manual.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: manual-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: manual-existing-value + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml new file mode 100644 index 00000000..eea3c1cc --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml @@ -0,0 +1,15 @@ +# The composed resource of existing-xr-pinned-revision-ref.yaml as its pinned revision rendered it, so +# configData holds that revision's name. It carries crossplane.io/composition-resource-name as well as the +# gotemplating annotation, as a rendered resource does; see existing-downstream-revision-ref.yaml for why a +# fixture without it would show a diff on every run. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: pinned-revision-ref-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefs.diff.example.org-0d65896 + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-revision-ref.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-revision-ref.yaml new file mode 100644 index 00000000..617bac3b --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-revision-ref.yaml @@ -0,0 +1,19 @@ +# The composed resource as it exists in the cluster, i.e. rendered from the revision the composite +# currently tracks. configData therefore holds that revision's name, which is exactly what makes the +# revision-propagating change observable: re-render against the new revision and this value must move. +# +# It carries crossplane.io/composition-resource-name, which the render adds, so that it is a real +# discriminator: a fixture omitting it shows an annotation diff on EVERY run, which would report the +# resource as modified whether or not the revision name propagated. Its value is a real revision name, +# which matches the predicted-name pattern too, so the tests pin the exact old and new value. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: revision-ref-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: xrevisionrefs.diff.example.org-e36719b + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-v2.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-v2.yaml new file mode 100644 index 00000000..709ba671 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-v2.yaml @@ -0,0 +1,13 @@ +# What original-composition-2.yaml currently renders for existing-xr-v2, so the diff against the +# changed composition shows only the fields that composition actually changed. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: v2-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource-v2 +spec: + forProvider: + configData: v2-updated-v2-existing-value + resourceTier: enterprise diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-downstream-with-argocd.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-with-argocd.yaml new file mode 100644 index 00000000..73dcfbfc --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-downstream-with-argocd.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + argocd.argoproj.io/tracking-id: "test-app:/XDownstreamResource:default/test-resource" + labels: + argocd.argoproj.io/instance: test-app + name: test-resource + namespace: default +spec: + forProvider: + configData: existing-value + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-stage0.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-stage0.yaml new file mode 100644 index 00000000..9f520639 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-stage0.yaml @@ -0,0 +1,12 @@ +# The stage0 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml. +# It has no Ready condition yet, so stage1 stays gated until --eventual-state synthesizes one. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-xr.yaml new file mode 100644 index 00000000..bf00981f --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-gating-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: sequencer-gating-composition diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sha256-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-sha256-downstream.yaml new file mode 100644 index 00000000..c5c48f16 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-sha256-downstream.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: sha256-test-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: sha256-value + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-sha256-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-sha256-xr.yaml new file mode 100644 index 00000000..3409f380 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-sha256-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sha256-test-resource + namespace: default +spec: + coolField: sha256-value + crossplane: + compositionRef: + name: xnopresources-sha256.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-unverified-cluster-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-unverified-cluster-xr.yaml new file mode 100644 index 00000000..4b75574e --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-unverified-cluster-xr.yaml @@ -0,0 +1,11 @@ +# A cluster-scoped XR using unverified-cluster-composition.yaml. It has composed nothing: the +# composition renders ConfigMaps into a namespace that does not exist, so Crossplane cannot create them. +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: unverified-xr +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources-unverified.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml new file mode 100644 index 00000000..b7ff7ef3 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml @@ -0,0 +1,13 @@ +# existing-xr-1.yaml where the composition was applied with a client-side `kubectl apply` of the +# file being diffed (original-composition-kubectl-applied-current.yaml). Composition.Hash() covers +# annotations, so that composition's revision has a different name, and this is the one the XR tracks. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml new file mode 100644 index 00000000..ea3e88fe --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml @@ -0,0 +1,13 @@ +# existing-xr-1.yaml where the composition was applied with a client-side `kubectl apply` +# (original-composition-kubectl-applied.yaml). Composition.Hash() covers annotations, so that +# composition's revision has a different name, and this is the one the XR tracks. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-deleting.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-deleting.yaml new file mode 100644 index 00000000..c4d717a5 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-deleting.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: deleting-resource + namespace: default + # The finalizer keeps the object around after a delete is issued, so envtest (which runs no + # controllers) leaves it with metadata.deletionTimestamp set — a resource mid-deletion. + finalizers: + - composite.apiextensions.crossplane.io +spec: + coolField: existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml new file mode 100644 index 00000000..c03ffe8e --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml @@ -0,0 +1,22 @@ +# ../../diff/resources/existing-xr-listing-uncontrolled-resources.yaml, tracking the in-cluster +# removal-test-original-composition.yaml instead. Its resourceRefs still name two composed resources it +# no longer controls: one ../../diff/resources/existing-other-xr.yaml now controls, and one whose +# controller reference was removed. The harness appends the resources this XR does control. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic + resourceRefs: + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: other-resource + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: resource-without-controller diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-manual.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-manual.yaml new file mode 100644 index 00000000..677aeba1 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-manual.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: manual-resource + namespace: default +spec: + coolField: manual-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Manual diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml new file mode 100644 index 00000000..ff2586ee --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml @@ -0,0 +1,18 @@ +# A Manual composite of the revision-templating composition, pinned to an older CompositionRevision than +# the one existing-xr-revision-ref.yaml tracks: revision 1, +# revision-templating-composition-older-revision.yaml. A Manual composite never re-points, so applying +# an edit of its composition leaves it on this revision, and a template reading the revision name must +# keep reading this one. Kept for analysis only under --include-pinned. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: pinned-revision-ref-resource + namespace: default +spec: + coolField: pinned-revision-ref-existing-value + crossplane: + compositionRef: + name: xrevisionrefs.diff.example.org + compositionUpdatePolicy: Manual + compositionRevisionRef: + name: xrevisionrefs.diff.example.org-0d65896 diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-revision-ref.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-revision-ref.yaml new file mode 100644 index 00000000..7750dd99 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-revision-ref.yaml @@ -0,0 +1,15 @@ +# A composite with an Automatic update policy, tracking the latest revision of +# revision-templating-composition.yaml (revision 2, revision-templating-composition-revision.yaml), as +# Crossplane's composite reconciler leaves it. Automatic, so it would re-point to the revision a composition edit +# produces. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: revision-ref-resource + namespace: default +spec: + coolField: revision-ref-existing-value + crossplane: + compositionRef: + name: xrevisionrefs.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-match.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-match.yaml new file mode 100644 index 00000000..ef9c030d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-match.yaml @@ -0,0 +1,18 @@ +# Deliberately no compositionRevisionRef. The selector matches no revision in the cluster: +# original-composition.yaml has no version label, so neither has its one revision. Crossplane cannot +# select a revision for this XR, so it never writes the ref, and the XR waits for a composition +# labelled version=0.0.2, like updated-composition-labeled.yaml. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-match-resource + namespace: default +spec: + coolField: selector-match-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + version: 0.0.2 diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-mismatch.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-mismatch.yaml new file mode 100644 index 00000000..a7f18cdc --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-mismatch.yaml @@ -0,0 +1,18 @@ +# Deliberately no compositionRevisionRef. The selector matches no revision in the cluster: +# original-composition.yaml has no version label, so neither has its one revision. Crossplane cannot +# select a revision for this XR, so it never writes the ref, and the XR waits for a composition +# labelled version=0.0.1 (updated-composition-labeled.yaml is 0.0.2, so it is not that one). +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-mismatch-resource + namespace: default +spec: + coolField: selector-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + version: 0.0.1 diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-v2.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-v2.yaml new file mode 100644 index 00000000..10b2dad7 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-v2.yaml @@ -0,0 +1,12 @@ +# An XR that uses the SECOND composition (xnopresources-v2), so a multi-composition run can have +# affected XRs under both compositions rather than only the first. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: v2-resource + namespace: default +spec: + coolField: v2-existing-value + crossplane: + compositionRef: + name: xnopresources-v2.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/existing-xr-with-argocd.yaml b/cmd/crossplane/project/testdata/comp/resources/existing-xr-with-argocd.yaml new file mode 100644 index 00000000..2b4fce63 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/existing-xr-with-argocd.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app:/XNopResource:default/test-resource" + labels: + argocd.argoproj.io/instance: test-app +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/field-removal/composition-with-field.yaml b/cmd/crossplane/project/testdata/comp/resources/field-removal/composition-with-field.yaml new file mode 100644 index 00000000..78a14146 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/field-removal/composition-with-field.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xfieldremoval.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + removableField: will-be-removed + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-downstream.yaml new file mode 100644 index 00000000..0c1491f2 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-downstream.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: field-removal-test + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: test-value + removableField: will-be-removed diff --git a/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-xr.yaml new file mode 100644 index 00000000..831e9b86 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/field-removal/existing-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: field-removal-test + namespace: default +spec: + coolField: test-value + crossplane: + compositionRef: + name: xfieldremoval.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/functions-sha256.yaml b/cmd/crossplane/project/testdata/comp/resources/functions-sha256.yaml new file mode 100644 index 00000000..0fda179d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/functions-sha256.yaml @@ -0,0 +1,17 @@ +--- +# Function referenced by SHA256 digest instead of tag. +# This tests that a function whose package is a digest reference renders. +# The digest is for function-go-templating:v0.11.0 - same image, different reference format. +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating-sha256 +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating@sha256:34f1ca0ff2e1702ae0a5088a96f31e992eb9b15356af3b0f1a2068603e767753 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.4.2 diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-1.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-1.yaml new file mode 100644 index 00000000..02c75e6e --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-1-database + namespace: default + annotations: + crossplane.io/composition-resource-name: database +spec: + forProvider: + resourceType: database + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-2.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-2.yaml new file mode 100644 index 00000000..55cbbba5 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-2-database + namespace: default + annotations: + crossplane.io/composition-resource-name: database +spec: + forProvider: + resourceType: database + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-3.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-3.yaml new file mode 100644 index 00000000..25ddf6f4 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-3.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-3-database + namespace: default + annotations: + crossplane.io/composition-resource-name: database +spec: + forProvider: + resourceType: database + tier: premium + backupEnabled: true diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-1.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-1.yaml new file mode 100644 index 00000000..f9672d09 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-1-network + namespace: default + annotations: + crossplane.io/composition-resource-name: network +spec: + forProvider: + resourceType: network + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-2.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-2.yaml new file mode 100644 index 00000000..e368615b --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-2-network + namespace: default + annotations: + crossplane.io/composition-resource-name: network +spec: + forProvider: + resourceType: network + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-3.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-3.yaml new file mode 100644 index 00000000..7186308e --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-3.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-3-network + namespace: default + annotations: + crossplane.io/composition-resource-name: network +spec: + forProvider: + resourceType: network + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-1.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-1.yaml new file mode 100644 index 00000000..1ef2aeff --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-1-storage + namespace: default + annotations: + crossplane.io/composition-resource-name: storage +spec: + forProvider: + resourceType: storage + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-2.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-2.yaml new file mode 100644 index 00000000..d6bcfd9d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-2-storage + namespace: default + annotations: + crossplane.io/composition-resource-name: storage +spec: + forProvider: + resourceType: storage + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-3.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-3.yaml new file mode 100644 index 00000000..af025860 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-3.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: mixed-test-xr-3-storage + namespace: default + annotations: + crossplane.io/composition-resource-name: storage +spec: + forProvider: + resourceType: storage + tier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-status-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-status-composition.yaml new file mode 100644 index 00000000..f3c367ee --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-status-composition.yaml @@ -0,0 +1,58 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xmixed.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-database + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + resourceType: database + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-storage + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: storage + spec: + forProvider: + resourceType: storage + tier: standard + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-network + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: network + spec: + forProvider: + resourceType: network + tier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-xr-1.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-xr-1.yaml new file mode 100644 index 00000000..8a44ded7 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-xr-1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: mixed-test-xr-1 + namespace: default +spec: + coolField: mixed-value-1 + crossplane: + compositionRef: + name: xmixed.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-xr-2.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-xr-2.yaml new file mode 100644 index 00000000..413db4d2 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-xr-2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: mixed-test-xr-2 + namespace: default +spec: + coolField: mixed-value-2 + crossplane: + compositionRef: + name: xmixed.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/mixed-xr-3.yaml b/cmd/crossplane/project/testdata/comp/resources/mixed-xr-3.yaml new file mode 100644 index 00000000..a6fb9cd6 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/mixed-xr-3.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: mixed-test-xr-3 + namespace: default +spec: + coolField: mixed-value-3 + crossplane: + compositionRef: + name: xmixed.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml new file mode 100644 index 00000000..12a756b2 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml @@ -0,0 +1,56 @@ +# The CompositionRevision Crossplane creates for collision-composition.yaml, +# named and labelled from its hash as Crossplane names it. envtest runs no Crossplane controllers, +# so it must be declared. The XRs in existing-xr-ns-{a,b}.yaml track it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xnopresources.collision.example.org-163b210 + labels: + crossplane.io/composition-name: xnopresources.collision.example.org + crossplane.io/composition-hash: 163b2101da346b4554697172116dfe489afe2605a36a3af8122f643c3f13e81 +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Fetch ConfigMap by name - uses XR's namespace since no namespace specified + - apiVersion: v1 + kind: ConfigMap + into: nsConfig + type: Reference + ref: + name: collision-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $config := index .context "apiextensions.crossplane.io/extra-resources" "nsConfig" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: collision-resource + spec: + forProvider: + # This should contain the ConfigMap data from the XR's namespace + configData: {{ index $config "data" "value" }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition.yaml new file mode 100644 index 00000000..eff808b4 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition.yaml @@ -0,0 +1,52 @@ +# Composition that fetches a ConfigMap by name from the XR's namespace. +# This is used to test the namespace collision bug where resources with +# the same name in different namespaces collide in the cache. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.collision.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Fetch ConfigMap by name - uses XR's namespace since no namespace specified + - apiVersion: v1 + kind: ConfigMap + into: nsConfig + type: Reference + ref: + name: collision-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $config := index .context "apiextensions.crossplane.io/extra-resources" "nsConfig" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: collision-resource + spec: + forProvider: + # This should contain the ConfigMap data from the XR's namespace + configData: {{ index $config "data" "value" }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml new file mode 100644 index 00000000..bc1a9387 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml @@ -0,0 +1,8 @@ +# ConfigMap in namespace ns-a - will be fetched by XR in ns-a +apiVersion: v1 +kind: ConfigMap +metadata: + name: collision-config + namespace: ns-a +data: + value: from-ns-a diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml new file mode 100644 index 00000000..3f5ba205 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml @@ -0,0 +1,8 @@ +# ConfigMap in namespace ns-b - will be fetched by XR in ns-b +apiVersion: v1 +kind: ConfigMap +metadata: + name: collision-config + namespace: ns-b +data: + value: from-ns-b diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml new file mode 100644 index 00000000..eccd4905 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml @@ -0,0 +1,14 @@ +# Downstream resource for XR in ns-a +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: xr-in-ns-a + namespace: ns-a + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-a +spec: + forProvider: + # Current value from the ConfigMap in ns-a + configData: from-ns-a diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml new file mode 100644 index 00000000..75a16cdf --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml @@ -0,0 +1,14 @@ +# Downstream resource for XR in ns-b +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: xr-in-ns-b + namespace: ns-b + annotations: + crossplane.io/composition-resource-name: collision-resource + labels: + crossplane.io/composite: xr-in-ns-b +spec: + forProvider: + # Current value from the ConfigMap in ns-b + configData: from-ns-b diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml new file mode 100644 index 00000000..6e1aad43 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml @@ -0,0 +1,15 @@ +# XR in namespace ns-a +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: xr-in-ns-a + labels: + crossplane.io/composite: xr-in-ns-a + namespace: ns-a +spec: + coolField: test-value-a + crossplane: + compositionRef: + name: xnopresources.collision.example.org + compositionRevisionRef: + name: xnopresources.collision.example.org-163b210 diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml new file mode 100644 index 00000000..a7e9c5a0 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml @@ -0,0 +1,15 @@ +# XR in namespace ns-b +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: xr-in-ns-b + labels: + crossplane.io/composite: xr-in-ns-b + namespace: ns-b +spec: + coolField: test-value-b + crossplane: + compositionRef: + name: xnopresources.collision.example.org + compositionRevisionRef: + name: xnopresources.collision.example.org-163b210 diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml new file mode 100644 index 00000000..cbf261f5 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ns-a diff --git a/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml new file mode 100644 index 00000000..0dc47b53 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ns-b diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-child-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-child-downstream.yaml new file mode 100644 index 00000000..3094b7df --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-child-downstream.yaml @@ -0,0 +1,16 @@ +# Downstream resource created by the CHILD'S composition (xnopresources.diff.example.org). +# This resource should NOT be affected when we diff the parent composition, +# because the nested XR should use its own composition. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-nested + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + # This value comes from the CHILD's composition (xnopresources.diff.example.org) + # which uses: configData: {{ .observed.composite.resource.spec.coolField }} + configData: child-config-value + resourceTier: basic diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml new file mode 100644 index 00000000..e9ff65cb --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml @@ -0,0 +1,14 @@ +# Nested XNopResource created by the parent composition. +# This XR uses its OWN composition (xnopresources.diff.example.org) to create downstream resources. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-parent-nested + namespace: default + annotations: + crossplane.io/composition-resource-name: nested-xr +spec: + coolField: child-config-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml new file mode 100644 index 00000000..7d6b8b19 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml @@ -0,0 +1,12 @@ +# Direct downstream resource created by the parent composition +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-direct + namespace: default + annotations: + crossplane.io/composition-resource-name: direct-resource +spec: + forProvider: + configData: parent-parent-value + resourceTier: parent-tier diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-xr.yaml b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-xr.yaml new file mode 100644 index 00000000..6b88e567 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-xr.yaml @@ -0,0 +1,12 @@ +# Existing parent XR that uses the parent composition +apiVersion: parent.diff.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: parent-value + childConfig: child-config-value + crossplane: + compositionRef: + name: xparentresources.parent.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-composition.yaml new file mode 100644 index 00000000..066be354 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-composition.yaml @@ -0,0 +1,49 @@ +# Parent composition that creates a nested XNopResource. +# The nested XNopResource uses its OWN composition (xnopresources.diff.example.org) +# to create its downstream resources. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xparentresources.parent.diff.example.org +spec: + compositeTypeRef: + apiVersion: parent.diff.example.org/v1alpha1 + kind: XParentResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + # Direct downstream resource owned by the parent XR + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-direct + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: direct-resource + spec: + forProvider: + configData: parent-{{ .observed.composite.resource.spec.parentField }} + resourceTier: parent-tier + --- + # Nested XR of type XNopResource - this should use its OWN composition + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-nested + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nested-xr + spec: + coolField: {{ .observed.composite.resource.spec.childConfig }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-xrd.yaml b/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-xrd.yaml new file mode 100644 index 00000000..ffe6e36d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-xrd.yaml @@ -0,0 +1,50 @@ +# Parent XRD that creates nested XRs of type XNopResource +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xparentresources.parent.diff.example.org +spec: + group: parent.diff.example.org + names: + kind: XParentResource + plural: xparentresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parentField: + type: string + childConfig: + type: string + status: + type: object + properties: + conditions: + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition-2.yaml b/cmd/crossplane/project/testdata/comp/resources/original-composition-2.yaml new file mode 100644 index 00000000..a6605cec --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/original-composition-2.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-v2.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource-v2 + spec: + forProvider: + configData: v2-updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: enterprise + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied-current.yaml b/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied-current.yaml new file mode 100644 index 00000000..0b2fe1dd --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied-current.yaml @@ -0,0 +1,41 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org + annotations: + # Content-identical to original-composition.yaml apart from this annotation, whose value is what a + # client-side `kubectl apply` of composition-no-changes.yaml writes: that file minus this annotation, + # JSON-encoded (kubectl keeps the emptied annotations map, hence "annotations":{}). Unlike + # original-composition-kubectl-applied.yaml, whose value is stale, re-applying that file over this + # one is a no-op: kubectl recomputes the same annotation, the patch is empty, and nothing is written. + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition","metadata":{"name":"xnopresources.diff.example.org","annotations":{}},"spec":{"compositeTypeRef":{"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource"},"mode":"Pipeline","pipeline":[{"step":"generate-resources","functionRef":{"name":"function-go-templating"},"input":{"apiVersion":"template.fn.crossplane.io/v1beta1","kind":"GoTemplate","source":"Inline","inline":{"template":"apiVersion: ns.nop.example.org/v1alpha1\nkind: XDownstreamResource\nmetadata:\n name: {{ .observed.composite.resource.metadata.name }}\n namespace: {{ .observed.composite.resource.metadata.namespace }}\n annotations:\n gotemplating.fn.crossplane.io/composition-resource-name: nop-resource\nspec:\n forProvider:\n configData: {{ .observed.composite.resource.spec.coolField }}\n resourceTier: basic\n"}}},{"step":"automatically-detect-ready-composed-resources","functionRef":{"name":"function-auto-ready"}}]}} +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied.yaml b/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied.yaml new file mode 100644 index 00000000..f13c7064 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied.yaml @@ -0,0 +1,40 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org + annotations: + # Content-identical to original-composition.yaml apart from this annotation, which is what a + # client-side `kubectl apply` stamps on the cluster copy. The file a user diffs never carries it, + # so this is the asymmetry the tool must not mistake for an edit. The value below is abridged — + # only its presence matters, never its content. + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"apiextensions.crossplane.io/v1","kind":"Composition","metadata":{"name":"xnopresources.diff.example.org"},"spec":{"compositeTypeRef":{"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource"},"mode":"Pipeline"}} +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/removal-test-original-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/removal-test-original-composition.yaml new file mode 100644 index 00000000..1c07815c --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/removal-test-original-composition.yaml @@ -0,0 +1,45 @@ +# The in-cluster version of removal-test-composition, which still renders resource2. Diffing +# ../../diff/resources/removal-test-composition.yaml, which no longer does, against it removes resource2. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-removed + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource2 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-older-revision.yaml b/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-older-revision.yaml new file mode 100644 index 00000000..a6a9dff3 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-older-revision.yaml @@ -0,0 +1,40 @@ +# An earlier revision of revision-templating-composition.yaml, from when the composition carried a +# channel=stable label it has since dropped: same spec, so only its name differs from revision 2's. +# The harness names and labels it from the hash of the composition it was cut from, as Crossplane does. +# The Manual XR in existing-xr-pinned-revision-ref.yaml is pinned to it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + channel: "stable" + crossplane.io/composition-name: xrevisionrefs.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-revision.yaml b/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-revision.yaml new file mode 100644 index 00000000..b3dd5157 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-revision.yaml @@ -0,0 +1,39 @@ +# The CompositionRevision Crossplane creates for revision-templating-composition.yaml, declared rather +# than left to the harness because it is revision 2, the latest: +# revision-templating-composition-older-revision.yaml is revision 1. The Automatic XR in +# existing-xr-revision-ref.yaml tracks it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xrevisionrefs.diff.example.org +spec: + revision: 2 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition.yaml new file mode 100644 index 00000000..58423fcd --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/revision-templating-composition.yaml @@ -0,0 +1,39 @@ +# The in-cluster copy of a composition whose template reads the CompositionRevision's own name off the +# composite and propagates it into a composed resource. Functions receive the whole XR (AsState -> +# xfn.AsStruct, no field stripping), so this is legitimate, if unusual — and it is the case that makes +# "a revision is created, but it renders identically" a claim the tool has to verify rather than assume. +# +# Paired with ../revision-templating-updated-composition.yaml, which differs from this by a label only. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefs.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/sha256-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/sha256-composition.yaml new file mode 100644 index 00000000..e08a7433 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/sha256-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-sha256.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating-sha256 + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/shared-child-xrs-differing.yaml b/cmd/crossplane/project/testdata/comp/resources/shared-child-xrs-differing.yaml new file mode 100644 index 00000000..c9421c59 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/shared-child-xrs-differing.yaml @@ -0,0 +1,23 @@ +# Two XRs on the shared-child composition, in one namespace, with DIFFERENT specs, so a change to that +# composition renders its fixed-name child differently for each. No one diff is correct for both. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: shared-child-owner-a + namespace: default +spec: + coolField: value-a + crossplane: + compositionRef: + name: xnopresources-shared-child.diff.example.org +--- +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: shared-child-owner-b + namespace: default +spec: + coolField: value-b + crossplane: + compositionRef: + name: xnopresources-shared-child.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/status-downstream-1.yaml b/cmd/crossplane/project/testdata/comp/resources/status-downstream-1.yaml new file mode 100644 index 00000000..a84d5a67 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/status-downstream-1.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: status-test-xr-1 + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stable-value + resourceTier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/status-downstream-2.yaml b/cmd/crossplane/project/testdata/comp/resources/status-downstream-2.yaml new file mode 100644 index 00000000..1fee2a23 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/status-downstream-2.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: status-test-xr-2 + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stable-value + resourceTier: standard diff --git a/cmd/crossplane/project/testdata/comp/resources/status-indicator-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/status-indicator-composition.yaml new file mode 100644 index 00000000..08fea7b5 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/status-indicator-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xstatus.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-value + resourceTier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/resources/status-xr-1.yaml b/cmd/crossplane/project/testdata/comp/resources/status-xr-1.yaml new file mode 100644 index 00000000..d22c27e2 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/status-xr-1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: status-test-xr-1 + namespace: default +spec: + coolField: test-value-1 + crossplane: + compositionRef: + name: xstatus.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/status-xr-2.yaml b/cmd/crossplane/project/testdata/comp/resources/status-xr-2.yaml new file mode 100644 index 00000000..2ef34578 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/status-xr-2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: status-test-xr-2 + namespace: default +spec: + coolField: test-value-2 + crossplane: + compositionRef: + name: xstatus.diff.example.org diff --git a/cmd/crossplane/project/testdata/comp/resources/test-namespace.yaml b/cmd/crossplane/project/testdata/comp/resources/test-namespace.yaml new file mode 100644 index 00000000..f8db044d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/test-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: test-namespace diff --git a/cmd/crossplane/project/testdata/comp/resources/unverified-cluster-composition.yaml b/cmd/crossplane/project/testdata/comp/resources/unverified-cluster-composition.yaml new file mode 100644 index 00000000..5c757c43 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/resources/unverified-cluster-composition.yaml @@ -0,0 +1,15 @@ +# The in-cluster version of updated-unverified-cluster-composition.yaml: a composition for the +# cluster-scoped XNopResource that composes nothing yet. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-unverified.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/revision-templating-updated-composition.yaml b/cmd/crossplane/project/testdata/comp/revision-templating-updated-composition.yaml new file mode 100644 index 00000000..263ad44c --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/revision-templating-updated-composition.yaml @@ -0,0 +1,43 @@ +# A metadata-only edit of resources/revision-templating-composition.yaml: one added label, identical +# spec. Crossplane's Composition.Hash() covers labels, so applying this mints a new CompositionRevision +# with a byte-identical spec — and because the template below reads the revision's own name, the +# composed resource genuinely changes. +# +# Before issue #474 the tool rendered this composite with its *existing* compositionRevisionRef, saw no +# downstream delta, and exited 0. The label is the only difference; everything else must stay identical +# to the cluster copy or the test stops exercising the metadata-only path. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xrevisionrefs.diff.example.org + labels: + channel: preview +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/status-indicator-updated-composition.yaml b/cmd/crossplane/project/testdata/comp/status-indicator-updated-composition.yaml new file mode 100644 index 00000000..a6a6a6a7 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/status-indicator-updated-composition.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xstatus.diff.example.org + labels: + environment: production +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-value + resourceTier: standard + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-claim-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-claim-composition.yaml new file mode 100644 index 00000000..f619a369 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-claim-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: nopclaims.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNop + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-collision-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-collision-composition.yaml new file mode 100644 index 00000000..88a356ef --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-collision-composition.yaml @@ -0,0 +1,50 @@ +# Updated composition - adds a prefix to trigger a diff +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.collision.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Fetch ConfigMap by name - uses XR's namespace since no namespace specified + - apiVersion: v1 + kind: ConfigMap + into: nsConfig + type: Reference + ref: + name: collision-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $config := index .context "apiextensions.crossplane.io/extra-resources" "nsConfig" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: collision-resource + spec: + forProvider: + # CHANGED: Added prefix to configData + configData: prefix-{{ index $config "data" "value" }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-composition-2-changed.yaml b/cmd/crossplane/project/testdata/comp/updated-composition-2-changed.yaml new file mode 100644 index 00000000..83dfeec3 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-composition-2-changed.yaml @@ -0,0 +1,36 @@ +# A genuinely-changed version of original-composition-2.yaml (configData prefix and resourceTier +# differ). Distinct from updated-composition-2.yaml, which is byte-identical to the original and is +# used for the mixed changed/unchanged case. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-v2.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource-v2 + spec: + forProvider: + configData: v2-changed-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium-plus + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-composition-2.yaml b/cmd/crossplane/project/testdata/comp/updated-composition-2.yaml new file mode 100644 index 00000000..a6605cec --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-composition-2.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-v2.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource-v2 + spec: + forProvider: + configData: v2-updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: enterprise + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/comp/updated-composition-labeled.yaml b/cmd/crossplane/project/testdata/comp/updated-composition-labeled.yaml new file mode 100644 index 00000000..6768f115 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-composition-labeled.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org + labels: + version: 0.0.2 +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-composition.yaml new file mode 100644 index 00000000..e3e72edb --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/comp/updated-conditional-gating-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-conditional-gating-composition.yaml new file mode 100644 index 00000000..6d5a3ed9 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-conditional-gating-composition.yaml @@ -0,0 +1,57 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: conditional-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-cycle-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-cycle-composition.yaml new file mode 100644 index 00000000..a1807a2d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-cycle-composition.yaml @@ -0,0 +1,35 @@ +# testdata/diff/resources/cycle/compositions.yaml's XCycleA composition with a spec change (a label +# on the composed XCycleB), so comp evaluates its composites at every --analyze-on setting. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xcycleas.cycle.example.org +spec: + compositeTypeRef: + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleA + mode: Pipeline + pipeline: + - step: generate-cycle-b + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleB + metadata: + name: {{ .observed.composite.resource.metadata.name }}-c + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cycle-b + labels: + cycle.example.org/edited: "true" + spec: + cycleField: {{ .observed.composite.resource.spec.cycleField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-parent-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-parent-composition.yaml new file mode 100644 index 00000000..b4e7093b --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-parent-composition.yaml @@ -0,0 +1,50 @@ +# Updated parent composition - changes the direct downstream resource tier from parent-tier to premium-tier. +# The nested XNopResource and its downstream should NOT be affected because: +# 1. The nested XR uses its own composition (xnopresources.diff.example.org) +# 2. That composition is NOT being diffed here +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xparentresources.parent.diff.example.org +spec: + compositeTypeRef: + apiVersion: parent.diff.example.org/v1alpha1 + kind: XParentResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + # Direct downstream resource owned by the parent XR - CHANGED tier + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-direct + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: direct-resource + spec: + forProvider: + configData: parent-{{ .observed.composite.resource.spec.parentField }} + resourceTier: premium-tier + --- + # Nested XR of type XNopResource - unchanged + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-nested + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nested-xr + spec: + coolField: {{ .observed.composite.resource.spec.childConfig }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-sequencer-gating-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-sequencer-gating-composition.yaml new file mode 100644 index 00000000..603c3ec0 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-sequencer-gating-composition.yaml @@ -0,0 +1,53 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- + - ^stage1- diff --git a/cmd/crossplane/project/testdata/comp/updated-sha256-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-sha256-composition.yaml new file mode 100644 index 00000000..de89c21e --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-sha256-composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-sha256.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating-sha256 + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + resourceTier: premium + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-shared-child-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-shared-child-composition.yaml new file mode 100644 index 00000000..e0751a68 --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-shared-child-composition.yaml @@ -0,0 +1,36 @@ +# The shared-child composition (testdata/diff/resources/shared-child-composition.yaml) with a label and +# a changed template, so diffing it analyses the XRs that use it. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-shared-child.diff.example.org + labels: + version: 0.0.2 +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-shared-child + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: shared-child + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: shared-child + spec: + forProvider: + configData: updated-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/comp/updated-unverified-cluster-composition.yaml b/cmd/crossplane/project/testdata/comp/updated-unverified-cluster-composition.yaml new file mode 100644 index 00000000..0f80e57d --- /dev/null +++ b/cmd/crossplane/project/testdata/comp/updated-unverified-cluster-composition.yaml @@ -0,0 +1,47 @@ +# Adds two ConfigMaps in a namespace that does not exist in the cluster. +# +# A cluster-scoped XR may compose resources in any namespace (a namespaced one is confined to its +# own), which is what lets `comp` reach a missing namespace from an XR that already exists. Both +# additions' dry-run creates are refused for want of the namespace, so both are diffed unverified, +# and the warning saying so counts them. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-unverified.diff.example.org +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ .observed.composite.resource.metadata.name }}-first + namespace: nonexistent-namespace + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: first-config + data: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ .observed.composite.resource.metadata.name }}-second + namespace: nonexistent-namespace + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: second-config + data: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/cluster-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/cluster-xrd.yaml new file mode 100644 index 00000000..f7e518e4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/cluster-xrd.yaml @@ -0,0 +1,51 @@ +# This is a v2 XRD so instances of the XR will be namespace scoped. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.diff.example.org +spec: + group: diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Cluster + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/composition.yaml b/cmd/crossplane/project/testdata/diff/resources/composition.yaml new file mode 100644 index 00000000..24a2b59b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # add namespace to serve v2 + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/conditional-gating-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/conditional-gating-composition.yaml new file mode 100644 index 00000000..57c9411f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/conditional-gating-composition.yaml @@ -0,0 +1,80 @@ +# Composition-driven gating (no function-sequencer). stage1 is only rendered by +# go-templating when observed stage0 has Ready=True. This isolates eventual-state +# simulation from sequencer behavior: the gating is encoded in the template itself. +# +# Template data model: observed composed resources are at .observed.resources +# (map keyed by composition-resource-name), matching the RunFunctionRequest proto +# field State.resources. Each entry has .resource (the composed resource object). +# +# Flow under eventual-state: +# iter 1: observed.resources is empty -> stage1 guard skipped -> renders stage0 only +# synthesizeReady flips rendered stage0 Ready=True and merges into observed +# iter 2: observed.resources[stage0-resource].resource.status.conditions has +# Ready=True -> renders stage0+stage1 +# iter 3: renders stage0+stage1 again; both are observed Ready -> stable +# For an existing XR whose stage0 is observed but not Ready, iter 1 synthesizes +# its Ready on the observed copy, and the flow continues the same way (#564). +# +# Flow without eventual-state: iter 1 only. Renders stage0. Stable immediately. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: conditional-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- /* Render stage1 iff observed stage0 has a Ready=True condition. + Access pattern: .observed.resources is the protojson of State.resources + (map keyed by composition-resource-name). + Compute $ready once, then emit the manifest once to avoid + duplicates if multiple Ready=True conditions ever appear. */ -}} + {{- $ready := false -}} + {{- with .observed.resources -}} + {{- with (index . "stage0-resource") -}} + {{- with .resource.status.conditions -}} + {{- range . -}} + {{- if and (eq .type "Ready") (eq .status "True") -}} + {{- $ready = true -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- end -}} + {{- if $ready }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/cycle/compositions.yaml b/cmd/crossplane/project/testdata/diff/resources/cycle/compositions.yaml new file mode 100644 index 00000000..37830144 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/cycle/compositions.yaml @@ -0,0 +1,67 @@ +# Each composition composes the other kind, naming the child after its parent the way the nested +# fixtures do, so every level of the cycle appends another suffix to the name. The suffix is a short +# "-c" because Crossplane's generated XR CRDs cap metadata.name at 63 characters: with "-child" a real +# cluster (and the generated test CRDs) would refuse the ninth level before --max-nested-depth fires. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xcycleas.cycle.example.org +spec: + compositeTypeRef: + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleA + mode: Pipeline + pipeline: + - step: generate-cycle-b + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleB + metadata: + name: {{ .observed.composite.resource.metadata.name }}-c + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cycle-b + spec: + cycleField: {{ .observed.composite.resource.spec.cycleField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xcyclebs.cycle.example.org +spec: + compositeTypeRef: + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleB + mode: Pipeline + pipeline: + - step: generate-cycle-a + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.cycle.example.org/v1alpha1 + kind: XCycleA + metadata: + name: {{ .observed.composite.resource.metadata.name }}-c + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cycle-a + spec: + cycleField: {{ .observed.composite.resource.spec.cycleField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/cycle/xrds.yaml b/cmd/crossplane/project/testdata/diff/resources/cycle/xrds.yaml new file mode 100644 index 00000000..6754ced2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/cycle/xrds.yaml @@ -0,0 +1,100 @@ +# Two XR kinds whose compositions compose each other: XCycleA -> XCycleB -> XCycleA -> ... +# A misconfiguration Crossplane itself would recurse on forever; crossplane-diff must stop at +# --max-nested-depth with a clear error rather than recursing until the stack overflows (#485). +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xcycleas.ns.cycle.example.org +spec: + group: ns.cycle.example.org + names: + kind: XCycleA + plural: xcycleas + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + cycleField: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string +--- +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xcyclebs.ns.cycle.example.org +spec: + group: ns.cycle.example.org + names: + kind: XCycleB + plural: xcyclebs + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + cycleField: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-other-xr-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-other-xr-downstream.yaml new file mode 100644 index 00000000..23e74d32 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-other-xr-downstream.yaml @@ -0,0 +1,11 @@ +# The resource composition.yaml composes for existing-other-xr.yaml, which controls it. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: other-resource + namespace: default +spec: + forProvider: + configData: other-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-other-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-other-xr.yaml new file mode 100644 index 00000000..4653e2b9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-other-xr.yaml @@ -0,0 +1,13 @@ +# A second XR, reconciled by composition.yaml, which controls the composed resource in +# existing-other-xr-downstream.yaml. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: other-resource + namespace: default +spec: + coolField: "other-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/functions.yaml b/cmd/crossplane/project/testdata/diff/resources/functions.yaml new file mode 100644 index 00000000..9d26bbcc --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/functions.yaml @@ -0,0 +1,39 @@ +--- +# TODO: when updating these, ensure you also update the list of pulled images in Earthfile/go-test +# +# No Docker runtime annotations: the render engine labels each function's container with its run, drops any container +# name, and removes the containers when the run ends. +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-go-templating +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.4.2 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-environment-configs +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-environment-configs:v0.4.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-extra-resources +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-extra-resources:v0.2.0 +--- +apiVersion: pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-sequencer +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-sequencer:v0.5.0 diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-composition.yaml new file mode 100644 index 00000000..b386e452 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml new file mode 100644 index 00000000..4072c25a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + name: resource-to-be-kept + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml new file mode 100644 index 00000000..385389d9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource2 + name: resource-to-be-removed + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml new file mode 100644 index 00000000..fb14f03a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml @@ -0,0 +1,15 @@ +# A resource test-resource once composed as resource3, whose controller reference has since been +# removed (say, by a user orphaning it). It keeps its labels and annotations, and the XR's resourceRefs +# still name it (existing-xr-listing-uncontrolled-resources.yaml). +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource3 + labels: + crossplane.io/composite: test-resource + name: resource-without-controller + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-gating-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-gating-composition.yaml new file mode 100644 index 00000000..2c912698 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-gating-composition.yaml @@ -0,0 +1,68 @@ +# Composition used by tests that need function-sequencer to actually gate later stages. +# +# IMPORTANT notes: +# +# 1. function-sequencer matches on the resource's composition-resource-name +# (the SDK's resource.Name), NOT on metadata.name. The rules use regexes that +# match the composition-resource-names emitted by go-templating +# (stage0-resource / stage1-resource). +# +# 2. function-sequencer's gating checks DesiredComposed.Ready == True for earlier +# stages. DesiredComposed.Ready is set by function-auto-ready based on the +# observed state. Therefore auto-ready MUST run BEFORE sequencer for eventual- +# state to propagate (synthesize observed Ready -> auto-ready sets desired +# Ready -> sequencer unlocks the next stage). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-gating-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + # auto-ready FIRST so sequencer sees desired.stage0.Ready reflecting observed. + - step: mark-ready-from-observed + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- # Stage 0 (composition-resource-name starts with stage0-) + - ^stage1- # Stage 1 (gated until stage0- desired.Ready==True) diff --git a/cmd/crossplane/project/testdata/diff/resources/shared-child-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/shared-child-composition.yaml new file mode 100644 index 00000000..c8e42f17 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/shared-child-composition.yaml @@ -0,0 +1,35 @@ +# Composes one child with a FIXED name, whatever the XR is called, so two XRs using this composition +# in one namespace both render the same object. In the cluster the first XR to create it keeps it and +# the other fails to reconcile it, so crossplane-diff must refuse to combine their diffs (#476). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-shared-child.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-shared-child + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: shared-child + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: shared-child + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/xrd.yaml new file mode 100644 index 00000000..b4b57069 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/xrd.yaml @@ -0,0 +1,51 @@ +# This is a v2 XRD so instances of the XR will be namespace scoped. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/shared-child-xrs.yaml b/cmd/crossplane/project/testdata/diff/shared-child-xrs.yaml new file mode 100644 index 00000000..d5fd3734 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/shared-child-xrs.yaml @@ -0,0 +1,24 @@ +# Two distinct XRs on the shared-child composition, in one namespace and with the SAME spec, so they +# render byte-identical children. They still contend for that child: the collision is decided by who +# would control it, not by whether the renderings match. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: shared-child-owner-a + namespace: default +spec: + coolField: "same-value" + crossplane: + compositionRef: + name: xnopresources-shared-child.diff.example.org +--- +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: shared-child-owner-b + namespace: default +spec: + coolField: "same-value" + crossplane: + compositionRef: + name: xnopresources-shared-child.diff.example.org diff --git a/cmd/crossplane/project/testdata/project/dupcomps/apis/a.yaml b/cmd/crossplane/project/testdata/project/dupcomps/apis/a.yaml new file mode 100644 index 00000000..b82cb372 --- /dev/null +++ b/cmd/crossplane/project/testdata/project/dupcomps/apis/a.yaml @@ -0,0 +1,8 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: dup +spec: + compositeTypeRef: + apiVersion: example.org/v1 + kind: XThing diff --git a/cmd/crossplane/project/testdata/project/dupcomps/apis/b.yaml b/cmd/crossplane/project/testdata/project/dupcomps/apis/b.yaml new file mode 100644 index 00000000..b82cb372 --- /dev/null +++ b/cmd/crossplane/project/testdata/project/dupcomps/apis/b.yaml @@ -0,0 +1,8 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: dup +spec: + compositeTypeRef: + apiVersion: example.org/v1 + kind: XThing diff --git a/cmd/crossplane/project/testdata/project/dupcomps/crossplane-project.yaml b/cmd/crossplane/project/testdata/project/dupcomps/crossplane-project.yaml new file mode 100644 index 00000000..e99141a9 --- /dev/null +++ b/cmd/crossplane/project/testdata/project/dupcomps/crossplane-project.yaml @@ -0,0 +1,6 @@ +apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: it-project +spec: + repository: xpkg.crossplane.io/example/it-project diff --git a/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index eb37ba23..d81f45e0 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` and `project simulate` first, then -`composition simulate` and `xr simulate` as follow-ups in the same stack. +engine: `resource simulate`, `project simulate` and `composition simulate` +first, then `xr simulate` later 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. diff --git a/internal/diff/testutils/utils.go b/internal/diff/testutils/utils.go index bd859f4c..9a5e5a42 100644 --- a/internal/diff/testutils/utils.go +++ b/internal/diff/testutils/utils.go @@ -1,6 +1,8 @@ package testutils import ( + "regexp" + "strings" "testing" "github.com/go-logr/logr/testr" @@ -22,6 +24,55 @@ const ( ColorReset = "\x1b[0m" ) +// color takes a multiline string, splits it by line, and adds the specified coloring to each line. +// It returns a single string with all lines joined back together. +func color(colorCode, input string) string { + lines := strings.Split(input, "\n") + coloredLines := make([]string, 0, len(lines)) + + for _, line := range lines { + // Handle the case of the last empty line after a newline + if line == "" && len(coloredLines) == len(lines)-1 { + coloredLines = append(coloredLines, "") + continue + } + + coloredLines = append(coloredLines, colorCode+line+ColorReset) + } + + return strings.Join(coloredLines, "\n") +} + +// Green takes a multiline string, splits it by line, and adds green coloring to each line. +// It returns a single string with all lines joined back together. +func Green(input string) string { + return color(ColorGreen, input) +} + +// Red takes a multiline string, splits it by line, and adds red coloring to each line. +// It returns a single string with all lines joined back together. +func Red(input string) string { + return color(ColorRed, input) +} + +// Yellow takes a multiline string, splits it by line, and adds yellow coloring to each line. +// It returns a single string with all lines joined back together. +func Yellow(input string) string { + return color(ColorYellow, input) +} + +var ansiPattern = regexp.MustCompile("\x1b\\[[0-9;]*m") + +// CompareIgnoringAnsi compares two strings after stripping ANSI special characters. +func CompareIgnoringAnsi(expected, actual string) bool { + // Strip ANSI codes from both strings + expectedStripped := ansiPattern.ReplaceAllString(expected, "") + actualStripped := ansiPattern.ReplaceAllString(actual, "") + + // Compare the stripped strings + return expectedStripped == actualStripped +} + // TestLogger coerces the T.Log into the shape of a Logr logger. func TestLogger(t *testing.T, verbose bool) logging.Logger { verbosity := 0 diff --git a/internal/simulate/flags.go b/internal/simulate/flags.go index 27ab5299..410ddf7a 100644 --- a/internal/simulate/flags.go +++ b/internal/simulate/flags.go @@ -64,8 +64,8 @@ type Flags struct { DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify with a server-side dry run: existing or all (default)."` SkipPlan bool `help:"Don't run provider diff servers; show only the cluster diff."` - Providers string `help:"YAML file or directory of Provider manifests to run as diff servers, instead of discovering them from the cluster." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` - ImageConfigs string `help:"YAML file of ImageConfig manifests that rewrite package paths for pulls, replacing the project's imageConfigs." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` + Providers string `help:"YAML file or directory of Provider manifests to run as diff servers, instead of discovering them from the cluster." name:"providers" optional:"" predictor:"yaml_file_or_directory" type:"path"` + ImageConfigs string `help:"YAML file of ImageConfig manifests that rewrite package paths for pulls, replacing a project's imageConfigs; without a project, only these apply." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` Impersonation kube.ImpersonationFlags `embed:""` @@ -99,7 +99,7 @@ func (c *CompositionFlags) Validate() error { return nil } -// Hooks replace what project simulate otherwise takes from its +// Hooks replace what a simulate command otherwise takes from its // environment, so a test can run the real command against a cluster it // started. Fields left zero keep the CLI's behavior. type Hooks struct { diff --git a/internal/simulate/help/imageconfigs.md b/internal/simulate/help/imageconfigs.md index 3286f735..b38a5112 100644 --- a/internal/simulate/help/imageconfigs.md +++ b/internal/simulate/help/imageconfigs.md @@ -4,4 +4,6 @@ of the functions the render runs, except a project's embedded functions, provider images, and a project's function and provider dependencies. When simulating a project, it replaces the project's imageConfigs for those pulls rather than adding to them; schema generation images still use the project's -imageConfigs, and without the flag the project's imageConfigs apply. +imageConfigs, and without the flag the project's imageConfigs apply. A command +that takes files rather than a project uses only the flag's ImageConfigs, and +none without it; it never reads a project file in the current directory. diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go new file mode 100644 index 00000000..a6260e0e --- /dev/null +++ b/internal/simulate/inputs.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "fmt" + + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + ld "github.com/crossplane/cli/v2/pkg/load" +) + +// 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 { + gvk := r.GroupVersionKind() + msg := fmt.Sprintf("-f inputs include %s %q (%s); %s", gvk.Kind, r.GetName(), gvk.GroupVersion(), takes) + if r.IsList() || gvk.Kind == "List" { + msg += "; pass its items as separate YAML documents instead of a List" + } + return errors.New(msg) +} + +// LoadInputs loads the -f inputs at paths ("-" is stdin) and rejects any +// for which validate returns an error. +func LoadInputs(paths []string, validate func(*un.Unstructured) error) ([]*un.Unstructured, error) { + // Pipeline step inputs are part of their Composition, not resources of + // their own. + loader, err := ld.NewExactCompositeLoader(paths, ld.WithoutPipelineInputs()) + if err != nil { + return nil, errors.Wrap(err, "cannot load -f inputs") + } + resources, err := loader.Load() + if err != nil { + return nil, errors.Wrap(err, "cannot load -f inputs") + } + for _, r := range resources { + if err := validate(r); err != nil { + return nil, err + } + } + return resources, nil +} + +// RejectDuplicateNames returns an error if two of the Compositions share a +// name, since both would be diffed against the same live Composition. +func RejectDuplicateNames(comps []*un.Unstructured) error { + seen := map[string]bool{} + for _, c := range comps { + if seen[c.GetName()] { + return errors.Errorf("Composition %q is defined more than once; Composition names must be unique", c.GetName()) + } + seen[c.GetName()] = true + } + return nil +} diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go new file mode 100644 index 00000000..837f5f62 --- /dev/null +++ b/internal/simulate/inputs_test.go @@ -0,0 +1,213 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package simulate + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +func TestLoadInputs(t *testing.T) { + dir := t.TempDir() + write := func(name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return path + } + + commaComp := write("a,b.yaml", "apiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: comma\n") + proj := write("proj/crossplane-project.yaml", "apiVersion: dev.crossplane.io/v1alpha1\nkind: Project\nmetadata:\n name: p\n") + comp := write("comp.yaml", `apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: c +spec: + compositeTypeRef: + apiVersion: example.org/v1 + kind: XThing + mode: Pipeline + pipeline: + - step: render + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline +`) + compsPipeline := write("pipelinedir/p.yaml", `apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: p +spec: + compositeTypeRef: + apiVersion: example.org/v1 + kind: XThing + mode: Pipeline + pipeline: + - step: render + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline +`) + // A step input that isn't an object is the engine's to report, not the + // loader's. + listInput := write("listinput.yaml", `apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: l +spec: + compositeTypeRef: + apiVersion: example.org/v1 + kind: XThing + mode: Pipeline + pipeline: + - step: render + functionRef: + name: function-go-templating + input: [1] +`) + xr := write("xr.yaml", "apiVersion: example.org/v1\nkind: XThing\nmetadata:\n name: x\n") + // A GoTemplate of its own is a resource, not a pipeline input. + goTemplate := write("gotemplate.yaml", "apiVersion: gotemplating.fn.crossplane.io/v1beta1\nkind: GoTemplate\nmetadata:\n name: g\nsource: Inline\n") + comps := write("comps/a.yaml", "apiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: a\n") + list := write("list.yaml", "apiVersion: v1\nkind: List\nitems:\n- apiVersion: apiextensions.crossplane.io/v1\n kind: Composition\n metadata:\n name: a\n") + empty := write("empty.yaml", "# nothing here\n") + + type want struct { + names []string + err string + } + + cases := map[string]struct { + reason string + paths []string + want want + }{ + "Composition": { + reason: "A Composition should load; its pipeline inputs are part of it.", + paths: []string{comp}, + want: want{names: []string{"c"}}, + }, + "CompositionListInput": { + reason: "A Composition should be loaded without parsing its pipeline inputs as resources.", + paths: []string{listInput}, + want: want{names: []string{"l"}}, + }, + "CompositionDirectory": { + reason: "A directory of Compositions should load.", + paths: []string{filepath.Dir(comps)}, + want: want{names: []string{"a"}}, + }, + "CompositionDirectoryPipelineInput": { + reason: "A directory's Compositions should load without their pipeline inputs as resources.", + paths: []string{filepath.Dir(compsPipeline)}, + want: want{names: []string{"p"}}, + }, + "Compositions": { + reason: "Several Composition files should load in order.", + paths: []string{comp, comps}, + want: want{names: []string{"c", "a"}}, + }, + "CommaInPath": { + reason: "-f never splits on commas, so a,b.yaml is one path.", + paths: []string{commaComp}, + want: want{names: []string{"comma"}}, + }, + "DuplicateName": { + reason: "Two Compositions with one name should be rejected.", + paths: []string{comps, comps}, + want: want{err: `Composition "a" is defined more than once`}, + }, + "ProjectFile": { + reason: "A project file should be rejected with a pointer to project simulate.", + paths: []string{proj}, + want: want{err: `-f inputs include Project "p" (dev.crossplane.io/v1alpha1); composition simulate takes Composition files`}, + }, + "CompositionWithXR": { + reason: "Compositions mixed with other resources should be rejected.", + paths: []string{comp, xr}, + want: want{err: `-f inputs include XThing "x" (example.org/v1); composition simulate takes Composition files`}, + }, + "CompositionWithStandaloneGoTemplate": { + reason: "A resource that only looks like a pipeline input should still be rejected.", + paths: []string{comp, goTemplate}, + 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.", + 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`}, + }, + "Empty": { + reason: "Inputs with no resources should be rejected.", + paths: []string{empty}, + want: want{err: "no resources found"}, + }, + "Missing": { + reason: "A missing input should be rejected.", + paths: []string{filepath.Join(dir, "nope.yaml")}, + want: want{err: "cannot load -f inputs"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := LoadInputs(tc.paths, func(r *un.Unstructured) error { + if r.GroupVersionKind() != apiextensionsv1.CompositionGroupVersionKind { + return RejectInput(r, "composition simulate takes Composition files") + } + return nil + }) + if err == nil { + err = RejectDuplicateNames(got) + } + if tc.want.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.want.err) { + t.Fatalf("\n%s\nLoadInputs(%v): want error containing %q, got %v", tc.reason, tc.paths, tc.want.err, err) + } + return + } + if err != nil { + t.Fatalf("\n%s\nLoadInputs(%v): %v", tc.reason, tc.paths, err) + } + names := []string{} + for _, r := range got { + names = append(names, r.GetName()) + } + if diff := cmp.Diff(tc.want.names, names); diff != "" { + t.Errorf("\n%s\nLoadInputs(%v): -want, +got:\n%s", tc.reason, tc.paths, diff) + } + }) + } +} diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 6fc2cb57..3edbd084 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -126,7 +126,7 @@ func compositeResult(impact dt.XRImpact) CompositeResult { c := CompositeResult{Ref: resourceRef(impact.ObjectReference), Pinned: impact.Pinned || impact.FilterReason == dt.FilterReasonManualPolicy} if impact.Pinned && impact.RenderedRevision != "" { - c.Detail = "rendered against pinned revision " + impact.RenderedRevision + " with the project's functions" + c.Detail = "rendered against pinned revision " + impact.RenderedRevision + " with the changed functions" } switch impact.Status { diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index d4d3c9e6..7d5e69f0 100644 --- a/internal/simulate/report_test.go +++ b/internal/simulate/report_test.go @@ -203,7 +203,7 @@ func TestFromCompReport(t *testing.T) { Summary: plan.Summary{NoOp: 1}, Plans: []*plan.ResourcePlan{compNoOp}, }, - Composites: []CompositeResult{{Ref: xrPlan, Status: CompositeChanged, Composition: &compPlan, Pinned: true, Detail: "rendered against pinned revision net-6 with the project's functions"}}, + Composites: []CompositeResult{{Ref: xrPlan, Status: CompositeChanged, Composition: &compPlan, Pinned: true, Detail: "rendered against pinned revision net-6 with the changed functions"}}, Compositions: []CompositionResult{{Ref: compPlan}}, Notes: notes(), }, diff --git a/internal/simulate/result_test.go b/internal/simulate/result_test.go index 968a86c9..c65078f4 100644 --- a/internal/simulate/result_test.go +++ b/internal/simulate/result_test.go @@ -82,10 +82,10 @@ func TestRenderPlanCompositeDetail(t *testing.T) { xr := plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod-network"} r := ProjectResult{ Result: plan.Result{}, - Composites: []CompositeResult{{Ref: xr, Status: CompositeUnchanged, Pinned: true, Detail: "rendered against pinned revision net-6 with the project's functions"}}, + Composites: []CompositeResult{{Ref: xr, Status: CompositeUnchanged, Pinned: true, Detail: "rendered against pinned revision net-6 with the changed functions"}}, } want := `XR: XNetwork.example.org prod-network - rendered against pinned revision net-6 with the project's functions + rendered against pinned revision net-6 with the changed functions Plan: 0 to add, 0 to change, 0 to destroy. ` diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 21ad04c8..1e396ab5 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -17,6 +17,7 @@ limitations under the License. package simulate import ( + "cmp" "context" "fmt" "io" @@ -55,15 +56,18 @@ import ( "github.com/crossplane/cli/v2/internal/kube" "github.com/crossplane/cli/v2/internal/project" "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" xprender "github.com/crossplane/cli/v2/pkg/render" ) // Packages is how a simulation fetches packages. Functions and providers go // through one set of ImageConfigs, so --image-configs rewrites both. type Packages struct { - ICs []pkgv1beta1.ImageConfig + ICs []pkgv1beta1.ImageConfig + // Client is set by Run. Client runtimexpkg.Client + // Proj and DepMgr are set for a project. Proj *v1alpha1.Project DepMgr *dependency.Manager } @@ -71,7 +75,8 @@ type Packages struct { // planOptions sets up provider diff servers and the XRPlanner, and returns // the processor options that plan each composite resource's composed // resources. Namespaced resources without a namespace plan in ns, the -// kubeconfig's namespace. pkgs is the project's. Run stops c.servers when it is set. Planning +// kubeconfig's namespace. pkgs is the project's, or without a project has +// only ICs and Client. Run stops c.servers when it is set. Planning // shares the engine's clients' REST mapper and CRD cache, so a run discovers // the cluster once and fetches each CRD at most once. func (c *Flags) planOptions(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, restCfg *rest.Config, clients *client.Clients, ns string, pkgs *Packages) ([]dp.ProcessorOption, error) { @@ -89,9 +94,10 @@ func (c *Flags) planOptions(sigCtx context.Context, k *kong.Context, logger logg router = c.servers } + var ensure EnsureFunc if c.servers != nil { var upfront []diffserver.Input - switch providerSource(c.Providers) { + switch providerSource(c.Providers, pkgs.Proj != nil) { case providersFlag: upfront, err = LoadProviderInputs(c.Providers) if err != nil { @@ -103,14 +109,22 @@ func (c *Flags) planOptions(sigCtx context.Context, k *kong.Context, logger logg return nil, errors.Wrap(err, "cannot load the project's provider dependencies") } upfront = diffserver.Images(images) + case providersPerXR: + cache := NewImageCache() + ensure = func(ctx context.Context, rs []*un.Unstructured) error { + imgs := RelevantProviderImages(ctx, kubeClient, rmapper, crds, rs, len(pkgs.ICs) > 0, cache, k.Stderr) + return EnsureServers(ctx, sp, k.Stderr, c.servers, diffserver.Images(imgs)) + } } - if err := EnsureServers(sigCtx, sp, k.Stderr, c.servers, upfront); err != nil { - return nil, err + if ensure == nil { + if err := EnsureServers(sigCtx, sp, k.Stderr, c.servers, upfront); err != nil { + return nil, err + } } } c.keep = ProviderManaged(sigCtx, rmapper, crds) - c.xrPlanner = NewXRPlanner(NewPlanner(kubeClient, rmapper, crds, ns, router, c.Timeout), c.keep, nil) + c.xrPlanner = NewXRPlanner(NewPlanner(kubeClient, rmapper, crds, ns, router, c.Timeout), c.keep, ensure) return []dp.ProcessorOption{dp.WithAfterXRDiff(c.xrPlanner.AfterXRDiff), dp.WithKeepEqualDiffs(c.keep)}, nil } @@ -122,15 +136,23 @@ const ( providersFlag source = iota // providersProject starts the project's provider dependencies up front. providersProject + // providersPerXR starts the providers each composite resource needs as + // it is simulated. + providersPerXR ) // providerSource picks where provider diff servers come from: --providers -// when set, else the project's provider dependencies. -func providerSource(providers string) source { - if providers != "" { +// when set, else the project's provider dependencies when there is a +// project, else per composite resource. +func providerSource(providers string, project bool) source { + switch { + case providers != "": return providersFlag + case project: + return providersProject + default: + return providersPerXR } - return providersProject } // noteNoServers prints NoteNoDiffServers to stderr once, for -o @@ -191,7 +213,8 @@ func changedFunctions(ctx context.Context, rc k8client.ResourceClient, digests m // diffCompositions returns the diff simulate runs for Compositions: their // impact on the composite resources in scope, rendered to k's writers. -func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp.CompDiffProcessor, comps []*un.Unstructured) func(context.Context) (bool, error) { +// Errors are wrapped with msg. +func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp.CompDiffProcessor, comps []*un.Unstructured, msg string) func(context.Context) (bool, error) { return func(ctx context.Context) (bool, error) { output, err := proc.DiffComposition(ctx, comps, c.sel.ns, c.sel.refs) @@ -202,7 +225,7 @@ func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp if output != nil { changed, err = c.renderCompReport(k, logger, output, err) } - return changed, errors.Wrap(err, "cannot simulate project") + return changed, errors.Wrap(err, msg) } } @@ -494,16 +517,18 @@ func isTerminal(w io.Writer) bool { return ok && term.IsTerminal(f.Fd()) } -// Run previews a project's compositions, with its functions built locally, -// against the cluster. +// Run previews in against the cluster: a project's Compositions, with its +// functions built locally, or Composition files, with the cluster's +// functions. func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, in *Inputs) error { if in.Selection != nil { c.sel = *in.Selection } - warnIgnoredFlags(k.Stderr, setFlags(k), c.SkipPlan, true) + project := in.Build != nil + warnIgnoredFlags(k.Stderr, setFlags(k), c.SkipPlan, project) if err := docker.Check(sigCtx); err != nil { - return errors.Wrap(err, "cannot connect to Docker, which project simulate needs to run functions") + return errors.Wrap(err, "cannot connect to Docker, which simulate needs to run functions") } var creds []corev1.Secret @@ -522,9 +547,27 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg return err } - built, err := in.Build(sigCtx) - if err != nil { - 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") + } + } } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages @@ -555,7 +598,18 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg } xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) - return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps)) + return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, msg)) +} + +// 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 +// the user passed. +func (c *Flags) flagImageConfigs() ([]pkgv1beta1.ImageConfig, error) { + if c.ImageConfigs == "" { + return nil, nil + } + return ImageConfigs(c.ImageConfigs, "") } // warnIgnoredFlags prints a warning to stderr for each flag set on the @@ -589,8 +643,13 @@ 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 func(ctx context.Context) (*Built, error) + // Compositions are the Compositions to simulate, with the cluster's + // functions, when Build is nil. + Compositions []*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 e490d981..56ef07c9 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -557,6 +557,8 @@ func TestSimulateRun(t *testing.T) { initClients func(context.Context, logging.Logger) error proc *fakeCompProc cancel bool + // files runs as composition simulate rather than project simulate. + files bool } type want struct { code int @@ -635,6 +637,20 @@ func TestSimulateRun(t *testing.T) { }}}, want: want{code: dp.ExitCodeToolError, err: errBoom, diffed: true}, }, + "ProjectDiffErrorWrap": { + reason: "A project's simulation error should name the project.", + args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { + return nil, errBoom + }}}, + want: want{code: dp.ExitCodeToolError, err: errBoom, errMsg: "cannot simulate project: boom", diffed: true}, + }, + "CompositionDiffErrorWrap": { + reason: "A Composition file's simulation error should name the Compositions.", + args: args{files: true, initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { + return nil, errBoom + }}}, + want: want{code: dp.ExitCodeToolError, err: errBoom, errMsg: "cannot simulate Compositions: boom", diffed: true}, + }, "SchemaValidationError": { reason: "A simulation that failed only schema validation should exit 2.", args: args{initClients: noInit, proc: &fakeCompProc{diffFn: func(context.Context) (*dt.CompDiffOutput, error) { @@ -664,7 +680,11 @@ func TestSimulateRun(t *testing.T) { tc.args.cmd.Output = "plan" } k := &kong.Context{Kong: &kong.Kong{Stdout: &bytes.Buffer{}, Stderr: &bytes.Buffer{}}} - diff := tc.args.cmd.diffCompositions(k, logging.NewNopLogger(), tc.args.proc, []*un.Unstructured{{}}) + msg := "cannot simulate project" + if tc.args.files { + msg = "cannot simulate Compositions" + } + diff := tc.args.cmd.diffCompositions(k, logging.NewNopLogger(), tc.args.proc, []*un.Unstructured{{}}, msg) err := tc.args.cmd.simulate(ctx, logging.NewNopLogger(), tc.args.initClients, tc.args.proc, diff) if diff := cmp.Diff(tc.want.code, exitCode(err)); diff != "" { @@ -838,6 +858,7 @@ func TestProviderSource(t *testing.T) { cases := map[string]struct { reason string providers string + project bool want source }{ "Flag": { @@ -845,14 +866,25 @@ func TestProviderSource(t *testing.T) { providers: "providers.yaml", want: providersFlag, }, - "WithoutFlag": { - reason: "Without --providers the project's provider dependencies start.", - want: providersProject, + "FlagWithProject": { + reason: "--providers wins even when a project is loaded.", + providers: "providers.yaml", + project: true, + want: providersFlag, + }, + "Project": { + reason: "Without --providers the project's provider dependencies start.", + project: true, + want: providersProject, + }, + "NoProject": { + reason: "Without --providers or a project, each composite resource's providers start as it is simulated.", + want: providersPerXR, }, } for name, tc := range cases { t.Run(name, func(t *testing.T) { - if got := providerSource(tc.providers); got != tc.want { + if got := providerSource(tc.providers, tc.project); got != tc.want { t.Errorf("%s\nproviderSource(%q): want %v, got %v", tc.reason, tc.providers, tc.want, got) } }) @@ -908,3 +940,21 @@ func parseFlags(t *testing.T, args ...string) (*Flags, error) { _, err = parser.Parse(append([]string{"simulate"}, args...)) 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. + 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) + } + if len(ics) != 0 { + t.Errorf("flagImageConfigs(): want no ImageConfigs, got %v", ics) + } +} diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go new file mode 100644 index 00000000..48fe549e --- /dev/null +++ b/internal/simulate/simtest/plan_assertions.go @@ -0,0 +1,820 @@ +package simtest + +import ( + "encoding/json" + "fmt" + "reflect" + "regexp" + "slices" + "strings" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" + "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: +// +// - 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; +// - a composition is a Compositions entry plus its own (ungrouped) plan; +// - an error's resourceID ("Kind/name") is the Kind and Name of its Errors[].resource. + +// changeAction maps a builder change word onto its plan action, empty for an unknown word. +func changeAction(changeType string) plan.Action { + switch changeType { + case dt.DiffTypeWordAdded: + return plan.ActionCreate + case dt.DiffTypeWordModified: + return plan.ActionUpdate + case dt.DiffTypeWordRemoved: + return plan.ActionDelete + default: + return "" + } +} + +// statusFiltered is crossplane-diff's status for a composite left out of impact analysis, which +// simulate.ProjectResult 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(). +func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { + t.Helper() + + expected := e.expectation() + + var res simulate.ProjectResult + 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) + } + + assertWarnings(t, res.Warnings, warningMatches(expected.warnings, func(w *WarningExpectation) warningMatch { return w.warningMatch }), expected.noWarnings) + + for _, id := range expected.errorIDs { + if findMatchingError(res.Errors, id) == nil { + t.Errorf("Expected top-level error with resourceID %q not found. Actual error IDs: %v", id, errorIDs(res.Errors)) + } + } + + for _, want := range expected.compositions { + assertComposition(t, res, want) + } +} + +func assertComposition(t *testing.T, res simulate.ProjectResult, want *CompositionExpectation) { + t.Helper() + + i := slices.IndexFunc(res.Compositions, func(c simulate.CompositionResult) bool { return c.Ref.Name == want.name }) + if i < 0 { + names := make([]string, 0, len(res.Compositions)) + for _, c := range res.Compositions { + names = append(names, c.Ref.Name) + } + + t.Errorf("Expected composition %s not found in output. Actual compositions: %v", want.name, names) + + return + } + + comp := res.Compositions[i] + label := "Composition " + want.name + compPlan := findPlan(res.Plans, comp.Ref, nil) + + composites := slices.DeleteFunc(slices.Clone(res.Composites), func(c simulate.CompositeResult) bool { + return c.Composition == nil || *c.Composition != comp.Ref + }) + + assertAffected(t, label, composites, want) + + if want.impactAnalysisSkipped != nil && comp.ImpactAnalysisSkipped != *want.impactAnalysisSkipped { + t.Errorf("%s: ImpactAnalysisSkipped: expected %t, got %t", label, *want.impactAnalysisSkipped, comp.ImpactAnalysisSkipped) + } + + assertRevisionImpact(t, label, comp.RevisionImpact, want) + + if want.compositionChangeType != "" { + switch { + case compPlan == nil: + t.Errorf("%s: expected composition changes but found no plan for it", label) + case compPlan.Action != changeAction(want.compositionChangeType): + t.Errorf("%s: expected action %s, got %q (error %q)", label, changeAction(want.compositionChangeType), compPlan.Action, compPlan.Error) + } + } + + for _, wantXR := range want.xrImpacts { + assertXRImpact(t, res, label, composites, wantXR) + } +} + +// assertAffected checks the composition's affected-composite counts. Total counts every composite the +// composition reaches, filtered ones included. +func assertAffected(t *testing.T, label string, composites []simulate.CompositeResult, want *CompositionExpectation) { + t.Helper() + + count := func(f func(simulate.CompositeResult) bool) int { + n := 0 + for _, c := range composites { + if f(c) { + n++ + } + } + + return n + } + status := func(s simulate.CompositeStatus) func(simulate.CompositeResult) bool { + return func(c simulate.CompositeResult) bool { return c.Status == s } + } + + check := func(name string, want *int, got int) { + if want != nil && got != *want { + t.Errorf("%s: AffectedResources.%s: expected %d, got %d", label, name, *want, got) + } + } + + check("Total", want.affectedTotal, len(composites)) + check("WithChanges", want.affectedWithChanges, count(status(simulate.CompositeChanged))) + check("Unchanged", want.affectedUnchanged, count(status(simulate.CompositeUnchanged))) + check("WithErrors", want.affectedWithErrors, count(status(simulate.CompositeError))) + check("FilteredByDeletion", want.affectedFilteredByDeletion, count(func(c simulate.CompositeResult) bool { + return c.Status == simulate.CompositeSkipped && c.Reason == simulate.CompositeReasonDeleting + })) +} + +func assertRevisionImpact(t *testing.T, label string, got *simulate.RevisionImpact, want *CompositionExpectation) { + t.Helper() + + if want.revisionImpact == nil && want.predictedRevisionName == nil { + return + } + + if got == nil { + if want.revisionImpact != nil || want.predictedRevisionName.pattern != "" { + t.Errorf("%s: expected a revisionImpact, but none was reported", label) + } + + return + } + + if w := want.revisionImpact; w != nil { + if got.ChangeScope != w.changeScope { + t.Errorf("%s: RevisionImpact.ChangeScope: expected %q, got %q", label, w.changeScope, got.ChangeScope) + } + + if got.CreatesRevision != w.createsRevision { + t.Errorf("%s: RevisionImpact.CreatesRevision: expected %t, got %t", label, w.createsRevision, got.CreatesRevision) + } + + if got.RepointedComposites != w.repointedComposites { + t.Errorf("%s: RevisionImpact.RepointedComposites: expected %d, got %d", label, w.repointedComposites, got.RepointedComposites) + } + } + + w := want.predictedRevisionName + switch { + case w == nil: + case w.pattern == "": + if got.PredictedRevision != "" { + t.Errorf("%s: RevisionImpact.PredictedRevision: expected absent, got %q", label, got.PredictedRevision) + } + default: + matched, err := regexp.MatchString(w.pattern, got.PredictedRevision) + + switch { + case err != nil: + t.Errorf("%s: RevisionImpact.PredictedRevision: invalid pattern %q: %v", label, w.pattern, err) + case !matched: + t.Errorf("%s: RevisionImpact.PredictedRevision: %q does not match pattern %q", label, got.PredictedRevision, w.pattern) + } + } +} + +func assertXRImpact(t *testing.T, res simulate.ProjectResult, 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) + }) + if i < 0 { + t.Errorf("%s: Expected XR impact %s/%s (ns=%s) not found. Actual XRs: %v", + label, want.kind, want.name, want.namespace, compositeIDs(composites)) + + return + } + + got := composites[i] + label = fmt.Sprintf("%s: XR %s/%s", label, want.kind, got.Ref.Name) + + if !statusMatches(got.Status, want.status) { + t.Errorf("%s: expected status %s, got %s", label, want.status, got.Status) + } + + if want.filterReason != "" && string(got.Reason) != want.filterReason { + t.Errorf("%s: expected filterReason %s, got %s", label, want.filterReason, got.Reason) + } + + errs := slices.DeleteFunc(slices.Clone(res.Errors), func(e simulate.Error) bool { return e.Ref == nil || *e.Ref != got.Ref }) + + if want.errorContains != "" { + msgs := make([]string, 0, len(errs)) + for _, e := range errs { + msgs = append(msgs, e.Message) + } + + if text := strings.Join(msgs, "; "); !strings.Contains(text, want.errorContains) { + t.Errorf("%s: expected error containing %q, got %q", label, want.errorContains, text) + } + } + + for _, id := range want.errorIDs { + if findMatchingError(errs, id) == nil { + t.Errorf("%s: expected error with resourceID %q not found. Actual error IDs: %v", label, id, errorIDs(errs)) + } + } + + downstream := changePlans(res.Plans, groupedUnder(got.Ref)) + + if s := want.downstreamSummary; s != nil { + create, update, destroy, replace := countActions(downstream) + assertSummary(t, label+": downstream summary", s, create, update, destroy, replace) + } + + for _, wantRes := range want.downstreamResources { + p := findMatchingPlan(downstream, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace) + if p == nil { + t.Errorf("%s: expected downstream %s resource %s/%s not found. Actual: %v", + label, wantRes.changeType, wantRes.kind, wantRes.name, planIDs(downstream)) + + continue + } + + id := fmt.Sprintf("%s: downstream %s %s/%s", label, wantRes.changeType, wantRes.kind, p.Name) + assertDryRun(t, id, p.DryRun, wantRes.dryRun) + assertFields(t, id, p, wantRes.changeType, wantRes.fieldValues, wantRes.fieldChanges) + } +} + +// statusMatches compares a composite's status with crossplane-diff's, whose "filtered" simulate.ProjectResult +// splits into deferred and skipped. +func statusMatches(got simulate.CompositeStatus, want string) bool { + if want == statusFiltered { + // Assert filterReason when it matters which of deferred or skipped applies. + return got == simulate.CompositeDeferred || got == simulate.CompositeSkipped + } + + 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. +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) + } + } + + 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) + } + + // FieldChanges are leaf changes only, so a field rendered identically on both sides is read from + // the plan's objects instead, and must have no change at or under it. + if want[0] != nil && valuesEqual(want[0], want[1]) { + if c := changesUnder(t, p.Changes, path); len(c) > 0 { + t.Errorf("%s: field %s: expected unchanged (%v), got changes %+v", id, path, want[0], c) + } + + for i, u := range []*unstructured.Unstructured{p.Before, p.After} { + if got, ok := objectValueAt(t, u, path); !ok || !valuesEqual(got, want[0]) { + t.Errorf("%s: field %s in %s: expected %v, got %v (present: %t)", id, path, []string{"before", "after"}[i], want[0], got, ok) + } + } + + continue + } + + if got, _ := valueAt(t, p.Changes, false, path); !valuesEqual(got, want[0]) { + t.Errorf("%s: field %s old value: expected %v, got %v", id, path, want[0], got) + } + + if got, _ := valueAt(t, p.Changes, true, path); !valuesEqual(got, want[1]) { + t.Errorf("%s: field %s new value: expected %v, got %v", id, path, want[1], 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. +func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunExpectation) { + t.Helper() + + if want.mustBePerformed && want.skipReason != "" { + t.Errorf("%s: WithDryRunPerformed and WithDryRunSkipped(%q) contradict each other; set only one", id, want.skipReason) + 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)", + id, got.SkipReason, got.Detail) + } + + return + } + + if want.skipReason == "" { + return + } + + if got == nil { + t.Errorf("%s: expected dryRun.skipReason %q, but no dryRun was reported (absence means the desired state went through the apiserver)", + id, want.skipReason) + + return + } + + if got.SkipReason != want.skipReason { + t.Errorf("%s: dryRun.skipReason: expected %q, got %q", id, want.skipReason, got.SkipReason) + } + + if want.detailSubstring != "" && !strings.Contains(got.Detail, want.detailSubstring) { + t.Errorf("%s: dryRun.detail %q does not contain %q — the cluster's own explanation must be propagated, not swallowed", + id, got.Detail, want.detailSubstring) + } +} + +// 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 +// 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) { + t.Helper() + + if noWarnings && len(want) > 0 { + t.Errorf("WithNoWarnings and WithWarning(%q) contradict each other; set only one", want[0].messageContains) + return + } + + // Each expectation consumes a distinct warning, so two identical expectations need two warnings. + used := make([]bool, len(got)) + for _, w := range want { + assertWarningExpectation(t, got, used, w) + } + + if (len(want) > 0 || noWarnings) && len(got) != len(want) { + t.Errorf("Expected %d warnings in structured output, got %d: %v", len(want), len(got), warningMessages(got)) + } +} + +// assertWarningExpectation finds an unused warning whose message contains want's substring and, when the +// expectation pins one, whose context map is exactly want's, and marks it used. Both conditions select the +// warning, since several warnings legitimately share a message and differ only in context. +func assertWarningExpectation(t *testing.T, got []simulate.Warning, used []bool, want warningMatch) { + t.Helper() + + var contextsSeen []map[string]string + + for i, w := range got { + if used[i] || !strings.Contains(w.Message, want.messageContains) { + continue + } + + if want.context == nil || reflect.DeepEqual(want.context, w.Context) { + used[i] = true + return + } + + contextsSeen = append(contextsSeen, w.Context) + } + + if len(contextsSeen) > 0 { + t.Errorf("warning %q context mismatch: expected %v, got only %v", want.messageContains, want.context, contextsSeen) + return + } + + t.Errorf("expected a warning containing %q in structured output; got %v", want.messageContains, warningMessages(got)) +} + +func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy, replace int) { + t.Helper() + + // expectedSummary has no replace bucket, so a replace would otherwise go uncounted. + if replace != 0 { + t.Errorf("%s: %d replace action(s) can't be pinned by a summary of added/modified/removed", label, replace) + } + + if create != want.added || update != want.modified || destroy != want.removed { + t.Errorf("%s: expected {added:%d modified:%d removed:%d}, got {added:%d modified:%d removed:%d}", + label, want.added, want.modified, want.removed, create, update, destroy) + } +} + +// changePlans returns the plans that change something, i.e. neither failed nor no-op, and satisfy f. +func changePlans(plans []*plan.ResourcePlan, f func(*plan.ResourcePlan) bool) []*plan.ResourcePlan { + var out []*plan.ResourcePlan + + for _, p := range plans { + if p.Error == "" && p.Action != plan.ActionNoOp && f(p) { + out = append(out, p) + } + } + + return out +} + +func groupedUnder(ref plan.ResourceRef) func(*plan.ResourcePlan) bool { + return func(p *plan.ResourcePlan) bool { return p.Composite != nil && *p.Composite == ref } +} + +func countActions(plans []*plan.ResourcePlan) (create, update, destroy, replace int) { + for _, p := range plans { + switch p.Action { + case plan.ActionCreate: + create++ + case plan.ActionUpdate: + update++ + case plan.ActionDelete: + destroy++ + case plan.ActionReplace: + replace++ + case plan.ActionNoOp: + } + } + + return create, update, destroy, replace +} + +// findPlan returns the plan for ref grouped under composite. +func findPlan(plans []*plan.ResourcePlan, ref plan.ResourceRef, composite *plan.ResourceRef) *plan.ResourcePlan { + for _, p := range plans { + if p.APIVersion == ref.APIVersion && p.Kind == ref.Kind && p.Name == ref.Name && p.Namespace == ref.Namespace && + reflect.DeepEqual(p.Composite, composite) { + return p + } + } + + return nil +} + +// findMatchingPlan returns the first plan with changeType's action, kind, 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) { + continue + } + + ref := plan.ResourceRef{Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} + if refMatches(ref, kind, name, namespace) { + return p + } + } + + 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) +} + +// findMatchingError returns the error whose resource is resourceID ("Kind/name"). +func findMatchingError(errs []simulate.Error, resourceID string) *simulate.Error { + for i := range errs { + if errorID(errs[i]) == resourceID { + return &errs[i] + } + } + + return nil +} + +// errorID is crossplane-diff's resourceID for an error: its resource's Kind/name, empty for a run +// error no resource owns. +func errorID(e simulate.Error) string { + if e.Ref == nil { + return "" + } + + return e.Ref.Kind + "/" + e.Ref.Name +} + +func errorIDs(errs []simulate.Error) []string { + ids := make([]string, 0, len(errs)) + for _, e := range errs { + ids = append(ids, errorID(e)) + } + + return ids +} + +func planIDs(plans []*plan.ResourcePlan) []string { + ids := make([]string, 0, len(plans)) + for _, p := range plans { + ids = append(ids, fmt.Sprintf("%s %s/%s (ns=%s)", p.Action, p.Kind, p.Name, p.Namespace)) + } + + return ids +} + +func compositeIDs(cs []simulate.CompositeResult) []string { + ids := make([]string, 0, len(cs)) + for _, c := range cs { + ids = append(ids, fmt.Sprintf("%s/%s (ns=%s, status=%s)", c.Ref.Kind, c.Ref.Name, c.Ref.Namespace, c.Status)) + } + + return ids +} + +func warningMessages(ws []simulate.Warning) []string { + msgs := make([]string, 0, len(ws)) + for _, w := range ws { + msgs = append(msgs, w.Message) + } + + return msgs +} + +func warningMatches[W any](ws []W, f func(W) warningMatch) []warningMatch { + out := make([]warningMatch, 0, len(ws)) + for _, w := range ws { + out = append(out, f(w)) + } + + return out +} + +func mustParsePath(t *testing.T, path string) fieldpath.Segments { + t.Helper() + + toks, err := fieldpath.Parse(path) + if err != nil { + t.Fatalf("field path: %v", err) + } + + return toks +} + +// under returns the steps of toks below prefix, and whether toks is at or under prefix at all. +func under(toks, prefix fieldpath.Segments) (fieldpath.Segments, bool) { + if len(toks) < len(prefix) || !slices.Equal(toks[:len(prefix)], prefix) { + return nil, false + } + + return toks[len(prefix):], true +} + +// changesUnder returns the changes at or under path. +func changesUnder(t *testing.T, changes []plan.FieldChange, path string) []plan.FieldChange { + t.Helper() + + prefix := mustParsePath(t, path) + + var out []plan.FieldChange + + for _, c := range changes { + if _, ok := under(mustParsePath(t, c.Path), prefix); ok { + out = append(out, c) + } + } + + return out +} + +// objectValueAt returns the value at path in u, and whether it is there at all. +func objectValueAt(t *testing.T, u *unstructured.Unstructured, path string) (any, bool) { + t.Helper() + + if u == nil { + return nil, false + } + + return walk(u.Object, mustParsePath(t, path)) +} + +// walk returns the value at toks below node, and whether it is there at all. +func walk(node any, toks fieldpath.Segments) (any, bool) { + for _, tok := range toks { + if tok.Type == fieldpath.SegmentIndex { + l, ok := node.([]any) + if !ok || tok.Index >= uint(len(l)) { + return nil, false + } + + node = l[tok.Index] + + continue + } + + m, ok := node.(map[string]any) + if !ok { + return nil, false + } + + if node, ok = m[tok.Field]; !ok { + return nil, false + } + } + + return node, true +} + +// valuesEqual compares a and b by value, so int 3 equals float64 3. Both are normalised through JSON, +// which is how the plan's values arrive. +// NOTE: integers beyond 2^53 lose precision in the round trip; compare them differently if one is ever asserted. +func valuesEqual(a, b any) bool { + // A marker state matches no concrete value, nil included, at any depth. + if hasMarker(a) || hasMarker(b) { + return false + } + + na, errA := normalise(a) + nb, errB := normalise(b) + + if errA != nil || errB != nil { + return reflect.DeepEqual(a, b) + } + + return reflect.DeepEqual(na, nb) +} + +// hasMarker reports whether v is a marker or a map or slice holding one. It runs before normalisation, +// which would turn a marker into the plain string an expectation could spell out. +func hasMarker(v any) bool { + switch v := v.(type) { + case marker: + return true + case map[string]any: + for _, e := range v { + if hasMarker(e) { + return true + } + } + case []any: + if slices.ContainsFunc(v, hasMarker) { + return true + } + } + + return false +} + +func normalise(v any) (any, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + + var out any + + return out, json.Unmarshal(raw, &out) +} + +// marker stands in for a plan value that has no concrete value: unknown, sensitive or unresolved. +// valuesEqual never matches it, and it prints readably in failure messages. +type marker string + +// leaf is the value a plan.Value contributes to a comparison. +func leaf(v *plan.Value) any { + switch { + case v.Unknown: + return marker("(known after apply)") + case v.Sensitive: + return marker("(sensitive)") + case v.Unresolved: + return marker("(unresolved)") + } + + return v.Value +} + +// valueAt returns the value at path on one side of a plan's changes: the planned side, or else the +// actual. A leaf is its change's value; a non-leaf is re-assembled from the changed leaves under it. +// Found is false, and the value nil, when no change sets anything at or under path on that side, so an +// absent field differs from an explicit null. A change above path (one whose value is a whole object) +// is searched for path within its value. +func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string) (any, bool) { + t.Helper() + + prefix := mustParsePath(t, path) + + var ( + root any + found bool + ) + + for _, c := range changes { + v := c.Actual + if planned { + v = c.Planned + } + + if v == nil { + continue + } + + cp := mustParsePath(t, c.Path) + + // An enclosing change holds the value inside its own. + if outer, ok := under(prefix, cp); ok && len(outer) > 0 { + if m, ok := leaf(v).(marker); ok { + return m, true + } + + if inner, ok := walk(v.Value, outer); ok { + return inner, true + } + + continue + } + + rel, ok := under(cp, prefix) + if !ok { + continue + } + + if len(rel) == 0 { + return leaf(v), true + } + + root = insert(root, rel, leaf(v)) + found = true + } + + return assemble(root), found +} + +// insert sets v at toks below node, building maps as it goes. Lists are built as index maps, which +// assemble turns into slices. +func insert(node any, toks fieldpath.Segments, v any) any { + if len(toks) == 0 { + return v + } + + if toks[0].Type == fieldpath.SegmentIndex { + m, _ := node.(map[uint]any) + if m == nil { + m = map[uint]any{} + } + + m[toks[0].Index] = insert(m[toks[0].Index], toks[1:], v) + + return m + } + + m, _ := node.(map[string]any) + if m == nil { + m = map[string]any{} + } + + m[toks[0].Field] = insert(m[toks[0].Field], toks[1:], v) + + return m +} + +// assemble turns insert's index maps into slices. +func assemble(node any) any { + switch n := node.(type) { + case map[uint]any: + size := uint(0) + for i := range n { + size = max(size, i+1) + } + + out := make([]any, size) + for i, v := range n { + out[i] = assemble(v) + } + + return out + case map[string]any: + for k, v := range n { + n[k] = assemble(v) + } + + return n + default: + return node + } +} diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go new file mode 100644 index 00000000..c4350b49 --- /dev/null +++ b/internal/simulate/simtest/structured_assertions.go @@ -0,0 +1,373 @@ +// Package simtest asserts on composition simulate's structured output, simulate.ProjectResult, through +// crossplane-diff's composition expectation builders. +package simtest + +import ( + dt "github.com/crossplane/cli/v2/internal/diff/result" +) + +// Expectation is implemented by every level of the builder hierarchy, so AssertStructured accepts +// any node and callers never need And()/AndXR()/AndComp() to climb back to the root. +type Expectation interface { + expectation() *Expected +} + +// 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. +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 + // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". + noWarnings bool +} + +// warningMatch describes one expected entry in warnings[]. Matched +// by message substring rather than exact equality, since warning prose is not a stable contract; +// context keys are asserted exactly, because those ARE what a machine consumer reads. +type warningMatch struct { + messageContains string + context map[string]string +} + +// WarningExpectation describes one expected entry in warnings[]. +type WarningExpectation struct { + warningMatch + + parent *Expected +} + +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 ResourceExpectation struct { + parent *XRImpactExpectation + changeType string // "added", "modified", "removed" + kind string + name string + namespace string + fieldValues map[string]any // For added/removed: field path -> expected value + fieldChanges map[string][2]any // For modified: field path -> [old, new] + + dryRun dryRunExpectation +} + +// dryRunExpectation holds a downstream change's dryRun assertions. +// +// skipReason, when non-empty, asserts the change carries a dryRun object with +// that skipReason. mustBePerformed asserts the opposite — that no dryRun object +// is present, i.e. the desired state went through the apiserver. The two are +// mutually exclusive; both unset asserts nothing, which keeps every pre-existing +// expectation valid. +type dryRunExpectation struct { + skipReason string + detailSubstring string + mustBePerformed bool +} + +func (r *ResourceExpectation) expectation() *Expected { return r.parent.expectation() } + +// Expect creates a new Expected builder. +func Expect() *Expected { + return &Expected{compositions: make([]*CompositionExpectation, 0)} +} + +// 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 { + w := &WarningExpectation{ + parent: e, + warningMatch: warningMatch{messageContains: messageContains}, + } + e.warnings = append(e.warnings, w) + + return w +} + +// WithNoWarnings asserts that structured output carries no advisories at all. Distinct from simply +// declaring no WithWarning expectations, which asserts nothing: this is how a test pins that a +// condition the tool used to warn about is correctly silent. +func (e *Expected) WithNoWarnings() *Expected { + e.noWarnings = true + return e +} + +// WithWarningContext pins the expected context key/value pairs on the warning under construction. +func (w *WarningExpectation) WithWarningContext(context map[string]string) *WarningExpectation { + w.context = context + return w +} + +// And returns to the parent builder. +func (w *WarningExpectation) And() *Expected { return w.parent } + +// 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 +} + +// WithFieldChange asserts a field changed from old to new value (for modified resources). +func (r *ResourceExpectation) WithFieldChange(path string, oldValue, newValue any) *ResourceExpectation { + r.fieldChanges[path] = [2]any{oldValue, newValue} + return r +} + +// WithDryRunSkipped asserts this change reports that its desired state was NOT +// verified against the apiserver, for the given reason (one of the +// dt.DryRunSkipReason values: "disabled", "forbidden", "webhookUnavailable"). +// +// Pass a detailSubstring to additionally require that the cluster's own +// explanation was propagated rather than swallowed; pass "" to skip that check. +func (r *ResourceExpectation) WithDryRunSkipped(reason, detailSubstring string) *ResourceExpectation { + r.dryRun.skipReason = reason + r.dryRun.detailSubstring = detailSubstring + + return r +} + +// WithDryRunPerformed asserts this change carries NO dryRun object, i.e. its +// desired state did go through the apiserver. Worth asserting explicitly on the +// happy path: without it, a regression that silently stopped dry-running +// additions would only be caught by whichever field assertions happened to +// depend on server-side defaulting. +func (r *ResourceExpectation) WithDryRunPerformed() *ResourceExpectation { + r.dryRun.mustBePerformed = true + return r +} + +// WithErrorID asserts the top-level errors[] contains an error with the given resourceID. 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 +} + +// CompositionExpectation defines expectations for a single composition in the diff. +type CompositionExpectation struct { + parent *Expected + name string + affectedTotal *int + affectedWithChanges *int + affectedUnchanged *int + affectedWithErrors *int + affectedFilteredByDeletion *int + xrImpacts []*XRImpactExpectation + // compositionChangeType, when set, is the composition's own change: "modified", the only one a + // composition makes. + compositionChangeType string + // impactAnalysisSkipped, when set, asserts the composites were deliberately not evaluated. + impactAnalysisSkipped *bool + // revisionImpact, when set, asserts what applying the composition does to CompositionRevisions. + revisionImpact *expectedRevisionImpact + // predictedRevisionName, when set, asserts revisionImpact.predictedRevisionName. Set independently + // of revisionImpact so a case can assert the name alone, and because the name is usually matched by + // pattern (its suffix is a content hash) while the rest are exact. + predictedRevisionName *expectedPredictedRevisionName +} + +// expectedRevisionImpact is the expected revisionImpact object for a composition. +type expectedRevisionImpact struct { + changeScope string + createsRevision bool + repointedComposites int +} + +// expectedPredictedRevisionName is the expected revisionImpact.predictedRevisionName. A pattern rather +// than a literal because the name's suffix is the first 7 hex digits of the composition's hash, so +// pinning it exactly would make every fixture edit a test failure. The derivation itself is pinned +// exactly by the revisionIdentity unit test, which is where that belongs. +// +// An empty pattern asserts the field is absent, which is a meaningful outcome in its own right: the +// name was not predictable. +type expectedPredictedRevisionName struct { + pattern string +} + +func (c *CompositionExpectation) expectation() *Expected { return c.parent } + +// XRImpactExpectation defines expectations for a single XR impact. +type XRImpactExpectation struct { + parent *CompositionExpectation + kind string + name string + namespace string + 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 + 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{ + parent: e, + name: name, + xrImpacts: make([]*XRImpactExpectation, 0), + } + e.compositions = append(e.compositions, c) + + return c +} + +// WithAffectedResources sets the expected affected resources counts. +func (c *CompositionExpectation) WithAffectedResources(total, withChanges, unchanged, withErrors int) *CompositionExpectation { + c.affectedTotal = &total + c.affectedWithChanges = &withChanges + c.affectedUnchanged = &unchanged + c.affectedWithErrors = &withErrors + + return c +} + +// WithFilteredByDeletion asserts the number of XRs excluded from impact analysis because they are +// being deleted (AffectedResourcesSummary.FilteredByDeletion). A single-purpose setter rather than a +// positional counterpart to WithAffectedResources, so a caller cannot transpose filter counters. +func (c *CompositionExpectation) WithFilteredByDeletion(count int) *CompositionExpectation { + c.affectedFilteredByDeletion = &count + + return c +} + +// WithImpactAnalysisSkipped asserts that the composites were deliberately not evaluated, which is +// what distinguishes "we did not look" from "we looked and found no affected composites" — both of +// which leave impactAnalysis empty. +func (c *CompositionExpectation) WithImpactAnalysisSkipped() *CompositionExpectation { + skipped := true + c.impactAnalysisSkipped = &skipped + + return c +} + +// WithRevisionImpact asserts what applying the composition does to CompositionRevisions, independent +// of whether anything renders differently. changeScope is "none", "metadata" or "spec". +func (c *CompositionExpectation) WithRevisionImpact(changeScope string, createsRevision bool, repointedComposites int) *CompositionExpectation { + c.revisionImpact = &expectedRevisionImpact{ + changeScope: changeScope, + createsRevision: createsRevision, + repointedComposites: repointedComposites, + } + + return c +} + +// WithPredictedRevisionNamePattern asserts that revisionImpact.predictedRevisionName matches pattern +// (an unanchored regexp unless the pattern anchors itself). Use for the usual case, where the name's +// hash suffix is not worth pinning in an end-to-end test. +func (c *CompositionExpectation) WithPredictedRevisionNamePattern(pattern string) *CompositionExpectation { + c.predictedRevisionName = &expectedPredictedRevisionName{pattern: pattern} + + return c +} + +// WithoutPredictedRevisionName asserts that revisionImpact carries no predictedRevisionName — i.e. the +// revision's identity could not be predicted. Distinct from simply not asserting on the field. +func (c *CompositionExpectation) WithoutPredictedRevisionName() *CompositionExpectation { + c.predictedRevisionName = &expectedPredictedRevisionName{pattern: ""} + + return c +} + +// WithCompositionModified asserts that the composition itself is modified. +func (c *CompositionExpectation) WithCompositionModified() *CompositionExpectation { + c.compositionChangeType = dt.DiffTypeWordModified + return c +} + +// WithXRImpact adds an expectation for an XR impact. +func (c *CompositionExpectation) WithXRImpact(kind, name, namespace, status string) *XRImpactExpectation { + x := &XRImpactExpectation{ + parent: c, + kind: kind, + name: name, + namespace: namespace, + status: status, + } + c.xrImpacts = append(c.xrImpacts, x) + + return x +} + +// WithFilterReason pins the expected FilterReason on a filtered XR impact (e.g. "manualPolicy", +// "revisionSelectorMismatch", or "deleting"). Only asserted when set. +func (x *XRImpactExpectation) WithFilterReason(reason string) *XRImpactExpectation { + x.filterReason = reason + return x +} + +// WithErrorContaining pins a substring of an "error" XR impact's Error, so a test can assert why +// the composite failed rather than only that it did. Only asserted when set. +func (x *XRImpactExpectation) WithErrorContaining(s string) *XRImpactExpectation { + x.errorContains = s + return x +} + +// WithErrorResourceID asserts the entry's errors[] (v2 only) holds an error with the given resourceID. +func (x *XRImpactExpectation) WithErrorResourceID(resourceID string) *XRImpactExpectation { + x.errorIDs = append(x.errorIDs, resourceID) + return x +} + +// WithDownstreamSummary sets the expected downstream changes summary. +func (x *XRImpactExpectation) WithDownstreamSummary(added, modified, removed int) *XRImpactExpectation { + x.downstreamSummary = &expectedSummary{ + added: added, + modified: modified, + removed: removed, + } + + return x +} + +// WithDownstreamResource adds an expectation for a downstream resource change. +// Use this to assert on specific field-level changes in downstream resources. +func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: x, + changeType: changeType, + kind: kind, + name: name, + namespace: namespace, + fieldChanges: make(map[string][2]any), + fieldValues: make(map[string]any), + } + x.downstreamResources = append(x.downstreamResources, r) + + return r +} + +// 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 } + +// AndComp returns to parent to chain more XR impact expectations. +// Not needed at the end of a chain - AssertStructured accepts XRImpactExpectation directly. +func (x *XRImpactExpectation) AndComp() *CompositionExpectation { + return x.parent +} + +// AndComposition starts an expectation for another composition, from anywhere in the current +// composition's chain. It exists because the only climb a caller genuinely needs is the one back to +// the root to add a sibling — every other And()/AndXR()/AndComp() is avoidable, since the assert +// functions accept any builder level. Spelling the sibling case as one intention-named call keeps a +// multi-composition expectation readable instead of ending each composition with a run of climbs +// whose purpose is not obvious from reading them. +func (r *ResourceExpectation) AndComposition(name string) *CompositionExpectation { + return r.expectation().WithComposition(name) +} diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go new file mode 100644 index 00000000..86ca0408 --- /dev/null +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -0,0 +1,257 @@ +package simtest + +import ( + "encoding/json" + "reflect" + "testing" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/plan" + "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 { + t.Helper() + + out, err := json.Marshal(r) + if err != nil { + t.Fatalf("json.Marshal(): %v", err) + } + + return string(out) +} + +func val(v any) *plan.Value { return &plan.Value{Value: v} } + +// checkAssertion runs assert against a throwaway T and reports whether it failed as wantFail says. +func checkAssertion(t *testing.T, name string, wantFail bool, assert func(*testing.T)) { + t.Helper() + + inner := &testing.T{} + assert(inner) + + if inner.Failed() != wantFail { + t.Errorf("%s: failed = %t, want %t", name, inner.Failed(), wantFail) + } +} + +var ( + compA = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "comp-a"} + compB = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "comp-b"} + xr1 = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNop", Name: "xr-1", Namespace: "default"} + xr2 = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNop", Name: "xr-2", Namespace: "default"} + xr3 = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XNop", Name: "xr-3", Namespace: "default"} +) + +// 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{ + Result: plan.Result{ + Plans: []*plan.ResourcePlan{ + { + APIVersion: compA.APIVersion, Kind: compA.Kind, Name: compA.Name, Action: plan.ActionUpdate, + Changes: []plan.FieldChange{{Path: "spec.mode", Actual: val("Resources"), Planned: val("Pipeline")}}, + }, + {APIVersion: compB.APIVersion, Kind: compB.Kind, Name: compB.Name, Error: "cannot convert composition"}, + { + APIVersion: "example.org/v1", Kind: "XDownstream", Name: "xr-1-abc12", Namespace: "default", Action: plan.ActionUpdate, Composite: &xr1, + Changes: []plan.FieldChange{{Path: "spec.forProvider.configData", Actual: val("old"), Planned: val("new-value-42")}}, + Before: downstreamObject("old"), + After: downstreamObject("new-value-42"), + DryRun: &plan.Verification{SkipReason: "not-found", Detail: "the server could not find it"}, + }, + }, + }, + Compositions: []simulate.CompositionResult{ + {Ref: compA, RevisionImpact: &simulate.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 2, PredictedRevision: "comp-a-1a2b3c4"}}, + {Ref: compB, ImpactAnalysisSkipped: true}, + }, + Composites: []simulate.CompositeResult{ + {Ref: xr1, Status: simulate.CompositeChanged, Composition: &compA}, + {Ref: xr2, Status: simulate.CompositeUnchanged, Composition: &compA}, + {Ref: xr3, Status: simulate.CompositeSkipped, Reason: simulate.CompositeReasonDeleting, Composition: &compA}, + {Ref: xr1, Status: simulate.CompositeError, Composition: &compB}, + }, + Errors: []simulate.Error{ + {Ref: &compB, Message: "cannot convert composition"}, + {Ref: &xr1, Message: "maximum nesting depth exceeded"}, + }, + Warnings: []simulate.Warning{{Message: "something advisory", Context: map[string]string{"composition": "comp-a"}}}, + } +} + +// downstreamObject is xr-1's downstream resource with configData, and an unchanged tier. +func downstreamObject(configData string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", "kind": "XDownstream", + "metadata": map[string]any{"name": "xr-1-abc12", "namespace": "default"}, + "spec": map[string]any{"forProvider": map[string]any{"configData": configData, "tier": "premium"}}, + }} +} + +func TestAssertStructuredCompositionPlan(t *testing.T) { + out := resultJSON(t, compResult()) + + downstream := func() *ResourceExpectation { + return Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "changed"). + WithDownstreamResource("modified", "XDownstream", "xr-1-abc12", "default") + } + + cases := map[string]struct { + want Expectation + wantFail bool + }{ + "Composition": {want: Expect(). + WithComposition("comp-a"). + WithAffectedResources(3, 1, 1, 0). + WithFilteredByDeletion(1). + WithRevisionImpact("spec", true, 2). + WithPredictedRevisionNamePattern(`^comp-a-[0-9a-f]{7}$`). + WithCompositionModified(). + WithXRImpact("XNop", "xr-1", "default", "changed"). + WithDownstreamSummary(0, 1, 0). + WithDownstreamResource("modified", "XDownstream", "xr-1-abc12", "default"). + WithFieldChange("spec.forProvider.configData", "old", "new-value-42"). + WithField("spec.forProvider", map[string]any{"configData": "new-value-42"}). + WithDryRunSkipped("not-found", "could not find"). + AndXR(). + AndComp(). + WithXRImpact("XNop", "xr-3", "default", "filtered"). + WithFilterReason(string(dt.FilterReasonDeleting))}, + // Counts are per composition: comp-b's errored composite is not comp-a's. + "CountsArePerComposition": {want: Expect().WithComposition("comp-a").WithAffectedResources(4, 1, 1, 1), wantFail: true}, + "OtherComposition": {want: Expect(). + WithErrorID("Composition/comp-b"). + WithComposition("comp-b"). + WithImpactAnalysisSkipped(). + WithAffectedResources(1, 0, 0, 1). + WithXRImpact("XNop", "xr-1", "default", "error"). + WithErrorContaining("maximum nesting depth"). + WithErrorResourceID("XNop/xr-1")}, + "MissingComposition": {want: Expect().WithComposition("comp-c"), wantFail: true}, + "CompositionNotModified": {want: Expect().WithComposition("comp-b").WithCompositionModified(), wantFail: true}, + "WrongRevisionScope": {want: Expect().WithComposition("comp-a").WithRevisionImpact("metadata", true, 2), wantFail: true}, + "PredictedNameAbsent": {want: Expect().WithComposition("comp-a").WithoutPredictedRevisionName(), wantFail: true}, + "NoRevisionImpact": {want: Expect().WithComposition("comp-b").WithRevisionImpact("none", false, 0), wantFail: true}, + "NotSkipped": {want: Expect().WithComposition("comp-a").WithImpactAnalysisSkipped(), wantFail: true}, + "WrongXRStatus": {want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "changed"), wantFail: true}, + "WrongFilterReason": { + want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-3", "default", "filtered"). + WithFilterReason(string(dt.FilterReasonManualPolicy)), + wantFail: true, + }, + "WrongDownstreamValue": {want: downstream().WithFieldChange("spec.forProvider.configData", "old", "other"), wantFail: true}, + "WrongOldValue": {want: downstream().WithFieldChange("spec.forProvider.configData", "older", "new-value-42"), wantFail: true}, + "WrongField": {want: downstream().WithField("spec.forProvider.configData", "other"), wantFail: true}, + "WrongDownstreamName": {want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "changed").WithDownstreamResource("modified", "XDownstream", "xr-1", "default"), wantFail: true}, + "DryRunPerformed": {want: downstream().WithDryRunPerformed(), wantFail: true}, + "WrongDryRunReason": {want: downstream().WithDryRunSkipped("disabled", ""), 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: downstream().WithFieldChange("spec.forProvider.tier", "premium", "premium")}, + "UnchangedWrongValue": {want: downstream().WithFieldChange("spec.forProvider.tier", "basic", "basic"), wantFail: true}, + "UnchangedMissing": {want: downstream().WithFieldChange("spec.forProvider.size", "large", "large"), wantFail: true}, + "ChangedNotSteady": {want: downstream().WithFieldChange("spec.forProvider.configData", "old", "old"), wantFail: true}, + "Warning": {want: Expect().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "comp-a"})}, + "WrongWarningContext": {want: Expect().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "comp-b"}), wantFail: true}, + "MissingWarning": {want: Expect().WithWarning("advisory").And().WithWarning("other"), wantFail: true}, + "NoWarnings": {want: Expect().WithNoWarnings(), wantFail: true}, + "AndComposition": {want: downstream().AndComposition("comp-b").WithImpactAnalysisSkipped()}, + "ErrorOnOtherXR": { + want: Expect().WithComposition("comp-a").WithXRImpact("XNop", "xr-2", "default", "unchanged").WithErrorResourceID("XNop/xr-1"), + wantFail: true, + }, + "MissingTopLevelError": {want: Expect().WithErrorID("Composition/comp-a"), wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) + } +} + +func TestValueAt(t *testing.T) { + changes := []plan.FieldChange{ + {Path: "spec.a", Actual: val("old-a"), Planned: val("new-a")}, + {Path: "spec.list[0].x", Planned: val("x0")}, + {Path: "spec.list[1].x", Actual: val("gone")}, + {Path: "metadata.labels[app.io/name]", Planned: val("app")}, + {Path: "spec.whole", Planned: val(map[string]any{"inner": map[string]any{"k": "v"}, "nul": nil})}, + } + + cases := map[string]struct { + path string + planned bool + want any + wantFound bool + }{ + "Leaf": {path: "spec.a", planned: true, want: "new-a", wantFound: true}, + "LeafActual": {path: "spec.a", want: "old-a", wantFound: true}, + "Subtree": {path: "spec", planned: true, want: map[string]any{"a": "new-a", "list": []any{map[string]any{"x": "x0"}}, "whole": map[string]any{"inner": map[string]any{"k": "v"}, "nul": nil}}, wantFound: true}, + "SubtreeActual": {path: "spec.list", want: []any{nil, map[string]any{"x": "gone"}}, wantFound: true}, + "QuotedKey": {path: "metadata.labels['app.io/name']", planned: true, want: "app", wantFound: true}, + "Absent": {path: "spec.b", planned: true, want: nil}, + "InsideEnclosingChange": {path: "spec.whole.inner.k", planned: true, want: "v", wantFound: true}, + "NullInsideEnclosing": {path: "spec.whole.nul", planned: true, want: nil, wantFound: true}, + "AbsentInsideEnclosing": {path: "spec.whole.other", planned: true, want: nil}, + "PrefixIsNotPath": {path: "spec.list[0].xy", planned: true, want: nil}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, found := valueAt(t, changes, tc.planned, tc.path) + if !reflect.DeepEqual(got, tc.want) || found != tc.wantFound { + t.Errorf("valueAt(%q, planned=%t) = %#v, %t, want %#v, %t", tc.path, tc.planned, got, found, tc.want, tc.wantFound) + } + }) + } +} + +func TestAssertFieldsAndWarnings(t *testing.T) { + p := &plan.ResourcePlan{ + Changes: []plan.FieldChange{ + {Path: "spec.n", Planned: val(3)}, + {Path: "spec.nul", Planned: val(nil)}, + }, + } + two := []simulate.Warning{{Message: "advisory"}} + + cases := map[string]struct { + assert func(*testing.T) + wantFail bool + }{ + "AbsentIsNotNil": {assert: func(t *testing.T) { + t.Helper() + assertFields(t, "id", p, "added", map[string]any{"spec.missing": nil}, nil) + }, wantFail: true}, + "ExplicitNil": {assert: func(t *testing.T) { + t.Helper() + assertFields(t, "id", p, "added", map[string]any{"spec.nul": nil}, nil) + }}, + "IntEqualsFloat": {assert: func(t *testing.T) { + t.Helper() + assertFields(t, "id", p, "added", map[string]any{"spec.n": float64(3)}, nil) + }}, + "IntDiffersFloat": {assert: func(t *testing.T) { t.Helper(); assertFields(t, "id", p, "added", map[string]any{"spec.n": 4.5}, nil) }, wantFail: true}, + "DoubleExpectation": {assert: func(t *testing.T) { + t.Helper() + assertWarnings(t, two, []warningMatch{{messageContains: "advisory"}, {messageContains: "advisory"}}, false) + }, wantFail: true}, + "NoWarningsAndWarning": {assert: func(t *testing.T) { + t.Helper() + assertWarnings(t, nil, []warningMatch{{messageContains: "advisory"}}, true) + }, wantFail: true}, + "DryRunContradiction": {assert: func(t *testing.T) { + t.Helper() + assertDryRun(t, "id", nil, dryRunExpectation{mustBePerformed: true, skipReason: "disabled"}) + }, wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, tc.assert) + } +} diff --git a/pkg/load/loader.go b/pkg/load/loader.go index b7a25ab3..0f76fd56 100644 --- a/pkg/load/loader.go +++ b/pkg/load/loader.go @@ -63,8 +63,15 @@ func NewLoader(input string) (Loader, error) { } func newLoader(input string) (Loader, error) { + return newSourceLoader(input, false) +} + +// newSourceLoader returns the Loader for one input. With skipPipelineInputs, +// the loader doesn't return Composition pipeline step inputs as manifests of +// their own. +func newSourceLoader(input string, skipPipelineInputs bool) (Loader, error) { if input == "-" { - return &StdinLoader{}, nil + return &StdinLoader{skipPipelineInputs: skipPipelineInputs}, nil } fi, err := os.Stat(input) @@ -73,10 +80,10 @@ func newLoader(input string) (Loader, error) { } if fi.IsDir() { - return &FolderLoader{path: input}, nil + return &FolderLoader{path: input, skipPipelineInputs: skipPipelineInputs}, nil } - return &FileLoader{path: input}, nil + return &FileLoader{path: input, skipPipelineInputs: skipPipelineInputs}, nil } // MultiLoader implements the Loader interface for reading from multiple other loaders. @@ -101,7 +108,9 @@ func (m *MultiLoader) Load() ([]*unstructured.Unstructured, error) { } // StdinLoader implements the Loader interface for reading from stdin. -type StdinLoader struct{} +type StdinLoader struct { + skipPipelineInputs bool +} // Load reads the contents from stdin. func (s *StdinLoader) Load() ([]*unstructured.Unstructured, error) { @@ -110,12 +119,13 @@ func (s *StdinLoader) Load() ([]*unstructured.Unstructured, error) { return nil, errors.Wrap(err, "cannot load YAML stream from stdin") } - return streamToUnstructured(stream) + return streamToUnstructured(stream, s.skipPipelineInputs) } // FileLoader implements the Loader interface for reading from a file and converting input to unstructured objects. type FileLoader struct { - path string + path string + skipPipelineInputs bool } // Load reads the contents from a file. @@ -125,12 +135,13 @@ func (f *FileLoader) Load() ([]*unstructured.Unstructured, error) { return nil, errors.Wrap(err, "cannot read file") } - return streamToUnstructured(stream) + return streamToUnstructured(stream, f.skipPipelineInputs) } // FolderLoader implements the Loader interface for reading from a folder. type FolderLoader struct { - path string + path string + skipPipelineInputs bool } // Load reads the contents from all files in a folder. @@ -157,7 +168,7 @@ func (f *FolderLoader) Load() ([]*unstructured.Unstructured, error) { return nil, errors.Wrap(err, "cannot read folder") } - return streamToUnstructured(stream) + return streamToUnstructured(stream, f.skipPipelineInputs) } func isYamlFile(info os.FileInfo) bool { @@ -200,7 +211,10 @@ func YamlStream(r io.Reader) ([][]byte, error) { return stream, nil } -func streamToUnstructured(stream [][]byte) ([]*unstructured.Unstructured, error) { +// streamToUnstructured parses stream. Unless skipPipelineInputs is set, it also +// returns each Composition pipeline step input as a manifest of its own, so +// validate can check the inputs against their schemas. +func streamToUnstructured(stream [][]byte, skipPipelineInputs bool) ([]*unstructured.Unstructured, error) { manifests := make([]*unstructured.Unstructured, 0, len(stream)) for _, y := range stream { @@ -215,7 +229,7 @@ func streamToUnstructured(stream [][]byte) ([]*unstructured.Unstructured, error) } // extract pipeline input resources - if u.GetObjectKind().GroupVersionKind() == v1.CompositionGroupVersionKind { + if !skipPipelineInputs && u.GetObjectKind().GroupVersionKind() == v1.CompositionGroupVersionKind { // Convert the unstructured resource to a Composition var comp v1.Composition @@ -259,6 +273,35 @@ type CompositeLoader struct { // Sources can be files, directories, or "-" for stdin. // If sources is empty, stdin is used by default. func NewCompositeLoader(sources []string) (Loader, error) { + return newCompositeLoader(sources, NewLoader) +} + +// ExactCompositeLoaderOption configures NewExactCompositeLoader. +type ExactCompositeLoaderOption func(*exactCompositeLoaderOptions) + +type exactCompositeLoaderOptions struct { + skipPipelineInputs bool +} + +// WithoutPipelineInputs stops the loader from returning each Composition +// pipeline step input as a manifest of its own. By default it returns them. +func WithoutPipelineInputs() ExactCompositeLoaderOption { + return func(o *exactCompositeLoaderOptions) { o.skipPipelineInputs = true } +} + +// NewExactCompositeLoader is NewCompositeLoader, except each source is exactly +// one path or "-": commas are part of the path, not separators. +func NewExactCompositeLoader(sources []string, opts ...ExactCompositeLoaderOption) (Loader, error) { + o := &exactCompositeLoaderOptions{} + for _, fn := range opts { + fn(o) + } + return newCompositeLoader(sources, func(source string) (Loader, error) { + return newSourceLoader(source, o.skipPipelineInputs) + }) +} + +func newCompositeLoader(sources []string, newSource func(string) (Loader, error)) (Loader, error) { if len(sources) == 0 { // In unit tests, this will cause an error when Load() is called // which is the expected behavior for NoSources test case @@ -280,7 +323,7 @@ func NewCompositeLoader(sources []string) (Loader, error) { stdinUsed = true } - loader, err := NewLoader(source) + loader, err := newSource(source) if err != nil { return nil, errors.Wrapf(err, "cannot create loader for %q", source) } diff --git a/pkg/load/loader_test.go b/pkg/load/loader_test.go index d0745e3a..bf00e8c9 100644 --- a/pkg/load/loader_test.go +++ b/pkg/load/loader_test.go @@ -141,7 +141,7 @@ func TestNewLoader(t *testing.T) { if diff := cmp.Diff( tc.want.loader, got, - cmpopts.IgnoreUnexported(FileLoader{}, FolderLoader{}, MultiLoader{}), + cmpopts.IgnoreUnexported(StdinLoader{}, FileLoader{}, FolderLoader{}, MultiLoader{}), ); diff != "" { t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) } @@ -352,7 +352,8 @@ func TestFolderLoaderLoad(t *testing.T) { func TestStreamToUnstructured(t *testing.T) { type args struct { - stream [][]byte + stream [][]byte + skipPipelineInputs bool } type want struct { @@ -486,6 +487,78 @@ spec: err: nil, }, }, + "CompositionWithoutPipelineInputs": { + reason: "With skipPipelineInputs, return only the Composition and not its pipeline step inputs", + args: args{ + stream: [][]byte{ + []byte(` +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: example-composition +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1alpha1 + kind: ExampleComposite + pipeline: + - step: patch-and-transform + functionRef: + name: example-function + input: + apiVersion: pt.fn.crossplane.io/v1beta1 + kind: Resources + resources: + - name: instanceNodeRole + base: + apiVersion: iam.aws.crossplane.io/v1beta1 + kind: Role + spec: {} +`), + }, + skipPipelineInputs: true, + }, + want: want{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1", + "kind": "Composition", + "metadata": map[string]any{ + "name": "example-composition", + }, + "spec": map[string]any{ + "compositeTypeRef": map[string]any{ + "apiVersion": "example.crossplane.io/v1alpha1", + "kind": "ExampleComposite", + }, + "pipeline": []any{ + map[string]any{ + "step": "patch-and-transform", + "functionRef": map[string]any{ + "name": "example-function", + }, + "input": map[string]any{ + "apiVersion": "pt.fn.crossplane.io/v1beta1", + "kind": "Resources", + "resources": []any{ + map[string]any{ + "name": "instanceNodeRole", + "base": map[string]any{ + "apiVersion": "iam.aws.crossplane.io/v1beta1", + "kind": "Role", + "spec": map[string]any{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, "SkipEmptyDocuments": { reason: "Return empty unstructured array for empty documents", args: args{ @@ -508,7 +581,7 @@ spec: for name, tc := range cases { t.Run(name, func(t *testing.T) { - got, err := streamToUnstructured(tc.args.stream) + got, err := streamToUnstructured(tc.args.stream, tc.args.skipPipelineInputs) if diff := cmp.Diff(tc.want.resources, got); diff != "" { t.Errorf("%s\nstreamToUnstructured(...): -want, +got:\n%s", tc.reason, diff) }