From ab8089aed0c93884186e47ddebd6e76077a542c2 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:15:27 -0500 Subject: [PATCH 01/27] feat(load): add an exact composite loader that can skip pipeline inputs Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- pkg/load/loader.go | 67 +++++++++++++++++++++++++++------- pkg/load/loader_test.go | 79 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 15 deletions(-) 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) } From fda7fc66966f17bdccc122a3cc98fc1e11585c65 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:19:26 -0500 Subject: [PATCH 02/27] feat(simulate): load and classify -f resource inputs with one rejection message Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/inputs.go | 103 +++++++++++++++++++ internal/simulate/inputs_test.go | 168 +++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 internal/simulate/inputs.go create mode 100644 internal/simulate/inputs_test.go diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go new file mode 100644 index 00000000..bf262882 --- /dev/null +++ b/internal/simulate/inputs.go @@ -0,0 +1,103 @@ +/* +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" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/project/projectfile" + ld "github.com/crossplane/cli/v2/pkg/load" +) + +// Class is a kind of -f input. Each simulate command takes one class, and +// rejects the others with the same message. +type Class int + +// The -f input classes. +const ( + // ClassUnknown is any resource no simulate command takes. + ClassUnknown Class = iota + // ClassProject is a project file, which project simulate takes. + ClassProject + // ClassComposition is a Composition, which composition simulate takes. + ClassComposition +) + +// taker returns the command that takes class c and what it takes, or false +// when no command takes c. +func taker(c Class) (command, what string, ok bool) { + switch c { + case ClassProject: + return "project simulate", "a project file", true + case ClassComposition: + return "composition simulate", "Composition files", true + case ClassUnknown: + } + return "", "", false +} + +// Classify returns the class of the -f input r. +func Classify(r *un.Unstructured) Class { + gvk := r.GroupVersionKind() + switch { + case gvk.GroupVersion().String() == projectfile.APIVersion && gvk.Kind == projectfile.Kind: + return ClassProject + case gvk == apiextensionsv1.CompositionGroupVersionKind: + return ClassComposition + default: + return ClassUnknown + } +} + +// RejectInput returns the error for an -f input r that the command taking +// want doesn't take, pointing at the command that does take it, if any. +func RejectInput(r *un.Unstructured, want Class) error { + gvk := r.GroupVersionKind() + cmd, what, _ := taker(want) + msg := fmt.Sprintf("-f inputs include %s %q (%s); %s takes %s", gvk.Kind, r.GetName(), gvk.GroupVersion(), cmd, what) + if cmd, what, ok := taker(Classify(r)); ok { + msg += fmt.Sprintf("; %s takes %s", cmd, what) + } + return errors.New(msg) +} + +// LoadInputs loads the -f inputs at paths ("-" is stdin) and rejects any +// that aren't of class want. +func LoadInputs(paths []string, want Class) ([]*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 Classify(r) != want { + return nil, RejectInput(r, want) + } + } + return resources, nil +} diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go new file mode 100644 index 00000000..bcf1aa40 --- /dev/null +++ b/internal/simulate/inputs_test.go @@ -0,0 +1,168 @@ +/* +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" +) + +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 +`) + // 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") + 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"}}, + }, + "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"}}, + }, + "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; project simulate takes a project file`}, + }, + "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"`}, + }, + "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, ClassComposition) + 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) + } + }) + } +} From 21b48639a8f965dd76db28a0a6cea82832eadc81 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:27:09 -0500 Subject: [PATCH 03/27] feat(simulate): run Composition inputs with the cluster's functions Inputs.Compositions simulates Composition files without a project build: ImageConfigs come from --image-configs, the xpkg client is made on first use, and without --providers each composite resource's providers start as it is simulated. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/run.go | 86 +++++++++++++++++++++++++++-------- internal/simulate/run_test.go | 16 +++++-- 2 files changed, 78 insertions(+), 24 deletions(-) diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 21ad04c8..03bde105 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 for a project, and otherwise by planOptions. 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 planOptions sets its 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) { @@ -85,13 +90,21 @@ func (c *Flags) planOptions(sigCtx context.Context, k *kong.Context, logger logg if c.Hooks != nil && c.Hooks.Router != nil { router = c.Hooks.Router } else { + if pkgs.Client == nil { + client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(pkgs.ICs)) + if err != nil { + return nil, errors.Wrap(err, "cannot create xpkg client") + } + pkgs.Client = client + } c.servers = diffserver.NewManager(pkgs.Client, logger, diffserver.WithSessionID(c.SessionID)) 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 +116,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 +143,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 +220,11 @@ 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) { +func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp.CompDiffProcessor, comps []*un.Unstructured, project bool) func(context.Context) (bool, error) { + msg := "cannot simulate Compositions" + if project { + msg = "cannot simulate project" + } return func(ctx context.Context) (bool, error) { output, err := proc.DiffComposition(ctx, comps, c.sel.ns, c.sel.refs) @@ -202,7 +235,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,13 +527,15 @@ 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") @@ -522,9 +557,17 @@ 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} + if project { + if built, err = in.Build(sigCtx); err != nil { + return err + } + } else { + ics, err := ImageConfigs(c.ImageConfigs, "") + if err != nil { + return err + } + built.Packages = &Packages{ICs: ics} } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages @@ -555,7 +598,7 @@ 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, project)) } // warnIgnoredFlags prints a warning to stderr for each flag set on the @@ -589,8 +632,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..15100f95 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -664,7 +664,7 @@ 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{{}}) + diff := tc.args.cmd.diffCompositions(k, logging.NewNopLogger(), tc.args.proc, []*un.Unstructured{{}}, true) 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 +838,7 @@ func TestProviderSource(t *testing.T) { cases := map[string]struct { reason string providers string + project bool want source }{ "Flag": { @@ -845,14 +846,19 @@ func TestProviderSource(t *testing.T) { providers: "providers.yaml", want: providersFlag, }, - "WithoutFlag": { - reason: "Without --providers the project's provider dependencies start.", - want: providersProject, + "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) } }) From aecfd095ec84ecc720576ed9ef8d6d0399e6fb01 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:27:09 -0500 Subject: [PATCH 04/27] feat(composition): add composition simulate Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/composition/composition.go | 1 + cmd/crossplane/composition/help/simulate.md | 19 +++ .../composition/help/simulate_examples.md | 27 ++++ cmd/crossplane/composition/simulate.go | 60 +++++++++ cmd/crossplane/composition/simulate_test.go | 124 ++++++++++++++++++ 5 files changed, 231 insertions(+) create mode 100644 cmd/crossplane/composition/help/simulate.md create mode 100644 cmd/crossplane/composition/help/simulate_examples.md create mode 100644 cmd/crossplane/composition/simulate.go create mode 100644 cmd/crossplane/composition/simulate_test.go 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..50ee755a --- /dev/null +++ b/cmd/crossplane/composition/help/simulate.md @@ -0,0 +1,19 @@ +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. + +`-f` (`--input`) names a file of Compositions to simulate, and is required. +Repeat it for more files; `-f -` reads standard input. Every resource in the +files must be a Composition; to simulate a project, use `project simulate`. +Functions come from the cluster. `--cache-dir` sets where provider 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 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. + 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..e6fda201 --- /dev/null +++ b/cmd/crossplane/composition/simulate.go @@ -0,0 +1,60 @@ +/* +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" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "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 to simulate. Takes one path per flag; repeat it for more. - reads standard input." placeholder:"PATH" required:"" sep:"none" 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, simulate.ClassComposition) + if 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..337fc766 --- /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, keeps commas in paths, 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) + } +} From d1809916d1cb169a31550dd5437e57800ea95695 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 20:19:37 -0500 Subject: [PATCH 05/27] test(composition): restore the Composition cases and add a pinned-function test Restore TestCompDiffIntegration, the CompPlanText and CompPlanJSON cases of TestSimulatePlanOutput and the testdata they read. Rewrite TestSimulatePinnedChangedFunction as a `project simulate` test on the embedded fixture: it pins an XR to its Composition's revision and checks that a revision calling a changed function is rendered, and one that does not stays deferred. The harness parses a composition.Cmd alongside the project one and runs CompositionDiffTest cases as `composition simulate` with -f per file. Restore the testutils colour and ANSI helpers the Composition cases compare output with. The restored cases start their clusters with integration.Start, with the case's testdata//crds as CRDDirs, rather than the harness's deleted copy of it. leastPrivilegeRules, the RBAC two of them run under, lives beside TestCompDiffIntegration. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../project/integration_plan_output_test.go | 197 +- .../integration_simulate_harness_test.go | 161 +- cmd/crossplane/project/integration_test.go | 2875 +++++++++++++++++ .../comp/api-version-updated-composition.yaml | 32 + ...vision-templating-updated-composition.yaml | 37 + .../testdata/comp/composition-no-changes.yaml | 33 + .../field-removal-updated-composition.yaml | 32 + .../mixed-status-updated-composition.yaml | 59 + .../testdata/comp/net-new-composition.yaml | 37 + .../api-version-original-composition.yaml | 32 + .../comp/resources/claim-composition.yaml | 32 + ...emplating-composition-kubectl-applied.yaml | 41 + ...claim-revision-templating-composition.yaml | 39 + .../testdata/comp/resources/claim-xrd.yaml | 27 + .../comp/resources/custom-namespace.yaml | 4 + .../resources/cycle/existing-cycle-xr.yaml | 12 + ...isting-api-version-downstream-v1beta1.yaml | 10 + .../resources/existing-api-version-xr.yaml | 11 + .../comp/resources/existing-claim-1-xr.yaml | 21 + .../comp/resources/existing-claim-1.yaml | 18 + .../comp/resources/existing-claim-2-xr.yaml | 21 + .../comp/resources/existing-claim-2.yaml | 18 + .../existing-claim-downstream-1.yaml | 10 + .../existing-claim-downstream-2.yaml | 10 + ...xisting-claim-revision-ref-downstream.yaml | 14 + ...vision-ref-kubectl-applied-downstream.yaml | 12 + ...claim-revision-ref-kubectl-applied-xr.yaml | 15 + ...ng-claim-revision-ref-kubectl-applied.yaml | 20 + .../existing-claim-revision-ref-xr.yaml | 15 + .../existing-claim-revision-ref.yaml | 20 + .../existing-conditional-gating-stage0.yaml | 12 + .../existing-conditional-gating-xr.yaml | 10 + .../existing-custom-ns-downstream.yaml | 11 + .../comp/resources/existing-custom-ns-xr.yaml | 10 + .../existing-downstream-deleting.yaml | 11 + .../existing-downstream-drifted.yaml | 14 + .../resources/existing-downstream-manual.yaml | 11 + ...isting-downstream-pinned-revision-ref.yaml | 15 + .../existing-downstream-revision-ref.yaml | 19 + .../resources/existing-downstream-v2.yaml | 13 + .../existing-downstream-with-argocd.yaml | 14 + .../existing-sequencer-gating-stage0.yaml | 12 + .../existing-sequencer-gating-xr.yaml | 10 + .../resources/existing-sha256-downstream.yaml | 11 + .../comp/resources/existing-sha256-xr.yaml | 10 + .../existing-unverified-cluster-xr.yaml | 11 + ...existing-xr-1-kubectl-applied-current.yaml | 13 + .../existing-xr-1-kubectl-applied.yaml | 13 + .../comp/resources/existing-xr-deleting.yaml | 14 + ...ing-xr-listing-uncontrolled-resources.yaml | 22 + .../comp/resources/existing-xr-manual.yaml | 11 + .../existing-xr-pinned-revision-ref.yaml | 18 + .../resources/existing-xr-revision-ref.yaml | 15 + .../resources/existing-xr-selector-match.yaml | 18 + .../existing-xr-selector-mismatch.yaml | 18 + .../comp/resources/existing-xr-v2.yaml | 12 + .../resources/existing-xr-with-argocd.yaml | 14 + .../field-removal/composition-with-field.yaml | 33 + .../field-removal/existing-downstream.yaml | 11 + .../resources/field-removal/existing-xr.yaml | 10 + .../comp/resources/functions-sha256.yaml | 17 + .../comp/resources/mixed-downstream-db-1.yaml | 11 + .../comp/resources/mixed-downstream-db-2.yaml | 11 + .../comp/resources/mixed-downstream-db-3.yaml | 12 + .../resources/mixed-downstream-network-1.yaml | 11 + .../resources/mixed-downstream-network-2.yaml | 11 + .../resources/mixed-downstream-network-3.yaml | 11 + .../resources/mixed-downstream-storage-1.yaml | 11 + .../resources/mixed-downstream-storage-2.yaml | 11 + .../resources/mixed-downstream-storage-3.yaml | 11 + .../resources/mixed-status-composition.yaml | 58 + .../testdata/comp/resources/mixed-xr-1.yaml | 10 + .../testdata/comp/resources/mixed-xr-2.yaml | 10 + .../testdata/comp/resources/mixed-xr-3.yaml | 10 + .../collision-composition-revision.yaml | 56 + .../collision-composition.yaml | 52 + .../namespace-collision/configmap-ns-a.yaml | 8 + .../namespace-collision/configmap-ns-b.yaml | 8 + .../existing-downstream-ns-a.yaml | 14 + .../existing-downstream-ns-b.yaml | 14 + .../namespace-collision/existing-xr-ns-a.yaml | 15 + .../namespace-collision/existing-xr-ns-b.yaml | 15 + .../namespace-collision/namespace-ns-a.yaml | 4 + .../namespace-collision/namespace-ns-b.yaml | 4 + .../nested-xr/existing-child-downstream.yaml | 16 + .../nested-xr/existing-nested-child-xr.yaml | 14 + .../existing-parent-direct-downstream.yaml | 12 + .../nested-xr/existing-parent-xr.yaml | 12 + .../nested-xr/parent-composition.yaml | 49 + .../comp/resources/nested-xr/parent-xrd.yaml | 50 + .../resources/original-composition-2.yaml | 33 + ...l-composition-kubectl-applied-current.yaml | 41 + .../original-composition-kubectl-applied.yaml | 40 + .../removal-test-original-composition.yaml | 45 + ...templating-composition-older-revision.yaml | 40 + ...ision-templating-composition-revision.yaml | 39 + .../revision-templating-composition.yaml | 39 + .../comp/resources/sha256-composition.yaml | 33 + .../resources/shared-child-xrs-differing.yaml | 23 + .../comp/resources/status-downstream-1.yaml | 11 + .../comp/resources/status-downstream-2.yaml | 11 + .../status-indicator-composition.yaml | 33 + .../testdata/comp/resources/status-xr-1.yaml | 10 + .../testdata/comp/resources/status-xr-2.yaml | 10 + .../comp/resources/test-namespace.yaml | 4 + .../unverified-cluster-composition.yaml | 15 + ...vision-templating-updated-composition.yaml | 43 + .../status-indicator-updated-composition.yaml | 35 + .../comp/updated-claim-composition.yaml | 32 + .../comp/updated-collision-composition.yaml | 50 + .../comp/updated-composition-2-changed.yaml | 36 + .../testdata/comp/updated-composition-2.yaml | 33 + .../comp/updated-composition-labeled.yaml | 35 + .../testdata/comp/updated-composition.yaml | 33 + ...pdated-conditional-gating-composition.yaml | 57 + .../comp/updated-cycle-composition.yaml | 35 + .../comp/updated-parent-composition.yaml | 50 + .../updated-sequencer-gating-composition.yaml | 53 + .../comp/updated-sha256-composition.yaml | 33 + .../updated-shared-child-composition.yaml | 36 + ...pdated-unverified-cluster-composition.yaml | 47 + .../testdata/diff/resources/cluster-xrd.yaml | 51 + .../testdata/diff/resources/composition.yaml | 33 + .../conditional-gating-composition.yaml | 80 + .../diff/resources/cycle/compositions.yaml | 67 + .../testdata/diff/resources/cycle/xrds.yaml | 100 + .../existing-other-xr-downstream.yaml | 11 + .../diff/resources/existing-other-xr.yaml | 13 + .../testdata/diff/resources/functions.yaml | 39 + .../resources/removal-test-composition.yaml | 32 + .../removal-test-ns-downstream-resource1.yaml | 10 + .../removal-test-ns-downstream-resource2.yaml | 10 + ...test-ns-downstream-without-controller.yaml | 15 + .../sequencer-gating-composition.yaml | 68 + .../resources/shared-child-composition.yaml | 35 + .../project/testdata/diff/resources/xrd.yaml | 51 + .../testdata/diff/shared-child-xrs.yaml | 24 + internal/diff/testutils/utils.go | 50 + internal/simulate/simtest/plan_assertions.go | 713 ++++ .../simulate/simtest/structured_assertions.go | 373 +++ .../simtest/structured_assertions_test.go | 206 ++ 141 files changed, 7835 insertions(+), 10 deletions(-) create mode 100644 cmd/crossplane/project/integration_test.go create mode 100644 cmd/crossplane/project/testdata/comp/api-version-updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/claim-revision-templating-updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/composition-no-changes.yaml create mode 100644 cmd/crossplane/project/testdata/comp/field-removal-updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/mixed-status-updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/net-new-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/api-version-original-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/claim-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition-kubectl-applied.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/claim-revision-templating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/claim-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/custom-namespace.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/cycle/existing-cycle-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-api-version-downstream-v1beta1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-api-version-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-1-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-2-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-downstream-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-kubectl-applied.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-claim-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-stage0.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-conditional-gating-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-custom-ns-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-deleting.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-drifted.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-manual.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-pinned-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-v2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-downstream-with-argocd.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-stage0.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-sequencer-gating-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-sha256-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-sha256-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-unverified-cluster-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied-current.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-1-kubectl-applied.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-deleting.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-listing-uncontrolled-resources.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-manual.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-pinned-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-match.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-selector-mismatch.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-v2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/existing-xr-with-argocd.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/field-removal/composition-with-field.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/field-removal/existing-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/field-removal/existing-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/functions-sha256.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-db-3.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-network-3.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-downstream-storage-3.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-status-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-xr-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-xr-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/mixed-xr-3.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition-revision.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/collision-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-a.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/configmap-ns-b.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-a.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-downstream-ns-b.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-a.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/existing-xr-ns-b.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-a.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/namespace-collision/namespace-ns-b.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-child-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-nested-child-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-direct-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/nested-xr/existing-parent-xr.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/nested-xr/parent-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/original-composition-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied-current.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/original-composition-kubectl-applied.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/removal-test-original-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-older-revision.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/revision-templating-composition-revision.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/revision-templating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/sha256-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/shared-child-xrs-differing.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/status-downstream-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/status-downstream-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/status-indicator-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/status-xr-1.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/status-xr-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/test-namespace.yaml create mode 100644 cmd/crossplane/project/testdata/comp/resources/unverified-cluster-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/revision-templating-updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/status-indicator-updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-claim-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-collision-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-composition-2-changed.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-composition-2.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-composition-labeled.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-conditional-gating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-cycle-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-parent-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-sequencer-gating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-sha256-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-shared-child-composition.yaml create mode 100644 cmd/crossplane/project/testdata/comp/updated-unverified-cluster-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/cluster-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/conditional-gating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/cycle/compositions.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/cycle/xrds.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-other-xr-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-other-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/functions.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-resource2.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-gating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/shared-child-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/shared-child-xrs.yaml create mode 100644 internal/simulate/simtest/plan_assertions.go create mode 100644 internal/simulate/simtest/structured_assertions.go create mode 100644 internal/simulate/simtest/structured_assertions_test.go diff --git a/cmd/crossplane/project/integration_plan_output_test.go b/cmd/crossplane/project/integration_plan_output_test.go index ddd27bab..6b242615 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 project's functions" { + t.Errorf("\n%s\nsimulate -o json: want a changed pinned composite rendered against %s, got %+v", tc.reason, rev, c) + } + // The plan is rendered 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..550f3ff8 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -16,12 +16,29 @@ 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 +// 6a140b6, plus later upstream additions through d93eb87), written as its comp and xr commands' CLI args plus an expected +// exit code and output. runSimulate translates those args into +// `crossplane 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. +// # Gap and environment skips +// +// Cases the current simulate surface cannot express are ported in full +// and skipped, never dropped, so each names what it waits for: +// +// - gap: — the simulate command lacks the feature this case +// needs. Grep the skip reasons to find what to enable as features +// land. +// - env: — the environment lacks something the case needs. +// The render engine runs functions as Docker containers, so without +// a Docker daemon no rendering case can run; cases that would not +// render cannot be told apart upfront, so they skip too. +// +// testdata/README.md describes how a case runs, the skips, and how to run +// the suite and read its coverage. package project @@ -41,8 +58,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 +72,130 @@ 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 (or project) 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"` + FunctionCredentials string `help:"Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` + EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` + DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` + Resource []string `help:"Composite resources to scope to." sep:","` + IncludeManual bool `help:"Include composites with a manual update policy." name:"include-manual"` + AnalyzeUnchanged bool `help:"Analyze compositions identical to their in-cluster version." name:"analyze-unchanged"` + AnalyzeOn string `help:"Smallest composition change that triggers impact analysis." name:"analyze-on"` + Files []string `arg:"" help:"Input files." optional:""` +} + +// parseSimulateArgs parses one case's CLI-style args. A parse failure is +// 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 +} + +// simulateGap returns the reason the parsed args cannot run against the +// current simulate surface, or "" when they can. The runner calls it +// before starting envtest, so a skipped case costs nothing, and +// runSimulate never executes args it could not express. +func simulateGap(_ DiffTestType, _ *simArgs) string { + return "" +} + +// 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.ProjectResult. +func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { + t.Helper() + + cmd := "project" + if testType == CompositionDiffTest { + cmd = "composition" + } + argv := []string{ + cmd, "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.FunctionCredentials != "" { + argv = append(argv, "--function-credentials="+a.FunctionCredentials) + } + 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 project +// simulate` command against cfg, and returns the exit code the command +// would exit with, the error it would fail with, and everything it wrote, +// including the error as main prints it. +// Each call parses a fresh command, so a second run starts with cold +// client caches just as a second CLI invocation would; the determinism +// cases rely on it. +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 +211,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 +253,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..64bf0d8a --- /dev/null +++ b/cmd/crossplane/project/integration_test.go @@ -0,0 +1,2875 @@ +//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 + functionCredentials string // Path to function credentials file (optional) + eventualState bool // Enable eventual state simulation for XR or composition tests (optional) + timeout time.Duration // Custom timeout for this test (0 = use default) + resources []string // For composition tests: --resource values; each entry passed as one --resource flag + resourcesCSV string // For composition tests: alternative single --resource=a,b style invocation + includeManual bool // For composition tests: pass --include-manual flag + analyzeUnchanged bool // For composition tests: pass the deprecated --analyze-unchanged flag + analyzeOn string // For composition tests: pass --analyze-on= (empty = rely on the default) + // dryRunOn passes --dry-run-on= (empty = rely on the default, which is "all"). + // The flag lives on CommonCmdFields, so it applies to both `xr` and `comp`. + dryRunOn string + // awaitAdmissionDenialFor names a manifest whose resources a correctly-installed + // ValidatingAdmissionPolicy must refuse. Before running the diff, the harness dry-run + // creates each of them until the apiserver rejects it. + // + // This is not belt-and-braces: the apiserver loads admission policies through an informer, + // so a diff issued immediately after the policy is created races the plugin and observes a + // cluster that accepts everything. Measured on envtest 1.32, the policy took ~600ms and four + // attempts to take effect — long enough that without this gate the test would pass or fail + // depending on machine load, and a "pass" would prove nothing. + awaitAdmissionDenialFor string + // assertDeterministicAcrossRuns runs the command twice against the same cluster and requires + // byte-identical stdout, then applies the usual assertions to the second run. Needed for + // server-generated identity: the apiserver runs names.Generator before the dry-run + // short-circuit, so a generateName addition whose identity was not restored would emit a + // different random name on each run — which a single-run test cannot see. + assertDeterministicAcrossRuns bool + skip bool + skipReason string + // JSON output support: set outputFormat to "json" to use structured assertions. + outputFormat string // "json" or "" (default=visual diff) + // expectedStructured is asserted against the JSON output in either mode. It is typed as the + // Expectation interface, which every builder level implements, so a chain can end where it ends. + expectedStructured simtest.Expectation +} + +// runIntegrationTest runs a single integration test case for both XR and composition diff commands. +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 function-credentials if specified + if tt.functionCredentials != "" { + args = append(args, fmt.Sprintf("--function-credentials=%s", tt.functionCredentials)) + } + + // Add eventual-state flag if specified (supported by both `xr` and `comp`). + if tt.eventualState { + args = append(args, "--eventual-state") + } + + // --dry-run-on lives on CommonCmdFields, so unlike --analyze-on it is threaded for both + // command types. An empty value relies on the flag's own default. + if tt.dryRunOn != "" { + args = append(args, "--dry-run-on="+tt.dryRunOn) + } + + // Add --resource flags (composition tests only) + if testType == CompositionDiffTest { + for _, r := range tt.resources { + args = append(args, fmt.Sprintf("--resource=%s", r)) + } + + if tt.resourcesCSV != "" { + args = append(args, fmt.Sprintf("--resource=%s", tt.resourcesCSV)) + } + + if tt.includeManual { + args = append(args, "--include-manual") + } + + if tt.analyzeUnchanged { + args = append(args, "--analyze-unchanged") + } + + if tt.analyzeOn != "" { + args = append(args, "--analyze-on="+tt.analyzeOn) + } + } + + // Add files as positional arguments + args = append(args, testFiles...) + + // Skip cases the current simulate surface cannot express, before the cluster starts, so a + // skipped case costs nothing. The gap reasons are greppable; see the harness package comment. + if reason := simulateGap(testType, parseSimulateArgs(t, testType, args)); reason != "" { + t.Skip(reason) + return + } + + // The render engine runs functions as Docker containers. Without a Docker daemon no rendering + // case can run; cases that would not render anyway cannot be told apart upfront, so they skip. + if !dockerAvailable() { + t.Skip("env: no Docker daemon for the function render engine") + return + } + + // 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, + }) + ctx := cluster.Context + + // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff + // cannot race the apiserver's policy informer. See the field's doc comment. + if err := integration.AwaitAdmissionDenial(ctx, cluster.Client, tt.awaitAdmissionDenialFor); err != nil { + t.Fatalf("admission policy never took effect: %v", err) + } + + // runOnce executes the command against the running cluster, leaving its output in stdout / + // stderr. Everything the command layer touches is rebuilt per call, so a second run starts + // with cold client caches just as a second invocation of the CLI would. + runOnce := func(runCfg *rest.Config) (int, error) { + stdout.Reset() + stderr.Reset() + + return runSimulate(t, runCfg, testType, args, &stdout, &stderr) + } + + exitCode, err := runOnce(cluster.Config) + + if tt.assertDeterministicAcrossRuns { + first := stdout.String() + + exitCode, err = runOnce(cluster.Config) + + if second := stdout.String(); second != first { + t.Errorf("output differs between two runs against the same cluster; a value the apiserver generated has leaked into the diff.\nfirst run:\n%s\nsecond run:\n%s", first, second) + } + } + + if cluster.UserConfig != nil { + admin, adminErr := stdout.String(), err + + exitCode, err = runOnce(cluster.UserConfig) + + if restricted := stdout.String(); restricted != admin { + t.Errorf("output with only the case's RBAC rules differs from the admin's; the run needs access the rules don't grant.\nadmin (error: %v):\n%s\nrestricted (error: %v, stderr below):\n%s\n%s", + adminErr, admin, err, restricted, stderr.String()) + } + } + + // Check exit code matches expected + if exitCode != tt.expectedExitCode { + t.Errorf("expected exit code %d, got %d", tt.expectedExitCode, exitCode) + } + + // Assert stderr expectations before any of the early returns below, so they apply in every + // output mode (human-readable, JSON, and expected-error cases alike). + for _, want := range tt.expectedStderrContains { + if !strings.Contains(stderr.String(), want) { + t.Errorf("expected stderr to contain %q, got:\n%s", want, stderr.String()) + } + } + + if tt.expectedError && err == nil { + t.Fatal("expected error but got none") + } + + if !tt.expectedError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + // Check for specific error message if expected. + // + // Three modes: + // 1. expectedError + expectedErrorContains: substring-check the error. + // 2. expectedError + structured assertion: any error is fine here; + // the structured assertion below validates errors[]. + // 3. unexpected error: already caught by the t.Fatalf above. + if err != nil && tt.expectedError { + hasStructuredAssertion := tt.expectedStructured != nil + + switch { + case tt.expectedErrorContains != "": + if strings.Contains(err.Error(), tt.expectedErrorContains) { + t.Logf("Got expected error containing: %s", tt.expectedErrorContains) + } else { + t.Errorf("Expected error containing %q, got: %v", tt.expectedErrorContains, err) + } + case hasStructuredAssertion: + t.Logf("Got expected error; structured assertion will validate errors[]") + default: + t.Errorf("expectedError set without expectedErrorContains or structured assertion; got: %v", err) + } + } + + // Skip stdout check for expected-error cases that use the legacy + // substring assertion. Cases that pin a structured assertion fall + // through so the JSON path is exercised against errors[] / changes. + if tt.expectedError && tt.expectedErrorContains != "" && + tt.expectedStructured == nil { + return + } + + // Check the output + outputStr := stdout.String() + + // Handle JSON output format with structured assertions + if tt.outputFormat == "json" && tt.expectedStructured != nil { + simtest.AssertStructured(t, outputStr, tt.expectedStructured) + return + } + + // Using TrimSpace because the output might have trailing newlines + if !strings.Contains(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { + // Strings aren't equal, *including* ansi. but we can compare ignoring ansi to determine what output to + // show for the failure. if the difference is only in color codes, we'll show escaped ansi codes. + out := outputStr + + expect := tt.expectedOutput + if tu.CompareIgnoringAnsi(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { + out = strconv.QuoteToASCII(outputStr) + expect = strconv.QuoteToASCII(tt.expectedOutput) + } + + t.Fatalf("expected output to contain:\n%s\n\nbut got:\n%s", expect, out) + } +} + +// leastPrivilegeRules is the access a diff of an XNopResource composing XDownstreamResources needs, the +// types the testdata XRDs, compositions and CRDs use. It grants Crossplane's own types only get, except a +// list of CompositionRevisions, which are found by their composition's label. It grants list only where a +// lookup is scoped by a label. xrVerbs are the verbs on +// the XR type: get to read the existing XR, patch and create to dry-run it, and list only where the +// command finds composites by listing them. +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"}}, + } +} + +// TestDiffIntegration runs an integration test for the diff command. +// 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"), + }, + // The comp side of TestDiffIntegration/ResourceRemovalSkipsResourcesTheXRDoesNotControl (#552): + // the updated composition stops rendering resource2, which the XR controls, so it is removed; + // the XR's resourceRefs also name a resource another XR controls and one with no controller, + // which the composition doesn't render either, and neither of those is removed. + "CompositionDropsResourceRemovingOnlyWhatTheXRControls": { + reason: "A composition that stops rendering resources removes only those the XR controls", + // What this case pins: test-resource's resourceRefs still name two resources it doesn't control. + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/removal-test-original-composition.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/comp/resources/functions.yaml", + // Deliberately not in crossplaneManagedResources: its controller reference was removed, so + // no XR controls it, though test-resource's resourceRefs still name it. + "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", + }, + crossplaneManagedResources: []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. + // + // As in TestDiffIntegration/CompositionRevisionUpgradesResourceAPIVersion, the XR composes the + // resource as Crossplane leaves it, named in its resourceRefs at v1beta1, so this also checks + // that it is not reported as removed (#552). + reason: "Validates composition upgrade that changes resource API version shows as update not remove/add", + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + // NOTE: xapimigrate CRD is auto-loaded from testdata/comp/crds/ + // We apply no XRD for XApiMigrateResource because it is a regular managed resource + // (not a composite), and an XRD would make it composite, causing infinite recursion. + "testdata/comp/resources/api-version-original-composition.yaml", + "testdata/comp/resources/functions.yaml", + }, + crossplaneManagedResources: []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/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/internal/diff/testutils/utils.go b/internal/diff/testutils/utils.go index bd859f4c..1ddffa3e 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,54 @@ 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) +} + +// CompareIgnoringAnsi compares two strings after stripping ANSI special characters. +func CompareIgnoringAnsi(expected, actual string) bool { + // Strip ANSI codes from both strings + ansiPattern := regexp.MustCompile("\x1b\\[[0-9;]*m") + expectedStripped := ansiPattern.ReplaceAllString(expected, "") + actualStripped := ansiPattern.ReplaceAllString(actual, "") + + // Compare the stripped strings + return expectedStripped == actualStripped +} + // 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/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go new file mode 100644 index 00000000..61bb04ad --- /dev/null +++ b/internal/simulate/simtest/plan_assertions.go @@ -0,0 +1,713 @@ +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 { + if got := valueAt(t, p.Changes, planned, path); !reflect.DeepEqual(got, want) { + t.Errorf("%s: field %s: expected %v, got %v", id, path, want, got) + } + } + + for path, want := range changes { + 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 && reflect.DeepEqual(want[0], want[1]) { + if c := changesUnder(t, p.Changes, path); len(c) > 0 { + t.Errorf("%s: field %s: expected unchanged (%v), got changes %+v", id, path, want[0], c) + } + + for i, u := range []*unstructured.Unstructured{p.Before, p.After} { + if got, ok := objectValueAt(t, u, path); !ok || !reflect.DeepEqual(got, want[0]) { + t.Errorf("%s: field %s in %s: expected %v, got %v (present: %t)", id, path, []string{"before", "after"}[i], want[0], got, ok) + } + } + + continue + } + + if got := valueAt(t, p.Changes, false, path); !reflect.DeepEqual(got, want[0]) { + t.Errorf("%s: field %s old value: expected %v, got %v", id, path, want[0], got) + } + + if got := valueAt(t, p.Changes, true, path); !reflect.DeepEqual(got, want[1]) { + t.Errorf("%s: field %s new value: expected %v, got %v", id, path, want[1], got) + } + } +} + +// assertDryRun checks a plan's dry-run verification: either that its desired state was reported as NOT +// verified against the apiserver (for a reason, optionally with the cluster's own explanation), or that +// no such report is present at all. +func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunExpectation) { + t.Helper() + + if want.mustBePerformed { + if got != nil { + t.Errorf("%s: expected the desired state to have been dry-run against the apiserver, but it reports skipReason %q (detail: %q)", + id, got.SkipReason, got.Detail) + } + + return + } + + if want.skipReason == "" { + return + } + + if got == nil { + t.Errorf("%s: expected dryRun.skipReason %q, but no dryRun was reported (absence means the desired state went through the apiserver)", + id, want.skipReason) + + return + } + + if got.SkipReason != want.skipReason { + t.Errorf("%s: dryRun.skipReason: expected %q, got %q", id, want.skipReason, got.SkipReason) + } + + 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() + + for _, w := range want { + assertWarningExpectation(t, got, w) + } + + if (len(want) > 0 || noWarnings) && len(got) != len(want) { + t.Errorf("Expected %d warnings in structured output, got %d: %v", len(want), len(got), warningMessages(got)) + } +} + +// assertWarningExpectation finds a warning whose message contains want's substring and, when the +// expectation pins one, whose context map is exactly want's. Both conditions select the warning, since +// several warnings legitimately share a message and differ only in context. +func assertWarningExpectation(t *testing.T, got []simulate.Warning, want warningMatch) { + t.Helper() + + var contextsSeen []map[string]string + + for _, w := range got { + if !strings.Contains(w.Message, want.messageContains) { + continue + } + + if want.context == nil || reflect.DeepEqual(want.context, w.Context) { + return + } + + contextsSeen = append(contextsSeen, w.Context) + } + + if len(contextsSeen) > 0 { + t.Errorf("warning %q context mismatch: expected %v, got only %v", want.messageContains, want.context, contextsSeen) + return + } + + t.Errorf("expected a warning containing %q in structured output; got %v", want.messageContains, warningMessages(got)) +} + +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 + } + + var node any = u.Object + + for _, tok := range mustParsePath(t, path) { + 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 +} + +// valueAt returns the value at path on one side of a plan's changes: the planned side, or else the +// actual. A leaf is its change's value; a non-leaf is re-assembled from the changed leaves under it. +// Nil means no change sets anything at or under path on that side. +func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string) any { + t.Helper() + + prefix := mustParsePath(t, path) + + var root any + + for _, c := range changes { + v := c.Actual + if planned { + v = c.Planned + } + + if v == nil { + continue + } + + rel, ok := under(mustParsePath(t, c.Path), prefix) + if !ok { + continue + } + + if len(rel) == 0 { + return v.Value + } + + root = insert(root, rel, v.Value) + } + + return assemble(root) +} + +// insert sets v at toks below node, building maps as it goes. Lists are built as index maps, which +// assemble turns into slices. +func insert(node any, toks 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..ff6736aa --- /dev/null +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -0,0 +1,206 @@ +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")}, + } + + cases := map[string]struct { + path string + planned bool + want any + }{ + "Leaf": {path: "spec.a", planned: true, want: "new-a"}, + "LeafActual": {path: "spec.a", want: "old-a"}, + "Subtree": {path: "spec", planned: true, want: map[string]any{"a": "new-a", "list": []any{map[string]any{"x": "x0"}}}}, + "SubtreeActual": {path: "spec.list", want: []any{nil, map[string]any{"x": "gone"}}}, + "QuotedKey": {path: "metadata.labels['app.io/name']", planned: true, want: "app"}, + "Absent": {path: "spec.b", planned: true, want: nil}, + "PrefixIsNotPath": {path: "spec.list[0].xy", planned: true, want: nil}, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + if got := valueAt(t, changes, tc.planned, tc.path); !reflect.DeepEqual(got, tc.want) { + t.Errorf("valueAt(%q, planned=%t) = %#v, want %#v", tc.path, tc.planned, got, tc.want) + } + }) + } +} From d1e4737e7c6e289faadcf75367350e94beae8f1c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:44:37 -0500 Subject: [PATCH 06/27] docs(simulate): cross-reference project and composition simulate Make the shared Docker error, --image-configs help and pinned-revision detail command-neutral, and reword two integration test comments. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/composition/help/simulate.md | 3 ++- cmd/crossplane/project/help/simulate.md | 3 ++- cmd/crossplane/project/integration_test.go | 4 ++-- design/one-pager-cli-simulate.md | 4 ++-- internal/simulate/flags.go | 4 ++-- internal/simulate/report.go | 2 +- internal/simulate/report_test.go | 2 +- internal/simulate/result_test.go | 4 ++-- internal/simulate/run.go | 2 +- 9 files changed, 15 insertions(+), 13 deletions(-) diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index 50ee755a..1302fc26 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -5,7 +5,8 @@ the difference from what's in the cluster. It doesn't change the cluster. `-f` (`--input`) names a file of Compositions to simulate, and is required. Repeat it for more files; `-f -` reads standard input. Every resource in the -files must be a Composition; to simulate a project, use `project simulate`. +files 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/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index bee110e5..f412cd22 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -1,7 +1,8 @@ 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`. 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 diff --git a/cmd/crossplane/project/integration_test.go b/cmd/crossplane/project/integration_test.go index 64bf0d8a..a750a520 100644 --- a/cmd/crossplane/project/integration_test.go +++ b/cmd/crossplane/project/integration_test.go @@ -615,7 +615,7 @@ Summary: 2 modified`, WithDownstreamSummary(1, 0, 0). WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"), }, - // The comp side of TestDiffIntegration/ResourceRemovalSkipsResourcesTheXRDoesNotControl (#552): + // 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. @@ -1463,7 +1463,7 @@ Summary: 1 modified`, // +++ addition and a --- removal. The composition diff itself WILL show the template change // from v1beta1 to v1beta2. // - // As in TestDiffIntegration/CompositionRevisionUpgradesResourceAPIVersion, the XR composes the + // 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", diff --git a/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index eb37ba23..825318e6 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` +(all added), then `xr simulate` as a follow-up 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/simulate/flags.go b/internal/simulate/flags.go index 27ab5299..1ac2f17a 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 the project's imageConfigs, or supplying them without a project." name:"image-configs" optional:"" predictor:"yaml_file" type:"existingfile"` Impersonation kube.ImpersonationFlags `embed:""` diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 6fc2cb57..0513a526 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 simulated functions" } switch impact.Status { diff --git a/internal/simulate/report_test.go b/internal/simulate/report_test.go index d4d3c9e6..487dbc48 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 simulated functions"}}, Compositions: []CompositionResult{{Ref: compPlan}}, Notes: notes(), }, diff --git a/internal/simulate/result_test.go b/internal/simulate/result_test.go index 968a86c9..6f1dda83 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 simulated 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 simulated functions Plan: 0 to add, 0 to change, 0 to destroy. ` diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 03bde105..448a5788 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -538,7 +538,7 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg 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 From cb94693d259e1530a615f359364c634d96b576e1 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:12:20 -0500 Subject: [PATCH 07/27] fix(simulate): use only --image-configs for Composition inputs composition simulate fell back to ./crossplane-project.yaml for imageConfigs, so a run inside a project directory got rewrites it wasn't asked for, or failed on a project file it wasn't given. Non-project runs now take imageConfigs only from --image-configs; project mode is unchanged. The shared help and the flag help say so. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/flags.go | 4 ++-- internal/simulate/help/imageconfigs.md | 4 +++- internal/simulate/run.go | 13 ++++++++++++- internal/simulate/run_test.go | 18 ++++++++++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/internal/simulate/flags.go b/internal/simulate/flags.go index 1ac2f17a..888d66e8 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, or supplying them without a project." 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:""` 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/run.go b/internal/simulate/run.go index 448a5788..671d8c88 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -563,7 +563,7 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg return err } } else { - ics, err := ImageConfigs(c.ImageConfigs, "") + ics, err := c.flagImageConfigs() if err != nil { return err } @@ -601,6 +601,17 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, project)) } +// 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 // command line that does nothing in this run: --skip-plan runs no diff // servers, so --providers and --session-id do nothing with it, and diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index 15100f95..16259432 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -914,3 +914,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) + } +} From d5ea7e8f2a09acc8690771123fae764cfcab4ebc Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 20:19:58 -0500 Subject: [PATCH 08/27] test(composition): drop XR-only integration harness code No Composition or project case in this layer sets or calls these, so they are dead here. The xr simulate layer restores them. Removed identifiers: - IntegrationTestCase fields in integration_test.go: functionCredentials, awaitAdmissionDenialFor, assertDeterministicAcrossRuns, and their branches (the --function-credentials argv, the integration.AwaitAdmissionDenial call and the second determinism run) - integration_simulate_harness_test.go: simArgs.FunctionCredentials and its --function-credentials argv translation in simulateArgv Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integration_simulate_harness_test.go | 4 -- cmd/crossplane/project/integration_test.go | 45 ++----------------- 2 files changed, 3 insertions(+), 46 deletions(-) diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index 550f3ff8..e3787723 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -82,7 +82,6 @@ type simArgs struct { Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` NoColor bool `help:"Disable colorized output." name:"no-color"` IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` - FunctionCredentials string `help:"Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` Resource []string `help:"Composite resources to scope to." sep:","` @@ -159,9 +158,6 @@ func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { for _, p := range a.IgnorePaths { argv = append(argv, "--ignore-paths="+p) } - if a.FunctionCredentials != "" { - argv = append(argv, "--function-credentials="+a.FunctionCredentials) - } if a.EventualState { argv = append(argv, "--eventual-state") } diff --git a/cmd/crossplane/project/integration_test.go b/cmd/crossplane/project/integration_test.go index a750a520..cbc3ccc1 100644 --- a/cmd/crossplane/project/integration_test.go +++ b/cmd/crossplane/project/integration_test.go @@ -65,7 +65,6 @@ type IntegrationTestCase struct { namespace string // For composition tests (optional) xrdAPIVersion integration.XRDAPIVersion // For XR tests (optional) ignorePaths []string // Paths to ignore in diffs - functionCredentials string // Path to function credentials file (optional) eventualState bool // Enable eventual state simulation for XR or composition tests (optional) timeout time.Duration // Custom timeout for this test (0 = use default) resources []string // For composition tests: --resource values; each entry passed as one --resource flag @@ -75,25 +74,9 @@ type IntegrationTestCase struct { analyzeOn string // For composition tests: pass --analyze-on= (empty = rely on the default) // dryRunOn passes --dry-run-on= (empty = rely on the default, which is "all"). // The flag lives on CommonCmdFields, so it applies to both `xr` and `comp`. - dryRunOn string - // awaitAdmissionDenialFor names a manifest whose resources a correctly-installed - // ValidatingAdmissionPolicy must refuse. Before running the diff, the harness dry-run - // creates each of them until the apiserver rejects it. - // - // This is not belt-and-braces: the apiserver loads admission policies through an informer, - // so a diff issued immediately after the policy is created races the plugin and observes a - // cluster that accepts everything. Measured on envtest 1.32, the policy took ~600ms and four - // attempts to take effect — long enough that without this gate the test would pass or fail - // depending on machine load, and a "pass" would prove nothing. - awaitAdmissionDenialFor string - // assertDeterministicAcrossRuns runs the command twice against the same cluster and requires - // byte-identical stdout, then applies the usual assertions to the second run. Needed for - // server-generated identity: the apiserver runs names.Generator before the dry-run - // short-circuit, so a generateName addition whose identity was not restored would emit a - // different random name on each run — which a single-run test cannot see. - assertDeterministicAcrossRuns bool - skip bool - skipReason string + 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 @@ -175,11 +158,6 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC } } - // Add function-credentials if specified - if tt.functionCredentials != "" { - args = append(args, fmt.Sprintf("--function-credentials=%s", tt.functionCredentials)) - } - // Add eventual-state flag if specified (supported by both `xr` and `comp`). if tt.eventualState { args = append(args, "--eventual-state") @@ -242,13 +220,6 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC DeleteAfterSetup: tt.deleteAfterSetup, RBACRules: tt.rbacRules, }) - ctx := cluster.Context - - // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff - // cannot race the apiserver's policy informer. See the field's doc comment. - if err := integration.AwaitAdmissionDenial(ctx, cluster.Client, tt.awaitAdmissionDenialFor); err != nil { - t.Fatalf("admission policy never took effect: %v", err) - } // runOnce executes the command against the running cluster, leaving its output in stdout / // stderr. Everything the command layer touches is rebuilt per call, so a second run starts @@ -262,16 +233,6 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC exitCode, err := runOnce(cluster.Config) - if tt.assertDeterministicAcrossRuns { - first := stdout.String() - - exitCode, err = runOnce(cluster.Config) - - if second := stdout.String(); second != first { - t.Errorf("output differs between two runs against the same cluster; a value the apiserver generated has leaked into the diff.\nfirst run:\n%s\nsecond run:\n%s", first, second) - } - } - if cluster.UserConfig != nil { admin, adminErr := stdout.String(), err From 4c203dc95d574225f8386c0f37488c9c750ab02f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:14:18 -0500 Subject: [PATCH 09/27] test(composition): fix stale harness docs and reject unknown case types Drop the orphaned TestDiffIntegration doc comment, say that runSimulate runs composition simulate, and make simulateArgv fail on a test type it has no command for instead of defaulting to project simulate. Hooks' doc now names any simulate command. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integration_simulate_harness_test.go | 18 +++++++++--------- cmd/crossplane/project/integration_test.go | 1 - internal/simulate/flags.go | 2 +- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index e3787723..127f6a1d 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -74,7 +74,7 @@ const defaultTimeout = 60 * time.Second // simArgs mirrors the crossplane-diff flags the integration cases pass, // plus each command's positional files, so runSimulate can translate -// them into composition (or project) simulate's flags. +// 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"` @@ -126,12 +126,12 @@ func simulateGap(_ DiffTestType, _ *simArgs) string { func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { t.Helper() - cmd := "project" - if testType == CompositionDiffTest { - cmd = "composition" + // ponytail: 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{ - cmd, "simulate", + "composition", "simulate", "--timeout=" + a.Timeout.String(), "--output=" + a.Output, "--dry-run-on=" + a.DryRunOn, @@ -175,13 +175,13 @@ func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { return argv } -// runSimulate runs one case's args through the real `crossplane project -// simulate` command against cfg, and returns the exit code the command +// 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 determinism -// cases rely on it. +// 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() diff --git a/cmd/crossplane/project/integration_test.go b/cmd/crossplane/project/integration_test.go index cbc3ccc1..e054d122 100644 --- a/cmd/crossplane/project/integration_test.go +++ b/cmd/crossplane/project/integration_test.go @@ -345,7 +345,6 @@ func leastPrivilegeRules(xrVerbs ...string) []rbacv1.PolicyRule { } } -// TestDiffIntegration runs an integration test for the diff command. // TestCompDiffIntegration runs an integration test for the composition diff command. func TestCompDiffIntegration(t *testing.T) { t.Parallel() diff --git a/internal/simulate/flags.go b/internal/simulate/flags.go index 888d66e8..410ddf7a 100644 --- a/internal/simulate/flags.go +++ b/internal/simulate/flags.go @@ -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 { From ad4c4306be48733945e604dc3ef174c55979c729 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:14:33 -0500 Subject: [PATCH 10/27] fix(simulate): say a pinned render used the changed functions "with the simulated functions" didn't say why the pinned composite was rendered; "with the changed functions" does, for both commands. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/project/integration_plan_output_test.go | 2 +- internal/simulate/report.go | 2 +- internal/simulate/report_test.go | 2 +- internal/simulate/result_test.go | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/project/integration_plan_output_test.go b/cmd/crossplane/project/integration_plan_output_test.go index 6b242615..38ca236e 100644 --- a/cmd/crossplane/project/integration_plan_output_test.go +++ b/cmd/crossplane/project/integration_plan_output_test.go @@ -346,7 +346,7 @@ func TestSimulatePinnedChangedFunction(t *testing.T) { 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 project's functions" { + 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 diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 0513a526..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 simulated 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 487dbc48..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 simulated 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 6f1dda83..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 simulated 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 simulated functions + rendered against pinned revision net-6 with the changed functions Plan: 0 to add, 0 to change, 0 to destroy. ` From 2a805d1b5c6b5f06e8479e918b5e594a0a2068d8 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:15:03 -0500 Subject: [PATCH 11/27] test(simulate): cover the per-input simulation error wrap Assert both wrap messages: "cannot simulate project" for a project and "cannot simulate Compositions" for Composition files. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/run_test.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index 16259432..f6484a6e 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,7 @@ 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{{}}, true) + diff := tc.args.cmd.diffCompositions(k, logging.NewNopLogger(), tc.args.proc, []*un.Unstructured{{}}, !tc.args.files) 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 != "" { From b03b4b40809177fa38d65f1c50b91c228a8814a2 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:16:54 -0500 Subject: [PATCH 12/27] refactor(simulate): narrow the -f input API and document directories Unexport classify and rejectInput, which nothing outside the package calls; Class stays exported since LoadInputs takes it. Say in the composition simulate -f help that it takes a directory, as the loader does, and point a rejected List input (as kubectl get -o yaml writes) at unwrapping its items. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/composition/help/simulate.md | 6 +++--- cmd/crossplane/composition/simulate.go | 2 +- internal/simulate/inputs.go | 17 ++++++++++------- internal/simulate/inputs_test.go | 6 ++++++ 4 files changed, 20 insertions(+), 11 deletions(-) diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index 1302fc26..ad025e97 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -3,9 +3,9 @@ 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. -`-f` (`--input`) names a file of Compositions to simulate, and is required. -Repeat it for more files; `-f -` reads standard input. Every resource in the -files must be a Composition; to simulate a project, use +`-f` (`--input`) names a file, or a directory of YAML files, of Compositions to +simulate, and is required. 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/simulate.go b/cmd/crossplane/composition/simulate.go index e6fda201..5a70750b 100644 --- a/cmd/crossplane/composition/simulate.go +++ b/cmd/crossplane/composition/simulate.go @@ -42,7 +42,7 @@ type simulateCmd struct { simulate.Flags `embed:""` simulate.CompositionFlags `embed:""` - Input []string `help:"Composition file to simulate. Takes one path per flag; repeat it for more. - reads standard input." placeholder:"PATH" required:"" sep:"none" short:"f"` + Input []string `help:"Composition file or directory to simulate. Takes one path per flag; repeat it for more. - reads standard input." placeholder:"PATH" required:"" sep:"none" short:"f"` } func (c *simulateCmd) Help() string { diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go index bf262882..0aeb0777 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -56,8 +56,8 @@ func taker(c Class) (command, what string, ok bool) { return "", "", false } -// Classify returns the class of the -f input r. -func Classify(r *un.Unstructured) Class { +// classify returns the class of the -f input r. +func classify(r *un.Unstructured) Class { gvk := r.GroupVersionKind() switch { case gvk.GroupVersion().String() == projectfile.APIVersion && gvk.Kind == projectfile.Kind: @@ -69,15 +69,18 @@ func Classify(r *un.Unstructured) Class { } } -// RejectInput returns the error for an -f input r that the command taking +// rejectInput returns the error for an -f input r that the command taking // want doesn't take, pointing at the command that does take it, if any. -func RejectInput(r *un.Unstructured, want Class) error { +func rejectInput(r *un.Unstructured, want Class) error { gvk := r.GroupVersionKind() cmd, what, _ := taker(want) msg := fmt.Sprintf("-f inputs include %s %q (%s); %s takes %s", gvk.Kind, r.GetName(), gvk.GroupVersion(), cmd, what) - if cmd, what, ok := taker(Classify(r)); ok { + if cmd, what, ok := taker(classify(r)); ok { msg += fmt.Sprintf("; %s takes %s", cmd, what) } + if r.IsList() || gvk.Kind == "List" { + msg += "; pass its items as separate YAML documents instead of a List" + } return errors.New(msg) } @@ -95,8 +98,8 @@ func LoadInputs(paths []string, want Class) ([]*un.Unstructured, error) { return nil, errors.Wrap(err, "cannot load -f inputs") } for _, r := range resources { - if Classify(r) != want { - return nil, RejectInput(r, want) + if classify(r) != want { + return nil, rejectInput(r, want) } } return resources, nil diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index bcf1aa40..25521a31 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -80,6 +80,7 @@ spec: // 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 { @@ -132,6 +133,11 @@ spec: 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}, From 64a0fd381830251d85e4aafed813c11d5bb7e230 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:17:19 -0500 Subject: [PATCH 13/27] docs(simulate): describe the Composition integration cases Add TestCompDiffIntegration and its composition simulate translation to the testdata README, and smooth the design doc's command list. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/project/testdata/README.md | 10 +++++++++- design/one-pager-cli-simulate.md | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) 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/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index 825318e6..d81f45e0 100644 --- a/design/one-pager-cli-simulate.md +++ b/design/one-pager-cli-simulate.md @@ -84,7 +84,7 @@ many controllers (that remains the domain of control-plane-level simulation). Add `simulate` as a verb in the CLI's noun-first command tree, sharing one engine: `resource simulate`, `project simulate` and `composition simulate` -(all added), then `xr simulate` as a follow-up in the same stack. +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. From 91fe0e64424c0e192aa336c41623e1f6339f4314 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 14:45:57 -0500 Subject: [PATCH 14/27] docs(simulate): lead the help with the permissions simulate needs composition simulate and project simulate said, near the end of their own text, that they need the create and patch verbs. That's easy to miss and surprising, since the commands don't change the cluster: they check each rendered resource with a server-side dry run, and Kubernetes authorizes a dry-run request like a real one. Move it to an Important callout right after the summary, with that reason, the way render leads with its Docker requirement. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/composition/help/simulate.md | 18 ++++++++++-------- cmd/crossplane/project/help/simulate.md | 18 ++++++++++-------- 2 files changed, 20 insertions(+), 16 deletions(-) diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index ad025e97..dd2654d6 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -3,6 +3,16 @@ 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. Repeat it for more; `-f -` reads standard input. Every resource in them must be a Composition; to simulate a project, use @@ -10,11 +20,3 @@ Every resource in them must be a Composition; to simulate a project, use Functions come from the cluster. `--cache-dir` sets where provider 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 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. - diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index f412cd22..8b523937 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -4,6 +4,16 @@ composite resource and shows the difference from what's in the cluster. It doesn't change the cluster. To simulate Composition files against a cluster without a project, use `crossplane composition simulate`. +> **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 Docker daemon, resolves its function dependencies, and collects the @@ -13,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: From 832c678dc9ddcc113576a8462603e44b79c305f6 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 14:55:41 -0500 Subject: [PATCH 15/27] test(simtest): fail on missing fields, contradictory and double-counted expectations Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 101 ++++++++++++++---- .../simtest/structured_assertions_test.go | 75 ++++++++++--- 2 files changed, 143 insertions(+), 33 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 61bb04ad..40654147 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -286,8 +286,9 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri planned := changeType != dt.DiffTypeWordRemoved for path, want := range values { - if got := valueAt(t, p.Changes, planned, path); !reflect.DeepEqual(got, want) { - t.Errorf("%s: field %s: expected %v, got %v", id, path, want, got) + // 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) } } @@ -298,13 +299,13 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri // FieldChanges are leaf changes only, so a field rendered identically on both sides is read from // the plan's objects instead, and must have no change at or under it. - if want[0] != nil && reflect.DeepEqual(want[0], want[1]) { + if 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 || !reflect.DeepEqual(got, want[0]) { + 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) } } @@ -312,11 +313,11 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri continue } - if got := valueAt(t, p.Changes, false, path); !reflect.DeepEqual(got, want[0]) { + 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); !reflect.DeepEqual(got, want[1]) { + 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) } } @@ -328,6 +329,11 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri 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)", @@ -365,8 +371,15 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx 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, w) + assertWarningExpectation(t, got, used, w) } if (len(want) > 0 || noWarnings) && len(got) != len(want) { @@ -374,20 +387,21 @@ func assertWarnings(t *testing.T, got []simulate.Warning, want []warningMatch, n } } -// assertWarningExpectation finds a warning whose message contains want's substring and, when the -// expectation pins one, whose context map is exactly want's. Both conditions select the warning, since -// several warnings legitimately share a message and differ only in context. -func assertWarningExpectation(t *testing.T, got []simulate.Warning, want warningMatch) { +// 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 _, w := range got { - if !strings.Contains(w.Message, want.messageContains) { + 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 } @@ -596,9 +610,12 @@ func objectValueAt(t *testing.T, u *unstructured.Unstructured, path string) (any return nil, false } - var node any = u.Object + return walk(u.Object, mustParsePath(t, path)) +} - for _, tok := range 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)) { @@ -623,15 +640,45 @@ func objectValueAt(t *testing.T, u *unstructured.Unstructured, path string) (any 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. +// ponytail: integers beyond 2^53 lose precision in the round trip; compare them differently if one is ever asserted. +func valuesEqual(a, b any) bool { + na, errA := normalise(a) + nb, errB := normalise(b) + + if errA != nil || errB != nil { + return reflect.DeepEqual(a, b) + } + + return reflect.DeepEqual(na, nb) +} + +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) +} + // valueAt returns the value at path on one side of a plan's changes: the planned side, or else the // actual. A leaf is its change's value; a non-leaf is re-assembled from the changed leaves under it. -// Nil means no change sets anything at or under path on that side. -func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string) any { +// 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 + var ( + root any + found bool + ) for _, c := range changes { v := c.Actual @@ -643,19 +690,31 @@ func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string continue } - rel, ok := under(mustParsePath(t, c.Path), prefix) + 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 inner, ok := walk(v.Value, outer); ok { + return inner, true + } + + continue + } + + rel, ok := under(cp, prefix) if !ok { continue } if len(rel) == 0 { - return v.Value + return v.Value, true } root = insert(root, rel, v.Value) + found = true } - return assemble(root) + return assemble(root), found } // insert sets v at toks below node, building maps as it goes. Lists are built as index maps, which diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index ff6736aa..86ca0408 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -180,27 +180,78 @@ func TestValueAt(t *testing.T) { {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 + path string + planned bool + want any + wantFound bool }{ - "Leaf": {path: "spec.a", planned: true, want: "new-a"}, - "LeafActual": {path: "spec.a", want: "old-a"}, - "Subtree": {path: "spec", planned: true, want: map[string]any{"a": "new-a", "list": []any{map[string]any{"x": "x0"}}}}, - "SubtreeActual": {path: "spec.list", want: []any{nil, map[string]any{"x": "gone"}}}, - "QuotedKey": {path: "metadata.labels['app.io/name']", planned: true, want: "app"}, - "Absent": {path: "spec.b", planned: true, want: nil}, - "PrefixIsNotPath": {path: "spec.list[0].xy", planned: true, want: nil}, + "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) { - if got := valueAt(t, changes, tc.planned, tc.path); !reflect.DeepEqual(got, tc.want) { - t.Errorf("valueAt(%q, planned=%t) = %#v, want %#v", tc.path, tc.planned, got, tc.want) + 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) + } +} From 3049ba94902099b173cc0b0455b67d1cbfb5731e Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 14:55:41 -0500 Subject: [PATCH 16/27] test(simulate): cover directory pipeline inputs and the project provider source Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/inputs_test.go | 23 +++++++++++++++++++++++ internal/simulate/run_test.go | 6 ++++++ 2 files changed, 29 insertions(+) diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index 25521a31..03fe244f 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -58,6 +58,24 @@ spec: 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. @@ -108,6 +126,11 @@ spec: 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}, diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index f6484a6e..44cf6f3e 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -862,6 +862,12 @@ func TestProviderSource(t *testing.T) { providers: "providers.yaml", want: providersFlag, }, + "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, From 1e5b5902f859c3bca33834539a00db1a22b1f62a Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 15:13:29 -0500 Subject: [PATCH 17/27] fix(simulate): reject input Compositions that share a name Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/project/simulate.go | 4 ++++ cmd/crossplane/project/simulate_test.go | 5 +++++ .../testdata/project/dupcomps/apis/a.yaml | 8 ++++++++ .../testdata/project/dupcomps/apis/b.yaml | 8 ++++++++ .../project/dupcomps/crossplane-project.yaml | 6 ++++++ internal/simulate/inputs.go | 18 ++++++++++++++++++ internal/simulate/inputs_test.go | 5 +++++ 7 files changed, 54 insertions(+) create mode 100644 cmd/crossplane/project/testdata/project/dupcomps/apis/a.yaml create mode 100644 cmd/crossplane/project/testdata/project/dupcomps/apis/b.yaml create mode 100644 cmd/crossplane/project/testdata/project/dupcomps/crossplane-project.yaml 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/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/internal/simulate/inputs.go b/internal/simulate/inputs.go index 0aeb0777..2305927c 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -102,5 +102,23 @@ func LoadInputs(paths []string, want Class) ([]*un.Unstructured, error) { return nil, rejectInput(r, want) } } + if want == ClassComposition { + if err := RejectDuplicateNames(resources); 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 index 03fe244f..65dc5b48 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -141,6 +141,11 @@ spec: 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}, From 7f1b931da1dec496655b4ecbddccdbca9b3db2b2 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 15:27:17 -0500 Subject: [PATCH 18/27] test(simtest): don't treat unknown, sensitive or unresolved values as null Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 35 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 40654147..f404db50 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -644,6 +644,15 @@ func walk(node any, toks fieldpath.Segments) (any, bool) { // which is how the plan's values arrive. // ponytail: 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. + if _, ok := a.(marker); ok { + return false + } + + if _, ok := b.(marker); ok { + return false + } + na, errA := normalise(a) nb, errB := normalise(b) @@ -665,6 +674,24 @@ func normalise(v any) (any, error) { 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 @@ -694,6 +721,10 @@ func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string // 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 } @@ -707,10 +738,10 @@ func valueAt(t *testing.T, changes []plan.FieldChange, planned bool, path string } if len(rel) == 0 { - return v.Value, true + return leaf(v), true } - root = insert(root, rel, v.Value) + root = insert(root, rel, leaf(v)) found = true } From e2a72489816b7c4ab13ece91664adac2c142265b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:05:40 -0500 Subject: [PATCH 19/27] refactor(simulate): let each command validate its -f inputs LoadInputs takes a validate closure instead of a Class, so each command owns what it accepts. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/composition/simulate.go | 13 ++++- internal/simulate/inputs.go | 67 +++----------------------- internal/simulate/inputs_test.go | 15 +++++- 3 files changed, 33 insertions(+), 62 deletions(-) diff --git a/cmd/crossplane/composition/simulate.go b/cmd/crossplane/composition/simulate.go index 5a70750b..85307d02 100644 --- a/cmd/crossplane/composition/simulate.go +++ b/cmd/crossplane/composition/simulate.go @@ -20,9 +20,12 @@ 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" @@ -52,9 +55,17 @@ func (c *simulateCmd) Help() string { // 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, simulate.ClassComposition) + 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/internal/simulate/inputs.go b/internal/simulate/inputs.go index 2305927c..a6260e0e 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -23,61 +23,15 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" - apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" - - "github.com/crossplane/cli/v2/internal/project/projectfile" ld "github.com/crossplane/cli/v2/pkg/load" ) -// Class is a kind of -f input. Each simulate command takes one class, and -// rejects the others with the same message. -type Class int - -// The -f input classes. -const ( - // ClassUnknown is any resource no simulate command takes. - ClassUnknown Class = iota - // ClassProject is a project file, which project simulate takes. - ClassProject - // ClassComposition is a Composition, which composition simulate takes. - ClassComposition -) - -// taker returns the command that takes class c and what it takes, or false -// when no command takes c. -func taker(c Class) (command, what string, ok bool) { - switch c { - case ClassProject: - return "project simulate", "a project file", true - case ClassComposition: - return "composition simulate", "Composition files", true - case ClassUnknown: - } - return "", "", false -} - -// classify returns the class of the -f input r. -func classify(r *un.Unstructured) Class { +// 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() - switch { - case gvk.GroupVersion().String() == projectfile.APIVersion && gvk.Kind == projectfile.Kind: - return ClassProject - case gvk == apiextensionsv1.CompositionGroupVersionKind: - return ClassComposition - default: - return ClassUnknown - } -} - -// rejectInput returns the error for an -f input r that the command taking -// want doesn't take, pointing at the command that does take it, if any. -func rejectInput(r *un.Unstructured, want Class) error { - gvk := r.GroupVersionKind() - cmd, what, _ := taker(want) - msg := fmt.Sprintf("-f inputs include %s %q (%s); %s takes %s", gvk.Kind, r.GetName(), gvk.GroupVersion(), cmd, what) - if cmd, what, ok := taker(classify(r)); ok { - msg += fmt.Sprintf("; %s takes %s", cmd, what) - } + 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" } @@ -85,8 +39,8 @@ func rejectInput(r *un.Unstructured, want Class) error { } // LoadInputs loads the -f inputs at paths ("-" is stdin) and rejects any -// that aren't of class want. -func LoadInputs(paths []string, want Class) ([]*un.Unstructured, error) { +// 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()) @@ -98,12 +52,7 @@ func LoadInputs(paths []string, want Class) ([]*un.Unstructured, error) { return nil, errors.Wrap(err, "cannot load -f inputs") } for _, r := range resources { - if classify(r) != want { - return nil, rejectInput(r, want) - } - } - if want == ClassComposition { - if err := RejectDuplicateNames(resources); err != nil { + if err := validate(r); err != nil { return nil, err } } diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index 65dc5b48..837f5f62 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -23,6 +23,9 @@ import ( "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) { @@ -149,7 +152,7 @@ spec: "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; project simulate takes a project file`}, + 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.", @@ -180,7 +183,15 @@ spec: for name, tc := range cases { t.Run(name, func(t *testing.T) { - got, err := LoadInputs(tc.paths, ClassComposition) + 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) From 4c0d89b36f4e2e125bd69d14637c5d8809ad1d30 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:05:40 -0500 Subject: [PATCH 20/27] fix(composition): take -f as a comma-separated list like resource simulate Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/composition/help/simulate.md | 2 +- cmd/crossplane/composition/simulate.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index dd2654d6..a5665bbe 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -14,7 +14,7 @@ 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. Repeat it for more; `-f -` reads standard input. +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 diff --git a/cmd/crossplane/composition/simulate.go b/cmd/crossplane/composition/simulate.go index 85307d02..e2a05728 100644 --- a/cmd/crossplane/composition/simulate.go +++ b/cmd/crossplane/composition/simulate.go @@ -45,7 +45,7 @@ type simulateCmd struct { simulate.Flags `embed:""` simulate.CompositionFlags `embed:""` - Input []string `help:"Composition file or directory to simulate. Takes one path per flag; repeat it for more. - reads standard input." placeholder:"PATH" required:"" sep:"none" short:"f"` + 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 { From 15c0c97cc3b3e9eec3943e5c8f355bf27c1211d9 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:06:29 -0500 Subject: [PATCH 21/27] refactor(simulate): have Run build the xpkg client planOptions uses pkgs.Client as given. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/run.go | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 671d8c88..223ba115 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -64,7 +64,7 @@ import ( // through one set of ImageConfigs, so --image-configs rewrites both. type Packages struct { ICs []pkgv1beta1.ImageConfig - // Client is set for a project, and otherwise by planOptions. + // Client is set by Run. Client runtimexpkg.Client // Proj and DepMgr are set for a project. @@ -76,7 +76,7 @@ type Packages struct { // 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, or without a project has -// only ICs and planOptions sets its Client. Run stops c.servers when it is set. Planning +// 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) { @@ -90,13 +90,6 @@ func (c *Flags) planOptions(sigCtx context.Context, k *kong.Context, logger logg if c.Hooks != nil && c.Hooks.Router != nil { router = c.Hooks.Router } else { - if pkgs.Client == nil { - client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(pkgs.ICs)) - if err != nil { - return nil, errors.Wrap(err, "cannot create xpkg client") - } - pkgs.Client = client - } c.servers = diffserver.NewManager(pkgs.Client, logger, diffserver.WithSessionID(c.SessionID)) router = c.servers } @@ -567,7 +560,11 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg if err != nil { return err } - built.Packages = &Packages{ICs: ics} + client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)) + if err != nil { + return errors.Wrap(err, "cannot create xpkg client") + } + built.Packages = &Packages{ICs: ics, Client: client} } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages From 7639f4bb6778bae09bdff5838fef6fc0a6203741 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:07:11 -0500 Subject: [PATCH 22/27] test(simtest): reject markers nested in assembled values A marker inside an assembled map or slice was compared after JSON normalisation, so an expected literal "(known after apply)" could match it. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 31 ++++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index f404db50..997a1e15 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -644,12 +644,8 @@ func walk(node any, toks fieldpath.Segments) (any, bool) { // which is how the plan's values arrive. // ponytail: 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. - if _, ok := a.(marker); ok { - return false - } - - if _, ok := b.(marker); ok { + // A marker state matches no concrete value, nil included, at any depth. + if hasMarker(a) || hasMarker(b) { return false } @@ -663,6 +659,29 @@ func valuesEqual(a, b any) bool { 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: + for _, e := range v { + if hasMarker(e) { + return true + } + } + } + + return false +} + func normalise(v any) (any, error) { raw, err := json.Marshal(v) if err != nil { From 29d3c0629cdfe5b0a2a3cdb6ff66b33e6a765481 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:12:05 -0500 Subject: [PATCH 23/27] test(composition): expect -f to split on commas Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/composition/simulate_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/crossplane/composition/simulate_test.go b/cmd/crossplane/composition/simulate_test.go index 337fc766..08732e81 100644 --- a/cmd/crossplane/composition/simulate_test.go +++ b/cmd/crossplane/composition/simulate_test.go @@ -60,9 +60,9 @@ func TestSimulateFlags(t *testing.T) { input: []string{"comp.yaml"}, }, "RepeatedInput": { - reason: "-f repeats, keeps commas in paths, and takes - for stdin.", + reason: "-f repeats, splits on commas, and takes - for stdin.", args: []string{"-f", "a,b.yaml", "--input", "-"}, - input: []string{"a,b.yaml", "-"}, + input: []string{"a", "b.yaml", "-"}, }, "InputRequired": { reason: "Without -f the command fails rather than waiting on stdin.", From 2743322144fc9f27c8a6c4f4a5a1cb4cdb1b8c2b Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:24:57 -0500 Subject: [PATCH 24/27] test(simtest): use slices.ContainsFunc in hasMarker Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 997a1e15..4a67956e 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -672,10 +672,8 @@ func hasMarker(v any) bool { } } case []any: - for _, e := range v { - if hasMarker(e) { - return true - } + if slices.ContainsFunc(v, hasMarker) { + return true } } From 3ce993bf80692218514c7f1113fad655a4dea721 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:42:01 -0500 Subject: [PATCH 25/27] refactor(simulate): pass diffCompositions its wrap message Declare ics ahead of the branch in Run so it does not shadow err. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/run.go | 23 ++++++++++++----------- internal/simulate/run_test.go | 6 +++++- 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 223ba115..fbe9b22b 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -213,11 +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, project bool) func(context.Context) (bool, error) { - msg := "cannot simulate Compositions" - if project { - msg = "cannot simulate project" - } +// 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) @@ -551,20 +548,24 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg } 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 { - ics, err := c.flagImageConfigs() - if err != nil { + var ics []pkgv1beta1.ImageConfig + if ics, err = c.flagImageConfigs(); err != nil { return err } - client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)) - if err != nil { + var xc clixpkg.Client + if xc, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { return errors.Wrap(err, "cannot create xpkg client") } - built.Packages = &Packages{ICs: ics, Client: client} + built.Packages = &Packages{ICs: ics, Client: xc} } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages @@ -595,7 +596,7 @@ 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, project)) + return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, msg)) } // flagImageConfigs returns the ImageConfigs from --image-configs, or none. diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index 44cf6f3e..56ef07c9 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -680,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{{}}, !tc.args.files) + 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 != "" { From 85ffb477d820b015e24ea0ac2eaadae5455cfc55 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:42:02 -0500 Subject: [PATCH 26/27] test(simulate): drop simulateGap, fix harness comments, compile ANSI regexp once Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../integration_simulate_harness_test.go | 31 ++++++------------- cmd/crossplane/project/integration_test.go | 7 ----- internal/diff/testutils/utils.go | 3 +- internal/simulate/simtest/plan_assertions.go | 2 +- 4 files changed, 12 insertions(+), 31 deletions(-) diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index 127f6a1d..ceca5659 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -18,24 +18,19 @@ limitations under the License. // This file is the simulate harness: the one function every integration // case funnels through. The cases are crossplane-diff's (origin/main -// 6a140b6, plus later upstream additions through d93eb87), written as its comp and xr commands' CLI args plus an expected +// 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. // -// # Gap and environment skips +// # Environment skips // -// Cases the current simulate surface cannot express are ported in full -// and skipped, never dropped, so each names what it waits for: -// -// - gap: — the simulate command lacks the feature this case -// needs. Grep the skip reasons to find what to enable as features -// land. -// - env: — the environment lacks something the case needs. -// The render engine runs functions as Docker containers, so without -// a Docker daemon no rendering case can run; cases that would not -// render cannot be told apart upfront, so they skip too. +// 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. @@ -109,24 +104,16 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA return &a } -// simulateGap returns the reason the parsed args cannot run against the -// current simulate surface, or "" when they can. The runner calls it -// before starting envtest, so a skipped case costs nothing, and -// runSimulate never executes args it could not express. -func simulateGap(_ DiffTestType, _ *simArgs) string { - return "" -} - // 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.ProjectResult. +// 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() - // ponytail: only Composition cases run here; xr simulate adds its type. + // 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) } diff --git a/cmd/crossplane/project/integration_test.go b/cmd/crossplane/project/integration_test.go index e054d122..9da6d1ca 100644 --- a/cmd/crossplane/project/integration_test.go +++ b/cmd/crossplane/project/integration_test.go @@ -195,13 +195,6 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC // Add files as positional arguments args = append(args, testFiles...) - // Skip cases the current simulate surface cannot express, before the cluster starts, so a - // skipped case costs nothing. The gap reasons are greppable; see the harness package comment. - if reason := simulateGap(testType, parseSimulateArgs(t, testType, args)); reason != "" { - t.Skip(reason) - return - } - // The render engine runs functions as Docker containers. Without a Docker daemon no rendering // case can run; cases that would not render anyway cannot be told apart upfront, so they skip. if !dockerAvailable() { diff --git a/internal/diff/testutils/utils.go b/internal/diff/testutils/utils.go index 1ddffa3e..9a5e5a42 100644 --- a/internal/diff/testutils/utils.go +++ b/internal/diff/testutils/utils.go @@ -61,10 +61,11 @@ 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 - ansiPattern := regexp.MustCompile("\x1b\\[[0-9;]*m") expectedStripped := ansiPattern.ReplaceAllString(expected, "") actualStripped := ansiPattern.ReplaceAllString(actual, "") diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 4a67956e..48fe549e 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -642,7 +642,7 @@ func walk(node any, toks fieldpath.Segments) (any, bool) { // 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. -// ponytail: integers beyond 2^53 lose precision in the round trip; compare them differently if one is ever asserted. +// 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) { From 1df6a9ab9cac3265023d682085ec486547ae493d Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 18:17:21 -0500 Subject: [PATCH 27/27] fix(simulate): create the package client only when planning With --skip-plan nothing fetches packages, and --cache-dir is already reported as ignored, so don't create (and fail on) the cache directory. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/run.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/simulate/run.go b/internal/simulate/run.go index fbe9b22b..1e396ab5 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -561,11 +561,13 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg if ics, err = c.flagImageConfigs(); err != nil { return err } - var xc clixpkg.Client - if xc, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { - return errors.Wrap(err, "cannot create xpkg client") + built.Packages = &Packages{ICs: ics} + // Only planning fetches packages, so --skip-plan needs no cache. + if !c.SkipPlan { + if built.Packages.Client, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { + return errors.Wrap(err, "cannot create xpkg client") + } } - built.Packages = &Packages{ICs: ics, Client: xc} } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages