From ef0875e5f7e46d7574f046f2e0afd341bc133632 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:50:12 -0500 Subject: [PATCH 01/23] feat(diff): restore the XR-only diff path Restore PerformDiff with its input validator, ValidatedInput, the WithInputValidatorFactory option, their mocks, test helpers and tests from the state before the XR-only path was removed. Keep the TestManagedByManagerWithNoName case that the managedBy/boundToClaim table port added and the restored tests lack. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/diff/diffprocessor/comp_processor.go | 4 +- .../diff/diffprocessor/comp_processor_test.go | 14 +- internal/diff/diffprocessor/diff_processor.go | 149 +- .../diff/diffprocessor/diff_processor_test.go | 1368 +++++++++++++++-- internal/diff/diffprocessor/errors_test.go | 2 +- internal/diff/diffprocessor/overlap.go | 251 ++- internal/diff/diffprocessor/overlap_test.go | 444 +++++- .../diff/diffprocessor/processor_config.go | 16 +- internal/diff/renderer/diff_renderer.go | 166 +- internal/diff/renderer/diff_renderer_test.go | 123 ++ internal/diff/result/types.go | 34 +- internal/diff/testutils/mock_builder.go | 43 + internal/diff/testutils/mocks.go | 80 + internal/diff/types/types.go | 9 + 14 files changed, 2491 insertions(+), 212 deletions(-) diff --git a/internal/diff/diffprocessor/comp_processor.go b/internal/diff/diffprocessor/comp_processor.go index ec3c50df..53c2970f 100644 --- a/internal/diff/diffprocessor/comp_processor.go +++ b/internal/diff/diffprocessor/comp_processor.go @@ -332,8 +332,8 @@ func (p *DefaultCompDiffProcessor) DiffComposition(ctx context.Context, composit return output, errors.Join(errs...) } -// outputErrorsJoined returns errs as one error that names each one's resource, so each failed resource -// is named. +// outputErrorsJoined returns errs as one error that names each one's resource, the way PerformDiff +// names the resources it failed to process. func outputErrorsJoined(errs []dt.OutputError) error { joined := make([]error, 0, len(errs)) for _, e := range errs { diff --git a/internal/diff/diffprocessor/comp_processor_test.go b/internal/diff/diffprocessor/comp_processor_test.go index b38d23e7..ce35cf26 100644 --- a/internal/diff/diffprocessor/comp_processor_test.go +++ b/internal/diff/diffprocessor/comp_processor_test.go @@ -511,7 +511,11 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { xpClients := tt.setupMocks() // Create mock XR processor - mockXRProc := &tu.MockDiffProcessor{} + mockXRProc := &tu.MockDiffProcessor{ + PerformDiffFn: func(_ context.Context, _ []*un.Unstructured, _ types.CompositionProvider) (*dt.XRReport, error) { + return &dt.XRReport{}, nil + }, + } // Create processor using constructor to ensure all fields are initialized logger := tu.TestLogger(t, false) @@ -1791,9 +1795,11 @@ func TestDefaultCompDiffProcessor_DiffComposition_ReportErrorOutput(t *testing.T } } -// TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput pins that an advisory -// that can only be discovered while releasing resources is still in the report's warnings (warnings[]), -// not just on stderr. comp reaches teardown via xrProc.Cleanup. +// TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput is the comp-side twin +// of TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput: an advisory that can only +// be discovered while releasing resources must still be in the report's warnings (warnings[]), not +// just on stderr. comp reaches +// teardown via xrProc.Cleanup, so it needs its own guard — the xr-side test cannot fail for it. func TestDefaultCompDiffProcessor_DiffComposition_LateWarningsReachStructuredOutput(t *testing.T) { ctx := t.Context() diff --git a/internal/diff/diffprocessor/diff_processor.go b/internal/diff/diffprocessor/diff_processor.go index 8c92f38e..5ff00b9e 100644 --- a/internal/diff/diffprocessor/diff_processor.go +++ b/internal/diff/diffprocessor/diff_processor.go @@ -46,6 +46,11 @@ const ( // DiffProcessor interface for processing resources. type DiffProcessor interface { + // PerformDiff processes resources using a composition provider function, and returns the report + // for the caller to render. The report is non-nil whenever resources were processed, even with an + // error, so the successful inputs' results and the failures' errors can still be rendered. + PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) + // DiffSingleResource processes a single resource and returns its diffs and, by diff key, the // unchanged renderings its diffs leave out (see DefaultDiffCalculator.CalculateNonRemovalDiffs), // which only the render-overlap check consumes. @@ -82,7 +87,7 @@ type DefaultDiffProcessor struct { // NewDiffProcessor creates a new DefaultDiffProcessor with the provided options. func NewDiffProcessor(k8cs k8.Clients, xpcs xp.Clients, opts ...ProcessorOption) DiffProcessor { // Create default configuration - // Note: The processor does no presentation; the caller renders the reports it returns. + // Note: The processor does no presentation; the caller renders the report PerformDiff returns. // Engine limits default here so a caller that doesn't go through the CLI gets the same limits. config := ProcessorConfig{ MaxNestedDepth: DefaultMaxNestedDepth, @@ -210,6 +215,148 @@ func CleanupDetached(ctx context.Context, c Cleaner, logger logging.Logger) { } } +// PerformDiff processes resources using a composition provider function, and returns the report for +// the caller to render. The report is non-nil whenever resources were processed, even with an error. +func (p *DefaultDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) { + p.config.Logger.Debug("Processing resources with composition provider", "count", len(resources)) + + if len(resources) == 0 { + // Nothing was processed, so there is no report to render. + p.config.Logger.Debug("No resources to process") + return nil, nil + } + + // Build one diff group per input XR/claim, in input order, so the renderer + // can group structured output (xrs[]) and human output (per-XR sections) by + // the input that produced each change. See result.XRDiffGroup. + groups := make([]dt.XRDiffGroup, 0, len(resources)) + + // Collect errors for the top-level/global (union) error list, preserved for + // back-compat alongside the per-group errors. + var outputErrors []dt.OutputError + + var errs []error + + // Overlapping inputs are settled in three stages by a per-run InputValidator; see input_validator.go. + // First, before rendering: the validator says what to render, and which inputs it already rejected. + validator := p.config.Factories.InputValidator(p.config.Advisories, resources) + toRender := validator.ToRender() + + // unchanged holds, by group, the unchanged renderings its diffs leave out, for the overlap check. + unchanged := make([]map[string]*dt.ResourceDiff, len(toRender)) + + for i, in := range toRender { + res := in.Resource + + // Attribute the group to the name the XR is rendered under. A synthesized one (generateName + // only) is shown the way the diff formatter shows it, "(generated)", so the + // grouped and flat views agree and no unpredictable hash is published (issue #477). + name, generated := renderName(res) + if generated { + name = dt.GeneratedDisplayName(name, res.GetGenerateName()) + } + + group := dt.XRDiffGroup{ + XR: corev1.ObjectReference{ + APIVersion: res.GetAPIVersion(), + Kind: res.GetKind(), + Name: name, + Namespace: res.GetNamespace(), + }, + NameGenerated: generated, + } + + if in.Err == nil { + xrCtx, cancel := p.config.xrContext(ctx) + diffs, rendered, err := p.diffSingleResourceInternal(xrCtx, res, compositionProvider, nil, nil, true, 0) + err = p.config.afterXR(ctx, xrCtx, diffs, err) + cancel() + validator.RecordRender(i, rendered.Keys(), err) + + if err == nil { + // We don't emit partial results for a single XR: on success the + // whole diff tree is attached, on failure none of it. + group.Diffs = diffs + unchanged[i] = rendered.Unchanged() + } + } + + groups = append(groups, group) + } + + // Second: each group's final error, including any input another input manages. Errors are only + // reported now, because the validator's verdict may supersede a render failure. + for i, err := range validator.Verdicts(groups) { + if err == nil { + continue + } + + groups[i].Diffs = nil + + res := toRender[i].Resource + resourceID := fmt.Sprintf("%s/%s", groups[i].XR.Kind, groups[i].XR.Name) + + // Debug, not Info: this failure is already surfaced in the returned error and as an + // OutputError in the output. Raising it as a warning too would double-report it. + p.config.Logger.Debug("Failed to process resource", + "resource", resourceID, + "namespace", res.GetNamespace(), + "error", err) + errs = append(errs, errors.Wrapf(err, "unable to process resource %s", resourceID)) + + // Collect error for structured output. NewOutputError + // surfaces typed validation failures via + // OutputError.ValidationFailures when err wraps a + // SchemaValidationError that carries a structured Result. + // The same converted error goes to both the top-level union + // (outputErrors) and the per-group entry so consumers of + // either view see it. + outErr := NewOutputError(resourceID, err) + outputErrors = append(outputErrors, outErr) + groups[i].Err = &outErr + } + + // Third: renders that reach one resource in ways no single diff can represent. Collected into the + // report so they reach errors[] and the returned error like any other error; the report is still + // rendered, so structured output stays valid. The check sees every rendering, unchanged ones included, + // so contention is caught whether or not equal diffs are kept (with or without --skip-plan). + for _, err := range validator.RenderOverlaps(withUnchanged(groups, unchanged)) { + errs = append(errs, err) + outputErrors = append(outputErrors, dt.OutputError{Message: err.Error()}) + } + + // Release resources before draining advisories, so one raised during teardown (leftover function + // containers) is still in the report's warnings; the command's deferred Cleanup would run only + // after output has been emitted. + CleanupDetached(ctx, p, p.config.Logger) + + // The renderer includes the report's errors in structured output but does not print them: they + // are returned, below, to the command, which prints them once. Every Errors entry has its + // counterpart in errs, except the interruption's, which the command reports itself. + report := &dt.XRReport{ + Groups: groups, + Errors: withInterruption(ctx, outputErrors), + Warnings: p.config.Advisories.Advisories(), + } + + totalDiffs := 0 + for _, group := range groups { + totalDiffs += len(group.Diffs) + } + + p.config.Logger.Debug("Processing complete", + "resourceCount", len(resources), + "totalDiffs", totalDiffs, + "hasDiffs", report.HasChanges(), + "errorCount", len(errs)) + + if len(errs) > 0 { + return report, errors.Join(errs...) + } + + return report, nil +} + // DiffSingleResource handles one resource at a time and returns its diffs. // The compositionProvider function is called to obtain the composition to use for rendering. // This is the public method for top-level XR diffing, which enables removal detection. diff --git a/internal/diff/diffprocessor/diff_processor_test.go b/internal/diff/diffprocessor/diff_processor_test.go index 66366a10..f046f18e 100644 --- a/internal/diff/diffprocessor/diff_processor_test.go +++ b/internal/diff/diffprocessor/diff_processor_test.go @@ -11,12 +11,15 @@ import ( v1 "github.com/crossplane/function-sdk-go/proto/v1" gcmp "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/sergi/go-diff/diffmatchpatch" corev1 "k8s.io/api/core/v1" extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/dynamic/fake" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -26,6 +29,7 @@ import ( apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/cli/v2/internal/diff/client/core" xp "github.com/crossplane/cli/v2/internal/diff/client/crossplane" k8 "github.com/crossplane/cli/v2/internal/diff/client/kubernetes" "github.com/crossplane/cli/v2/internal/diff/compare" @@ -36,6 +40,17 @@ import ( "github.com/crossplane/cli/v2/pkg/resource" ) +// Test constants to avoid duplication. +const ( + testGroup = "example.org" + testKind = "XR1" + testPlural = "xr1s" + testSingular = "xr1" + testCRDName = testPlural + "." + testGroup + testXRDName = testCRDName + testAPIVersion = "v1" +) + // Ensure MockDiffProcessor implements the DiffProcessor interface. var _ DiffProcessor = &tu.MockDiffProcessor{} @@ -65,146 +80,1178 @@ func TestDefaultDiffProcessor_removeNamespacesFromClusterScopedResources(t *test wantErr bool wantErrMsg string }{ - "BuiltInNamespacedResourceKeepsNamespace": { - reason: "A namespaced built-in has no CRD; discovery reports it namespaced so its namespace is preserved.", - setupResource: func() *tu.MockResourceClient { - return tu.NewMockResourceClient().WithNamespacedResource(secretGVK).Build() - }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), + "BuiltInNamespacedResourceKeepsNamespace": { + reason: "A namespaced built-in has no CRD; discovery reports it namespaced so its namespace is preserved.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().WithNamespacedResource(secretGVK).Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{"default"}, + }, + "BuiltInClusterScopedResourceLosesNamespace": { + reason: "A cluster-scoped built-in has no CRD; discovery reports it cluster-scoped so render's namespace is stripped.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().WithClusterScopedResource(namespaceGVK).Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{""}, + }, + "MixedScopesResolveIndependently": { + reason: "Each resource's scope is resolved on its own; a cluster-scoped sibling does not affect a namespaced one.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient(). + WithNamespacedResource(secretGVK). + WithClusterScopedResource(namespaceGVK). + Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), + *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{"default", ""}, + }, + "FallsBackToCRDWhenDiscoveryFails": { + reason: "When discovery cannot resolve a custom kind, the CRD supplies the scope.", + setupResource: func() *tu.MockResourceClient { + // No scopes configured, so IsNamespacedResource errors. + return tu.NewMockResourceClient().Build() + }, + setupSchema: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithFoundCRD("example.org", "ClusterThing", clusterCRD). + Build() + }, + resources: []cpd.Unstructured{ + *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + wantNamespace: []string{""}, + }, + "ErrorsWhenNeitherDiscoveryNorCRDResolvesScope": { + reason: "Scope must be known to proceed; an unresolvable kind fails the diff rather than guessing.", + setupResource: func() *tu.MockResourceClient { + return tu.NewMockResourceClient().Build() + }, + setupSchema: func() *tu.MockSchemaClient { + return tu.NewMockSchemaClient(). + WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + return nil, errors.New("CRD not found") + }). + Build() + }, + resources: []cpd.Unstructured{ + *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + wantErr: true, + wantErrMsg: "cannot determine scope for resource ClusterThing/thing", + }, + "ResourceWithoutNamespaceIsSkipped": { + reason: "A resource render left unnamespaced needs no scope lookup at all.", + setupResource: func() *tu.MockResourceClient { + // Any scope lookup would error, proving none happened. + return tu.NewMockResourceClient().Build() + }, + setupSchema: noCRDs, + resources: []cpd.Unstructured{ + *tu.NewResource("example.org/v1", "ClusterThing", "thing").BuildUComposed(), + }, + wantNamespace: []string{""}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := &DefaultDiffProcessor{ + resourceClient: tt.setupResource(), + schemaClient: tt.setupSchema(), + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + }, + } + + err := processor.removeNamespacesFromClusterScopedResources(t.Context(), tt.resources) + + if tt.wantErr { + if err == nil { + t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): expected error but got none", tt.reason) + } + + if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): error %q doesn't contain %q", + tt.reason, err.Error(), tt.wantErrMsg) + } + + return + } + + if err != nil { + t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): unexpected error: %v", tt.reason, err) + } + + got := make([]string, len(tt.resources)) + for i := range tt.resources { + got[i] = (&un.Unstructured{Object: tt.resources[i].UnstructuredContent()}).GetNamespace() + } + + if diff := gcmp.Diff(tt.wantNamespace, got); diff != "" { + t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): -want namespaces, +got:\n%s", tt.reason, diff) + } + }) + } +} + +// testProcessorOptions returns sensible default options for tests. +// Tests can append additional options or override these as needed. +// +// The default WithRenderFunc is a no-op that returns an empty CompositionOutputs. +// The production default would spin up a Docker engine, which unit tests cannot rely on. +// Tests that need specific render behavior should override via WithRenderFunc. +func testProcessorOptions(t *testing.T) []ProcessorOption { + t.Helper() + + return []ProcessorOption{ + WithLogger(tu.TestLogger(t, false)), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil + }), + } +} + +func TestDefaultDiffProcessor_PerformDiff(t *testing.T) { + // Setup test context + ctx := t.Context() + + // Create test resources + resource1 := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + resource2 := tu.NewResource("example.org/v1", "XR1", "my-xr-2"). + WithSpecField("coolField", "test-value-2"). + Build() + + // Create a composition for testing + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef("example.org/v1", "XR1"). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + // The XR's XRD and CRD. The effective XR is defaulted before its composition is resolved, so even + // the cases that fail resolving it need them. + testXRD := tu.NewXRD(testXRDName, testGroup, testKind).WithVersion("v1", true, true).BuildAsUnstructured() + testCRD := makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion) + + // Create a composed resource for testing + composedResource := tu.NewResource("cpd.org/v1", "ComposedResource", "resource1"). + WithCompositeOwner("my-xr-1"). + WithCompositionResourceName("resA"). + WithSpecField("param", "value"). + Build() + + // Test cases + tests := map[string]struct { + setupMocks func() (k8.Clients, xp.Clients) + resources []*un.Unstructured + processorOpts []ProcessorOption + wantHasChanges bool + want error + validationError bool + }{ + "NoResources": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{}, + processorOpts: testProcessorOptions(t), + want: nil, + }, + "DiffSingleResourceError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + // The report carries the error for the caller to render (see + // TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput); this verifies the error return value. + want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), + }, + "MultipleResourceErrors": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1, resource2}, + processorOpts: testProcessorOptions(t), + // The report carries the errors for the caller to render (see + // TestDefaultDiffProcessor_PerformDiff_StderrErrorOutput); this verifies the error return value. + want: errors.New("[unable to process resource XR1/my-xr-1: cannot get composition: composition not found, " + + "unable to process resource XR1/my-xr-2: cannot get composition: composition not found]"), + }, + "CompositionNotFound": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + want: errors.New("unable to process resource XR1/my-xr-1: cannot get composition: composition not found"), + }, + "GetFunctionsError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient().WithSuccessfulCRDByNameFetch(testCRDName, testCRD).Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(testXRD).Build(), + Function: tu.NewMockFunctionClient(). + WithFailedFunctionsFetch("function not found"). + Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: testProcessorOptions(t), + want: errors.New("unable to process resource XR1/my-xr-1: cannot get functions for composition: cannot get functions from pipeline: function not found"), + }, + "SuccessfulDiff": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create mock functions that render will call successfully + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-test", + }, + }, + } + + // Create CRDs upfront to avoid recreating them in closures + mainCRD := makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion) + composedCRD := makeTestCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1") + + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(resource1, composedResource). // Add resources to existing resources + WithResourcesFoundByLabel([]*un.Unstructured{composedResource}, "crossplane.io/composite", "test-xr"). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testGroup && gvk.Kind == testKind { + return mainCRD, nil + } + + if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { + return composedCRD, nil + } + + return nil, errors.New("CRD not found") + }). + WithGetCRDByName(func(name string) (*extv1.CustomResourceDefinition, error) { + if name == testCRDName { + return mainCRD, nil + } + + if name == "composedresources.cpd.org" { + return composedCRD, nil + } + + return nil, errors.Errorf("CRD with name %s not found", name) + }). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create XRD for composed resource + composedXRD := tu.NewXRD("composedresources.cpd.org", "cpd.org", "ComposedResource"). + WithPlural("composedresources"). + WithSingular("composedresource"). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "param": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create main XRD + mainXRD := tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + WithVersion("v1", true, true). + WithSchema(&extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "field": {Type: "string"}, + }, + }, + "status": {Type: "object"}, + }, + }). + BuildAsUnstructured() + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForGVK(schema.GroupVersionKind{Group: testGroup, Version: "v1", Kind: testKind}, mainXRD). + WithXRDForGVK(schema.GroupVersionKind{Group: "cpd.org", Version: "v1", Kind: "ComposedResource"}, composedXRD). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + ResourceTree: tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: append(testProcessorOptions(t), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Only return composed resources for the main XR, not for nested XRs + // to avoid infinite recursion + if in.CompositeResource.GetKind() == testKind { + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + { + Unstructured: un.Unstructured{ + Object: composedResource.Object, + }, + }, + }, + }, nil + } + // For nested XRs, just return the XR itself with no composed resources + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + }, nil + }), + // Override the schema validator factory to use a simple validator + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return nil + }, + } + }), + // Override the diff calculator factory to return actual diffs + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter, func(*un.Unstructured) bool) DiffCalculator { + return &tu.MockDiffCalculator{ + CalculateNonRemovalDiffsFn: func(context.Context, *cmp.Unstructured, render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + diffs := make(map[string]*dt.ResourceDiff) + + var rendered types.RenderedResources + + // Add a modified diff (not just equal) + lineDiffs := []diffmatchpatch.Diff{ + {Type: diffmatchpatch.DiffDelete, Text: " field: old-value"}, + {Type: diffmatchpatch.DiffInsert, Text: " field: new-value"}, + } + + diffKey1 := "example.org/v1/XR1/test-xr" + diffs[diffKey1] = &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XR1"}, + ResourceName: "test-xr", + DiffType: dt.DiffTypeModified, + LineDiffs: lineDiffs, // Add line diffs + Current: dt.ResourceViews{Raw: resource1, Clean: resource1}, // for completeness + Desired: dt.ResourceViews{Raw: resource1, Clean: resource1}, // for completeness + } + rendered.AddKey(diffKey1) + + // Add a composed resource diff that's also modified + diffKey2 := "example.org/v1/ComposedResource/resource-a" + diffs[diffKey2] = &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "ComposedResource"}, + ResourceName: "resource-a", + DiffType: dt.DiffTypeModified, + LineDiffs: lineDiffs, + Current: dt.ResourceViews{Raw: composedResource, Clean: composedResource}, + Desired: dt.ResourceViews{Raw: composedResource, Clean: composedResource}, + } + rendered.AddKey(diffKey2) + + return diffs, rendered, nil + }, + } + }), + ), + wantHasChanges: true, + want: nil, + }, + "ValidationError": { + setupMocks: func() (k8.Clients, xp.Clients) { + // Create mock functions that render will call successfully + functions := []pkgv1.Function{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-test", + }, + }, + } + + // Create Kubernetes client mocks + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient(). + WithSuccessfulDryRun(). + Build(), + Resource: tu.NewMockResourceClient(). + WithResourcesExist(resource1). + Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithGetCRD(func(_ context.Context, gvk schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { + if gvk.Group == testGroup && gvk.Kind == testKind { + return makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion), nil + } + + if gvk.Group == "cpd.org" && gvk.Kind == "ComposedResource" { + return makeTestCRD("composedresources.cpd.org", "ComposedResource", "cpd.org", "v1"), nil + } + + return nil, errors.New("CRD not found") + }). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithSuccessfulCompositionMatch(composition). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + BuildAsUnstructured()). + Build(), + Function: tu.NewMockFunctionClient(). + WithSuccessfulFunctionsFetch(functions). + Build(), + // resource1 exists in the cluster, so its observed resources + // are fetched before validation runs. That fetch has to + // succeed for this case to reach the validation failure it is + // actually about. + ResourceTree: tu.NewMockResourceTreeClient(). + WithEmptyResourceTree(). + Build(), + } + + return k8sClients, xpClients + }, + resources: []*un.Unstructured{resource1}, + processorOpts: append(testProcessorOptions(t), + WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { + // Return valid render outputs + return render.CompositionOutputs{ + CompositeResource: in.CompositeResource, + ComposedResources: []cpd.Unstructured{ + { + Unstructured: un.Unstructured{ + Object: composedResource.Object, + }, + }, + }, + }, nil + }), + // Override with a validator that fails + WithSchemaValidatorFactory(func(_ k8.SchemaClient, _ k8.ResourceClient, _ xp.DefinitionClient, _ logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return errors.New("validation error") + }, + } + }), + ), + want: errors.New("unable to process resource XR1/my-xr-1: cannot validate resources: validation error"), + validationError: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + // Create components for testing + k8sClients, xpClients := tt.setupMocks() + + // Create the diff processor + processor := NewDiffProcessor(k8sClients, xpClients, tt.processorOpts...) + + // Create a mock composition provider that uses the same mock composition client + compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + } + report, err := processor.PerformDiff(ctx, tt.resources, compositionProvider) + + // Checked first, before the error checks: the report is returned even with an error. + if got := report != nil && report.HasChanges(); got != tt.wantHasChanges { + t.Errorf("PerformDiff(...): report.HasChanges() = %v, want %v", got, tt.wantHasChanges) + } + + if tt.want != nil { + if err == nil { + t.Errorf("PerformDiff(...): expected error but got none") + return + } + + if diff := gcmp.Diff(tt.want.Error(), err.Error()); diff != "" { + t.Errorf("PerformDiff(...): -want error, +got error:\n%s", diff) + } + + return + } + + if err != nil { + t.Errorf("PerformDiff(...): unexpected error: %v", err) + } + }) + } +} + +// Note: PerformDiff's per-XR grouping structure is verified end-to-end (real +// renderer, real JSON) by TestDiffIntegration/MultipleXRsGroupedByInputXR, and +// the per-group xrs[] shape — including errored groups — by +// TestStructuredDiffRenderer_GroupsByXR. The identity each group carries, and +// how the input validator's verdicts land, are asserted on the intermediate +// []XRDiffGroup handoff by TestDefaultDiffProcessor_PerformDiff_Groups. + +// TestDefaultDiffProcessor_PerformDiff_Groups pins how PerformDiff hands its groups to the renderer. With +// a mock InputValidator it pins the wiring: only what ToRender returns is rendered, and an input it +// rejected is not; every render is recorded; and each Verdicts and RenderOverlaps error lands as a group +// error (replacing that group's diffs), in errors[], and on the returned error. With the real default +// validator it pins what PerformDiff itself decides: the group identity of a generateName-only XR (issue +// #477), and the NameGenerated mark the overlap check relies on. The validation rules themselves are +// tested directly in input_validator_test.go and end to end in diff_integration_test.go; add rule cases +// there, not here. +func TestDefaultDiffProcessor_PerformDiff_Groups(t *testing.T) { + ctx := t.Context() + + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef(testGroup+"/"+testAPIVersion, testKind). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + functions := []pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "function-test"}}} + + xrd := tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + BuildAsUnstructured() + + // sharedKey is returned by the diff calculator for every input XR, so two + // inputs collide on it. + const sharedKey = "example.org/v1/Bucket/default/shared" + + // bucket is the shared resource as one input XR's render produces it, controlled by the XR named + // controller, with spec.value set to value. renderedBy only varies the controller reference's UID, + // the way a render synthesizes a fresh UID for an XR that does not exist yet. Clean mirrors + // cleanupForDiff, which strips ownerReferences (and uid) before anything is compared or displayed. + bucket := func(controller, value, renderedBy string) *dt.ResourceDiff { + desired := tu.NewResource("example.org/v1", "Bucket", "shared"). + InNamespace("default"). + WithSpecField("value", value). + WithControllerReference(testKind, controller, testGroup+"/"+testAPIVersion, "uid-rendered-by-"+renderedBy). + Build() + clean := desired.DeepCopy() + clean.SetOwnerReferences(nil) + + return &dt.ResourceDiff{ + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + Namespace: "default", + ResourceName: "shared", + DiffType: dt.DiffTypeModified, + Desired: dt.ResourceViews{Raw: desired, Clean: clean}, + } + } + + xr := func(name string) *un.Unstructured { + return tu.NewResource(testGroup+"/"+testAPIVersion, testKind, name).WithSpecField("coolField", name).Build() + } + + ref := func(name string) corev1.ObjectReference { + return corev1.ObjectReference{APIVersion: testGroup + "/" + testAPIVersion, Kind: testKind, Name: name} + } + + // recorded is one RecordRender call. + type recorded struct { + I int + Rendered map[string]bool + Err string + } + + // got is the whole handoff, asserted as one value: the group identities in + // input order, each errored group's message by name, the global (union) + // error list the renderer was given, the error PerformDiff returned, and + // (with a mock validator) the renders it recorded. + type got struct { + XRs []corev1.ObjectReference + GroupErrs map[string]string + GlobalErrs []string + Err string + Recorded []recorded + } + + const ( + generatedErr = `cannot combine diffs: inputs XR1/gen-xr-(generated), XR1/gen-xr-(generated) both produce resource ` + + `"example.org/v1/Bucket/default/shared" only because simulate gives XRs that share a generateName the ` + + `same placeholder name; the API server would name them differently, so they cannot be told apart here — diff ` + + `them separately` + + contendedErr = `cannot combine diffs: resource "example.org/v1/Bucket/default/shared" would be controlled by ` + + `more than one XR (XR1/xr-a, XR1/xr-b); Crossplane gives it to whichever of them creates it first, and ` + + `the other fails to reconcile it, so no diff predicts applying these inputs together — diff them separately` + + // renderFailedErr is how a render failure reaches RecordRender: wrapped by the render path. + renderFailedErr = "cannot calculate diffs for composed resources: render failed" + ) + + genXR := tu.NewResource(testGroup+"/"+testAPIVersion, testKind, ""). + WithGenerateName("gen-xr-"). + WithSpecField("coolField", "value"). + Build() + + // validation is what a mock validator returns: ToRender's list, and Verdicts and RenderOverlaps + // errors. A nil validation means the real default validator. + type validation struct { + toRender []types.ValidatedInput + verdicts []error + overlaps []error + } + + tests := map[string]struct { + resources []*un.Unstructured + validator *validation + // failRender names an input XR whose render fails. + failRender string + // slowRender names an input XR whose render runs until its context ends. + slowRender string + xrTimeout time.Duration + afterXR func(context.Context, map[string]*dt.ResourceDiff) error + // equalRender names an input XR whose render leaves the shared resource unchanged: an equal diff, + // controlled by that XR, kept among the diffs when keepEqual is set (as provider plans do) and + // otherwise recorded only as an unchanged rendering (as with --skip-plan). + equalRender string + keepEqual bool + want got + }{ + // An XR that runs out of its own XRTimeout fails alone, as a failed render does; the next + // one still renders. + "XRTimeoutFailsOnlyThatXR": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + slowRender: "my-xr-1", + xrTimeout: 50 * time.Millisecond, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + GroupErrs: map[string]string{"my-xr-1": "timed out after 50ms: cannot calculate diffs for composed resources: context canceled"}, + GlobalErrs: []string{"timed out after 50ms: cannot calculate diffs for composed resources: context canceled"}, + Err: "unable to process resource XR1/my-xr-1: timed out after 50ms: cannot calculate diffs for composed resources: context canceled", }, - wantNamespace: []string{"default"}, }, - "BuiltInClusterScopedResourceLosesNamespace": { - reason: "A cluster-scoped built-in has no CRD; discovery reports it cluster-scoped so render's namespace is stripped.", - setupResource: func() *tu.MockResourceClient { - return tu.NewMockResourceClient().WithClusterScopedResource(namespaceGVK).Build() + // An AfterXRDiff error fails its XR with none of its diffs, as a failed render does. + "AfterXRDiffErrorFailsTheXR": { + resources: []*un.Unstructured{xr("my-xr-1")}, + afterXR: func(_ context.Context, diffs map[string]*dt.ResourceDiff) error { + if diffs[sharedKey] == nil { + return errors.New("hook got no diffs") + } + return errors.New("plan failed") }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1")}, + GroupErrs: map[string]string{"my-xr-1": "plan failed"}, + GlobalErrs: []string{"plan failed"}, + Err: "unable to process resource XR1/my-xr-1: plan failed", }, - wantNamespace: []string{""}, }, - "MixedScopesResolveIndependently": { - reason: "Each resource's scope is resolved on its own; a cluster-scoped sibling does not affect a namespaced one.", - setupResource: func() *tu.MockResourceClient { - return tu.NewMockResourceClient(). - WithNamespacedResource(secretGVK). - WithClusterScopedResource(namespaceGVK). - Build() + // An input ToRender already rejected is not rendered; its verdict is a group error, in errors[], + // and on the returned error. + "InputRejectedBeforeRenderingIsNotRendered": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + validator: &validation{ + toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}, {Resource: xr("my-xr-2"), Err: errors.New("rejected")}}, + verdicts: []error{nil, errors.New("rejected")}, }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("v1", "Secret", "creds").InNamespace("default").BuildUComposed(), - *tu.NewResource("v1", "Namespace", "generated").InNamespace("default").BuildUComposed(), + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + GroupErrs: map[string]string{"my-xr-2": "rejected"}, + GlobalErrs: []string{"rejected"}, + Err: "unable to process resource XR1/my-xr-2: rejected", + Recorded: []recorded{{I: 0, Rendered: map[string]bool{sharedKey: true}}}, }, - wantNamespace: []string{"default", ""}, }, - "FallsBackToCRDWhenDiscoveryFails": { - reason: "When discovery cannot resolve a custom kind, the CRD supplies the scope.", - setupResource: func() *tu.MockResourceClient { - // No scopes configured, so IsNamespacedResource errors. - return tu.NewMockResourceClient().Build() + // A Verdicts error on a group that rendered fine replaces its diffs. + "VerdictReplacesAGroupsDiffs": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + validator: &validation{ + toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}, {Resource: xr("my-xr-2")}}, + verdicts: []error{nil, errors.New("verdict")}, }, - setupSchema: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient(). - WithFoundCRD("example.org", "ClusterThing", clusterCRD). - Build() + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + GroupErrs: map[string]string{"my-xr-2": "verdict"}, + GlobalErrs: []string{"verdict"}, + Err: "unable to process resource XR1/my-xr-2: verdict", + Recorded: []recorded{ + {I: 0, Rendered: map[string]bool{sharedKey: true}}, + {I: 1, Rendered: map[string]bool{sharedKey: true}}, + }, }, - resources: []cpd.Unstructured{ - *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + "RenderOverlapsReachErrorsAndTheReturnedError": { + resources: []*un.Unstructured{xr("my-xr-1")}, + validator: &validation{ + toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}}, + overlaps: []error{errors.New("overlap")}, + }, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1")}, + GlobalErrs: []string{"overlap"}, + Err: "overlap", + Recorded: []recorded{{I: 0, Rendered: map[string]bool{sharedKey: true}}}, }, - wantNamespace: []string{""}, }, - "ErrorsWhenNeitherDiscoveryNorCRDResolvesScope": { - reason: "Scope must be known to proceed; an unresolvable kind fails the diff rather than guessing.", - setupResource: func() *tu.MockResourceClient { - return tu.NewMockResourceClient().Build() + // Only what ToRender returns is rendered: a duplicate it dropped yields no group. + "OnlyWhatToRenderReturnsIsRendered": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-1")}, + validator: &validation{toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}}}, + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1")}, + Recorded: []recorded{{I: 0, Rendered: map[string]bool{sharedKey: true}}}, }, - setupSchema: func() *tu.MockSchemaClient { - return tu.NewMockSchemaClient(). - WithGetCRD(func(_ context.Context, _ schema.GroupVersionKind) (*extv1.CustomResourceDefinition, error) { - return nil, errors.New("CRD not found") - }). - Build() + }, + // RecordRender sees each render's keys and its error; the error is reported only if Verdicts + // returns it, which this mock does not. + "RecordRenderReceivesEachRender": { + resources: []*un.Unstructured{xr("my-xr-1"), xr("my-xr-2")}, + validator: &validation{toRender: []types.ValidatedInput{{Resource: xr("my-xr-1")}, {Resource: xr("my-xr-2")}}}, + failRender: "my-xr-2", + want: got{ + XRs: []corev1.ObjectReference{ref("my-xr-1"), ref("my-xr-2")}, + Recorded: []recorded{ + {I: 0, Rendered: map[string]bool{sharedKey: true}}, + {I: 1, Err: renderFailedErr}, + }, }, - resources: []cpd.Unstructured{ - *tu.NewResource("example.org/v1", "ClusterThing", "thing").InNamespace("default").BuildUComposed(), + }, + // Issue #477: the name used for rendering is synthesized from + // generateName inside SanitizeXR, so the identity must carry that + // effective name rather than the input's empty metadata.name. + "GenerateNameOnlyXR": { + resources: []*un.Unstructured{genXR}, + want: got{XRs: []corev1.ObjectReference{ref("gen-xr-(generated)")}}, + }, + // PerformDiff marks a group whose name it synthesized, which is the only way the overlap check + // can tell two generateName-only inputs apart from one XR reached twice. Without it these + // identical renderings would merge, reporting one XR's changes for two. + // Two XRs controlling one resource contend for it even when one of them leaves it unchanged, so + // the run fails whether that equal diff is kept for a plan or left out of the report. + "UnchangedRenderingContendsWithPlans": { + resources: []*un.Unstructured{xr("xr-a"), xr("xr-b")}, + equalRender: "xr-a", + keepEqual: true, + want: got{ + XRs: []corev1.ObjectReference{ref("xr-a"), ref("xr-b")}, + GlobalErrs: []string{contendedErr}, + Err: contendedErr, }, - wantErr: true, - wantErrMsg: "cannot determine scope for resource ClusterThing/thing", }, - "ResourceWithoutNamespaceIsSkipped": { - reason: "A resource render left unnamespaced needs no scope lookup at all.", - setupResource: func() *tu.MockResourceClient { - // Any scope lookup would error, proving none happened. - return tu.NewMockResourceClient().Build() + "UnchangedRenderingContendsWithSkipPlan": { + resources: []*un.Unstructured{xr("xr-a"), xr("xr-b")}, + equalRender: "xr-a", + want: got{ + XRs: []corev1.ObjectReference{ref("xr-a"), ref("xr-b")}, + GlobalErrs: []string{contendedErr}, + Err: contendedErr, }, - setupSchema: noCRDs, - resources: []cpd.Unstructured{ - *tu.NewResource("example.org/v1", "ClusterThing", "thing").BuildUComposed(), + }, + "SharedGenerateNameReachesTheOverlapCheck": { + resources: []*un.Unstructured{genXR, genXR.DeepCopy()}, + want: got{ + XRs: []corev1.ObjectReference{ref("gen-xr-(generated)"), ref("gen-xr-(generated)")}, + GlobalErrs: []string{generatedErr}, + Err: generatedErr, }, - wantNamespace: []string{""}, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { - processor := &DefaultDiffProcessor{ - resourceClient: tt.setupResource(), - schemaClient: tt.setupSchema(), - config: ProcessorConfig{ - Logger: tu.TestLogger(t, false), - }, + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().WithSuccessfulDryRun().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithNoResourcesRequiringCRDs(). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().WithSuccessfulCompositionMatch(composition).Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(), + Function: tu.NewMockFunctionClient().WithSuccessfulFunctionsFetch(functions).Build(), + ResourceTree: tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), } - err := processor.removeNamespacesFromClusterScopedResources(t.Context(), tt.resources) + var gotRecorded []recorded - if tt.wantErr { - if err == nil { - t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): expected error but got none", tt.reason) + opts := append(testProcessorOptions(t), + WithSchemaValidatorFactory(func(k8.SchemaClient, k8.ResourceClient, xp.DefinitionClient, logging.Logger) SchemaValidator { + return &tu.MockSchemaValidator{ + ValidateResourcesFn: func(context.Context, *un.Unstructured, []cpd.Unstructured) error { + return nil + }, + } + }), + WithDiffCalculatorFactory(func(k8.ApplyClient, k8.AccessChecker, xp.ResourceTreeClient, ResourceManager, logging.Logger, compare.Options, DryRunOn, Defaulter, func(*un.Unstructured) bool) DiffCalculator { + return &tu.MockDiffCalculator{ + CalculateNonRemovalDiffsFn: func(ctx context.Context, rendered *cmp.Unstructured, _ render.CompositionOutputs) (map[string]*dt.ResourceDiff, types.RenderedResources, error) { + if rendered.GetName() == tt.failRender { + return nil, types.RenderedResources{}, errors.New("render failed") + } + if rendered.GetName() == tt.slowRender { + <-ctx.Done() + return nil, types.RenderedResources{}, ctx.Err() + } + + var keys types.RenderedResources + keys.AddKey(sharedKey) + + if rendered.GetName() == tt.equalRender { + diff := bucket(rendered.GetName(), "same", rendered.GetName()) + diff.DiffType = dt.DiffTypeEqual + + if tt.keepEqual { + return map[string]*dt.ResourceDiff{sharedKey: diff}, keys, nil + } + + keys.AddUnchanged(sharedKey, diff) + + return map[string]*dt.ResourceDiff{}, keys, nil + } + + controller := "parent" + if tt.equalRender != "" { + controller = rendered.GetName() + } + + return map[string]*dt.ResourceDiff{sharedKey: bucket(controller, "same", rendered.GetName())}, keys, nil + }, + } + }), + ) + + opts = append(opts, WithXRTimeout(tt.xrTimeout), WithAfterXRDiff(tt.afterXR)) + + if v := tt.validator; v != nil { + opts = append(opts, WithInputValidatorFactory(func(dt.Advisories, []*un.Unstructured) InputValidator { + return &tu.MockInputValidator{ + ToRenderFn: func() []types.ValidatedInput { return v.toRender }, + RecordRenderFn: func(i int, rendered map[string]bool, err error) { + gotRecorded = append(gotRecorded, recorded{I: i, Rendered: rendered, Err: errMessage(err)}) + }, + VerdictsFn: func(groups []dt.XRDiffGroup) []error { + if v.verdicts == nil { + return make([]error, len(groups)) + } + + return v.verdicts + }, + RenderOverlapsFn: func([]dt.XRDiffGroup) []error { return v.overlaps }, + } + })) + } + + processor := NewDiffProcessor(k8sClients, xpClients, opts...) + + report, err := processor.PerformDiff(ctx, tt.resources, func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + }) + + result := got{Recorded: gotRecorded} + + for _, g := range report.Groups { + result.XRs = append(result.XRs, g.XR) + + if g.Err == nil { + continue } - if tt.wantErrMsg != "" && !strings.Contains(err.Error(), tt.wantErrMsg) { - t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): error %q doesn't contain %q", - tt.reason, err.Error(), tt.wantErrMsg) + if g.Diffs != nil { + t.Errorf("group %s/%s carries an error and diffs; an errored input must emit no partial result", g.XR.Kind, g.XR.Name) } - return + if result.GroupErrs == nil { + result.GroupErrs = map[string]string{} + } + + result.GroupErrs[g.XR.Name] = g.Err.Message } - if err != nil { - t.Fatalf("\n%s\nremoveNamespacesFromClusterScopedResources(): unexpected error: %v", tt.reason, err) + for _, e := range report.Errors { + result.GlobalErrs = append(result.GlobalErrs, e.Message) } - got := make([]string, len(tt.resources)) - for i := range tt.resources { - got[i] = (&un.Unstructured{Object: tt.resources[i].UnstructuredContent()}).GetNamespace() + if err != nil { + result.Err = err.Error() } - if diff := gcmp.Diff(tt.wantNamespace, got); diff != "" { - t.Errorf("\n%s\nremoveNamespacesFromClusterScopedResources(): -want namespaces, +got:\n%s", tt.reason, diff) + if diff := gcmp.Diff(tt.want, result); diff != "" { + t.Errorf("PerformDiff(...) group handoff: -want, +got:\n%s", diff) } }) } } -// testProcessorOptions returns sensible default options for tests. -// Tests can append additional options or override these as needed. -// -// The default WithRenderFunc is a no-op that returns an empty CompositionOutputs. -// The production default would spin up a Docker engine, which unit tests cannot rely on. -// Tests that need specific render behavior should override via WithRenderFunc. -func testProcessorOptions(t *testing.T) []ProcessorOption { - t.Helper() +// TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput pins the warning channel's +// contract for advisories that can only be discovered during teardown. The leftover-function-container +// advisory is raised from FunctionProvider.Cleanup, which used to run in the command layer's defer — +// i.e. after the renderer had already consumed and serialized the warning slice — so it reached stderr +// but was structurally absent from warnings[], contradicting the README. PerformDiff therefore tears +// down before it drains advisories into the report the command renders, and this test fails if that +// ordering is lost. +func TestDefaultDiffProcessor_PerformDiff_LateWarningsReachStructuredOutput(t *testing.T) { + ctx := t.Context() - return []ProcessorOption{ - WithLogger(tu.TestLogger(t, false)), - WithRenderFunc(func(_ context.Context, _ logging.Logger, in RenderInputs) (render.CompositionOutputs, error) { - return render.CompositionOutputs{CompositeResource: in.CompositeResource}, nil - }), + const advisory = "Some containers could not be cleaned up" + + var stderr bytes.Buffer + + advisories := NewAdvisoryCollector(&stderr) + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().Build(), + Schema: tu.NewMockSchemaClient().Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // The XR's own diff fails (no matching composition), which additionally pins the README's claim + // that a warning is still reported when a later step fails — the advisory has to survive alongside + // errors[], not instead of it. + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().WithNoMatchingComposition().Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient().Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, + append(testProcessorOptions(t), + WithAdvisories(advisories), + // Stands in for CachedFunctionProvider.Cleanup raising its advisory when a container could + // not be removed; the point under test is WHEN cleanup runs, not how it detects leftovers. + WithFunctionProviderFactory(func(xp.FunctionClient, logging.Logger) FunctionProvider { + return &tu.MockFunctionProvider{ + CleanupFn: func(context.Context) error { + advisories.Raise(dt.OutputWarning{Message: advisory, Context: map[string]string{"errors": "1"}}) + return nil + }, + } + }), + )..., + ) + + resource := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + report, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, xpClients.Composition.FindMatchingComposition) + if err == nil { + t.Fatal("PerformDiff(): expected the XR's diff to fail, got nil") + } + + if !strings.Contains(stderr.String(), advisory) { + t.Errorf("teardown advisory should reach stderr, got:\n%s", stderr.String()) + } + + // The report's warnings are what the structured renderer serializes as warnings[]. + want := []dt.OutputWarning{{Message: advisory, Context: map[string]string{"errors": "1"}}} + if diff := gcmp.Diff(want, report.Warnings); diff != "" { + t.Errorf("report warnings mismatch (-want +got):\n%s", diff) + } + + if len(report.Errors) != 1 { + t.Errorf("expected the XR's failure to still be reported in the report's errors, got %v", report.Errors) + } +} + +// TestDefaultDiffProcessor_PerformDiff_ReportErrorOutput verifies that when resource processing +// fails, the report carries the detailed error, attributed to the failed XR, for structured output, +// and the returned error names the resource and carries the detail: the command prints it once. +func TestDefaultDiffProcessor_PerformDiff_ReportErrorOutput(t *testing.T) { + ctx := t.Context() + + // Create test resource + resource := tu.NewResource("example.org/v1", "XR1", "my-xr-1"). + WithSpecField("coolField", "test-value-1"). + Build() + + // Create Kubernetes client mocks. The XR's CRD is needed because the effective XR is defaulted + // before its composition is resolved. + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().Build(), + Resource: tu.NewMockResourceClient().WithResourceNotFound().Build(), + Schema: tu.NewMockSchemaClient(). + WithSuccessfulCRDByNameFetch(testCRDName, makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)). + Build(), + Type: tu.NewMockTypeConverter().Build(), + } + + // Create Crossplane client mocks with a failing composition match + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient(). + WithNoMatchingComposition(). + Build(), + Credential: &tu.MockCredentialClient{}, + Definition: tu.NewMockDefinitionClient(). + WithXRDForXR(tu.NewXRD(testXRDName, testGroup, testKind).WithVersion("v1", true, true).BuildAsUnstructured()). + Build(), + Function: tu.NewMockFunctionClient().Build(), + ResourceTree: tu.NewMockResourceTreeClient().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, testProcessorOptions(t)...) + + // Create composition provider using mock client + compositionProvider := func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + } + + // Run the diff + report, err := processor.PerformDiff(ctx, []*un.Unstructured{resource}, compositionProvider) + + // Should return an error + if err == nil { + t.Fatal("PerformDiff() expected error but got none") + } + + for _, want := range []string{"unable to process resource XR1/my-xr-1", "composition not found"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("Expected the returned error to contain %q, got: %v", want, err) + } + } + + if len(report.Errors) != 1 { + t.Fatalf("Expected one error in the report, got: %v", report.Errors) + } + + if got := report.Errors[0]; got.ResourceID != "XR1/my-xr-1" || !strings.Contains(got.Message, "composition not found") { + t.Errorf("Expected the report's error to name XR1/my-xr-1 and 'composition not found', got: %+v", got) } } @@ -279,6 +1326,120 @@ func TestDefaultDiffProcessor_warnIfDeleting(t *testing.T) { } } +// TestDiffFetchesOnlyTheInputsCRD runs a diff through the real schema and definition clients, with +// validation and defaulting as the processor builds them, against a cluster that forbids listing XRDs +// and holds an XRD unrelated to the input whose CRD is missing. The run reads only what the input +// needs, so neither matters; the input's own CRD going missing still fails it. +func TestDiffFetchesOnlyTheInputsCRD(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: testGroup, Version: testAPIVersion, Kind: testKind} + xrdGVK := schema.GroupVersionKind{Group: xp.CrossplaneAPIExtGroup, Version: "v1", Kind: xp.CompositeResourceDefinitionKind} + + inputXRD := tu.NewXRD(testXRDName, testGroup, testKind). + WithPlural(testPlural). + WithSingular(testSingular). + WithVersion(testAPIVersion, true, true). + BuildAsUnstructured() + unrelatedXRD := tu.NewXRD("xothers."+testGroup, testGroup, "XOther"). + WithPlural("xothers"). + WithSingular("xother"). + WithVersion(testAPIVersion, true, true). + BuildAsUnstructured() + + xr := tu.NewResource(testGroup+"/"+testAPIVersion, testKind, "my-xr").WithSpecField("coolField", "value").Build() + composition := tu.NewComposition("test-comp"). + WithCompositeTypeRef(testGroup+"/"+testAPIVersion, testKind). + WithPipelineMode(). + WithPipelineStep("step1", "function-test", nil). + Build() + + tests := map[string]struct { + reason string + // crds are the CRDs in the cluster. + crds []*extv1.CustomResourceDefinition + wantHasChanges bool + wantErr string + }{ + "UnrelatedXRDWithoutCRD": { + reason: "An XRD unrelated to the input, and its missing CRD, must not fail the run", + crds: []*extv1.CustomResourceDefinition{makeTestCRD(testCRDName, testKind, testGroup, testAPIVersion)}, + wantHasChanges: true, + }, + "InputXRDWithoutCRD": { + reason: "The input's own CRD missing still fails the run", + wantErr: `unable to process resource XR1/my-xr: cannot apply XRD defaults: cannot default example.org/v1, Kind=XR1 "my-xr": ` + + `cannot find CRD for XRD xr1s.example.org: cannot get CRD xr1s.example.org: ` + + `customresourcedefinitions.apiextensions.k8s.io "xr1s.example.org" not found`, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + ctx := t.Context() + logger := tu.TestLogger(t, false) + + crdObjs := make([]runtime.Object, 0, len(tt.crds)) + + for _, crd := range tt.crds { + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(crd) + if err != nil { + t.Fatalf("cannot convert CRD: %v", err) + } + + crdObjs = append(crdObjs, &un.Unstructured{Object: obj}) + } + + typeConverter := tu.NewMockTypeConverter().WithDefaultGetResourceNameForGVK().Build() + resourceClient := tu.NewMockResourceClient(). + WithFoundGVKs([]schema.GroupVersionKind{xrdGVK}). + WithResourcesExist(inputXRD, unrelatedXRD). + WithErrorOnListOf(xrdGVK). + WithClusterScopedResource(xrGVK). + Build() + + defClient := xp.NewDefinitionClient(resourceClient, typeConverter, logger) + if err := defClient.Initialize(ctx); err != nil { + t.Fatalf("cannot initialize definition client: %v", err) + } + + k8sClients := k8.Clients{ + Apply: tu.NewMockApplyClient().WithSuccessfulDryRun().WithSuccessfulDryRunCreate().Build(), + Resource: resourceClient, + Schema: k8.NewSchemaClient(&core.Clients{Dynamic: fake.NewSimpleDynamicClient(runtime.NewScheme(), crdObjs...)}, typeConverter, logger), + Type: typeConverter, + } + xpClients := xp.Clients{ + Composition: tu.NewMockCompositionClient().WithSuccessfulCompositionMatch(composition).Build(), + Credential: &tu.MockCredentialClient{}, + Definition: defClient, + Function: tu.NewMockFunctionClient().WithSuccessfulFunctionsFetch([]pkgv1.Function{{ObjectMeta: metav1.ObjectMeta{Name: "function-test"}}}).Build(), + ResourceTree: tu.NewMockResourceTreeClient().WithEmptyResourceTree().Build(), + } + + processor := NewDiffProcessor(k8sClients, xpClients, testProcessorOptions(t)...) + if err := processor.Initialize(ctx); err != nil { + t.Fatalf("\n%s\nInitialize(...): unexpected error: %v", tt.reason, err) + } + + report, err := processor.PerformDiff(ctx, []*un.Unstructured{xr}, func(ctx context.Context, res *un.Unstructured) (types.ResolvedComposition, error) { + return xpClients.Composition.FindMatchingComposition(ctx, res) + }) + + gotErr := "" + if err != nil { + gotErr = err.Error() + } + + if diff := gcmp.Diff(tt.wantErr, gotErr); diff != "" { + t.Errorf("\n%s\nPerformDiff(...): -want error, +got error:\n%s", tt.reason, diff) + } + + if got := report != nil && report.HasChanges(); got != tt.wantHasChanges { + t.Errorf("\n%s\nPerformDiff(...): report.HasChanges() = %v, want %v", tt.reason, got, tt.wantHasChanges) + } + }) + } +} + func TestDefaultDiffProcessor_Initialize(t *testing.T) { // Setup test context ctx := t.Context() @@ -1218,6 +2379,17 @@ func hasReadyCondition(res *un.Unstructured) bool { return false } +// Helper function to create a test CRD for the given GVK. +func makeTestCRD(name string, kind string, group string, version string) *extv1.CustomResourceDefinition { + return tu.NewCRD(name, group, kind). + WithListKind(kind+"List"). + WithPlural(strings.ToLower(kind)+"s"). + WithSingular(strings.ToLower(kind)). + WithVersion(version, true, true). + WithStandardSchema("coolField"). + Build() +} + func TestDefaultDiffProcessor_getCompositeResourceXRD(t *testing.T) { ctx := t.Context() diff --git a/internal/diff/diffprocessor/errors_test.go b/internal/diff/diffprocessor/errors_test.go index e86e22fb..9ec3fda0 100644 --- a/internal/diff/diffprocessor/errors_test.go +++ b/internal/diff/diffprocessor/errors_test.go @@ -608,7 +608,7 @@ func TestIsOnlySchemaValidationErrors(t *testing.T) { want: false, }, "CrossplaneRuntimeJoinedSchemaValidationErrors": { - // This simulates the pattern where per-resource errors are joined + // This simulates the actual pattern in PerformDiff where errors are joined err: xperrors.Join( xperrors.Wrapf( xperrors.Wrap(&SchemaValidationError{Message: "validation"}, "cannot validate resources"), diff --git a/internal/diff/diffprocessor/overlap.go b/internal/diff/diffprocessor/overlap.go index a5394ac8..86d88af7 100644 --- a/internal/diff/diffprocessor/overlap.go +++ b/internal/diff/diffprocessor/overlap.go @@ -4,6 +4,7 @@ import ( "fmt" "maps" "sort" + "strconv" "strings" "k8s.io/apimachinery/pkg/api/equality" @@ -14,11 +15,224 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/errors" dt "github.com/crossplane/cli/v2/internal/diff/result" + "github.com/crossplane/cli/v2/internal/diff/types" ) -// This file holds the overlap checks among a run's inputs and renders: identity of an object -// independent of API version, which input manages another, and two renders reaching one resource. -// An input with no name yet (generateName only) has no identity. +// This file validates the xr command's input set. PerformDiff drives an InputValidator through three +// stages; the default, bundleInputValidator, treats the inputs as one change set, so any overlap among +// them is settled here: +// +// 1. checkInputs, before rendering (ToRender): a duplicate input, and the same object given twice differently. +// 2. rejectManagedInputs, after rendering (Verdicts): an input that another input manages. +// 3. renderOverlapErrors (RenderOverlaps): two inputs' renders reaching one resource. +// +// Identity throughout is group, kind, namespace and name, independent of API version, since one object +// served at two versions is still one object. An input with no name yet (generateName only) has none. + +// InputValidator validates one PerformDiff run's input set. It is created per run by an +// InputValidatorFactory, because it carries that run's state between stages. +type InputValidator interface { + // ToRender returns the inputs to render, in input order, with duplicates already dropped. + // An input with a non-nil Err was rejected before rendering and must not be rendered. + ToRender() []types.ValidatedInput + // RecordRender records the render of ToRender()[i]: every resource key it produced + // (including unchanged ones) and its error, if any. + RecordRender(i int, rendered map[string]bool, err error) + // Verdicts returns the final error for each group (indexed like ToRender()), or nil. + // It applies stage 2 and the precedence rule: a managed-input verdict replaces that + // group's own render failure. + Verdicts(groups []dt.XRDiffGroup) []error + // RenderOverlaps returns the stage-3 errors. + RenderOverlaps(groups []dt.XRDiffGroup) []error +} + +// InputValidatorFactory creates the InputValidator for one run. +type InputValidatorFactory func(advisories dt.Advisories, inputs []*un.Unstructured) InputValidator + +// bundleInputValidator is the default InputValidator. It is a "bundle" validator because it treats the +// inputs as one change set, to be applied together: any overlap among them — a duplicate, the same object +// twice differently, an input another input manages, two renders reaching one resource — is settled +// against the set as a whole, not per input. +type bundleInputValidator struct { + // checks are checkInputs' verdicts on the inputs to render (duplicates dropped), indexed like toRender. + checks []inputCheck + toRender []types.ValidatedInput + // rendered and failed are, by index, each render's resource keys and each input's own failure + // (its pre-render rejection, or else its render error). + rendered []map[string]bool + failed []error +} + +// NewBundleInputValidator returns the default InputValidator for one run over inputs. It has the +// InputValidatorFactory signature. Stage 1 (checkInputs) runs here, so duplicates are reported once. +func NewBundleInputValidator(advisories dt.Advisories, inputs []*un.Unstructured) InputValidator { + v := &bundleInputValidator{} + + for _, in := range checkInputs(advisories, inputs) { + if in.duplicate { + continue + } + + v.checks = append(v.checks, in) + v.toRender = append(v.toRender, types.ValidatedInput{Resource: in.res, Err: in.err}) + v.failed = append(v.failed, in.err) + } + + v.rendered = make([]map[string]bool, len(v.checks)) + + return v +} + +// ToRender returns the non-duplicate inputs in input order; see InputValidator. +func (v *bundleInputValidator) ToRender() []types.ValidatedInput { + return v.toRender +} + +// RecordRender records the render of ToRender()[i]; see InputValidator. +func (v *bundleInputValidator) RecordRender(i int, rendered map[string]bool, err error) { + v.rendered[i] = rendered + v.failed[i] = err +} + +// Verdicts returns each group's final error. A managed-input verdict replaces the group's own failure: +// that failure is moot, and often a symptom of the same mistake. See rejectManagedInputs. +func (v *bundleInputValidator) Verdicts(groups []dt.XRDiffGroup) []error { + verdicts := make([]error, len(v.failed)) + copy(verdicts, v.failed) + + for i, err := range rejectManagedInputs(groups, v.checks, v.rendered, v.failed) { + verdicts[i] = err + } + + return verdicts +} + +// RenderOverlaps returns the stage-3 errors; see renderOverlapErrors. +func (v *bundleInputValidator) RenderOverlaps(groups []dt.XRDiffGroup) []error { + return renderOverlapErrors(groups) +} + +// inputCheck is checkInputs' verdict on one input. +type inputCheck struct { + res *un.Unstructured + // duplicate is true for an input identical to an earlier one; it is not rendered. + duplicate bool + // err rejects the input before it is rendered. + err error +} + +// checkInputs judges the input set before anything is rendered. An input identical to an earlier one +// has one clear intent — a fat-fingered command line, or CI enumerating the same file twice — so it is +// dropped with a warning. The same object twice with different content fails both inputs: applying both +// would leave whichever is applied last, and the order inputs arrive in (often a glob's) is not intent. +func checkInputs(advisories dt.Advisories, resources []*un.Unstructured) []inputCheck { + checks := make([]inputCheck, len(resources)) + first := make(map[string]int, len(resources)) + + for i, res := range resources { + checks[i] = inputCheck{res: res} + + id := identityOf(res) + if id == "" { + continue + } + + j, seen := first[id] + if !seen { + first[id] = i + continue + } + + if equality.Semantic.DeepEqual(resources[j].Object, res.Object) { + checks[i].duplicate = true + advisories.Raise(dt.OutputWarning{ + Message: "Ignoring a duplicate input: it is identical to an earlier one", + Context: map[string]string{ + "resource": fmt.Sprintf("%s/%s", res.GetKind(), res.GetName()), + "input": strconv.Itoa(i + 1), + "duplicateOf": strconv.Itoa(j + 1), + }, + }) + + continue + } + + checks[i].err = conflictingInputError(i+1, j+1, res) + if checks[j].err == nil { + checks[j].err = conflictingInputError(j+1, i+1, res) + } + } + + return checks +} + +func conflictingInputError(position, other int, res *un.Unstructured) error { + return errors.Errorf("input %d defines %s/%s differently from input %d; applying both would leave whichever "+ + "is applied last, and the order inputs are given in is not a statement of intent — pass only one of them", + position, res.GetKind(), res.GetName(), other) +} + +// rejectManagedInputs returns, by group index, an error for each input that another input manages: an +// XR a parent input composes, and an XR a claim input is bound to. Crossplane writes such an object on +// the manager's behalf — the parent's composition, or the claim controller, applies it with forced +// ownership — so supplying it as an input too gives it a second writer. The managed input is the +// mistake, so it is the one rejected. +// +// inputs, rendered and failed are indexed like groups: each group's input, every resource key its +// render produced (including unchanged ones, which its diffs omit), and its own failure. A managed input +// is judged even if its own render failed, because the verdict supersedes that failure: it is moot, and +// often a symptom of the same mistake (an XR's cluster copy is rarely a valid standalone input). A +// manager must have rendered, since what it manages is read from its render. +func rejectManagedInputs(groups []dt.XRDiffGroup, inputs []inputCheck, rendered []map[string]bool, failed []error) map[int]error { + renderedIDs := make([]map[string]bool, len(groups)) + + for i := range groups { + renderedIDs[i] = make(map[string]bool, len(rendered[i])) + for key := range rendered[i] { + renderedIDs[i][diffKeyIdentity(key)] = true + } + } + + rejected := make(map[int]error) + + for b := range groups { + managed := inputs[b].res + if inputs[b].err != nil || identityOf(managed) == "" { + continue + } + + for a := range groups { + if a == b || failed[a] != nil { + continue + } + + manager := inputs[a].res + relation := managedBy(groups[b], managed, groups[a], manager) + + // The manager's rendered keys are checked as well as managedBy's controller reference: a child + // the manager would create has no cluster copy yet, so nothing carries that reference. + switch { + case renderedIDs[a][identityOf(managed)]: + rejected[b] = errors.Errorf("%s is composed by input %s, whose composition writes it; supplying it "+ + "as an input too gives it a second writer — pass %s only", kindName(managed), kindName(manager), kindName(manager)) + case relation == composedBy: + // The manager's render no longer produces it, but the manager still controls it in the + // cluster, so diffing it on its own predicts nothing applying the manager would do. + rejected[b] = errors.Errorf("%s is controlled by input %s; supplying it as an input too "+ + "gives it a second writer — pass %s only", kindName(managed), kindName(manager), kindName(manager)) + case relation == claimedBy: + rejected[b] = errors.Errorf("%s is the XR bound to claim %s, which Crossplane's claim controller writes; "+ + "supplying it as an input too gives it a second writer — pass the claim, not its XR", kindName(managed), kindName(manager)) + } + + if rejected[b] != nil { + break + } + } + } + + return rejected +} // management is how one input of a run manages another; see managedBy. type management int @@ -204,14 +418,16 @@ func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) er inputs := make([]string, 0, len(producers)) var ( - controllers []string - allEqual = true - identical = true - seenController = make(map[string]bool) - seenGroup = make(map[int]bool) - keys []string - seenKey = make(map[string]bool) - first = groups[producers[0].group].Diffs[producers[0].key] + controllers []string + allEqual = true + identical = true + sharedPlaceholder = false + placeholders = make(map[string]bool) + seenController = make(map[string]bool) + seenGroup = make(map[int]bool) + keys []string + seenKey = make(map[string]bool) + first = groups[producers[0].group].Diffs[producers[0].key] ) for _, p := range producers { @@ -227,6 +443,11 @@ func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) er if !seenGroup[p.group] { seenGroup[p.group] = true inputs = append(inputs, fmt.Sprintf("%s/%s", g.XR.Kind, g.XR.Name)) + + if g.NameGenerated { + sharedPlaceholder = sharedPlaceholder || placeholders[g.XR.Name] + placeholders[g.XR.Name] = true + } } // A nil entry is not expected; treat it as observable and as matching nothing, so a malformed @@ -260,6 +481,10 @@ func renderOverlapError(groups []dt.XRDiffGroup, producers []overlapProducer) er switch { case allEqual: return nil + case sharedPlaceholder: + return errors.Errorf("cannot combine diffs: inputs %s both produce resource %s only because simulate "+ + "gives XRs that share a generateName the same placeholder name; the API server would name them "+ + "differently, so they cannot be told apart here — diff them separately", strings.Join(inputs, ", "), key) case len(controllers) > 1: return errors.Errorf("cannot combine diffs: resource %s would be controlled by more than one XR (%s); "+ "Crossplane gives it to whichever of them creates it first, and the other fails to reconcile it, "+ @@ -359,3 +584,7 @@ func refIdentity(obj *un.Unstructured, path ...string) string { return identity(ref["apiVersion"], ref["kind"], ref["namespace"], ref["name"]) } + +func kindName(res *un.Unstructured) string { + return fmt.Sprintf("%s/%s", res.GetKind(), res.GetName()) +} diff --git a/internal/diff/diffprocessor/overlap_test.go b/internal/diff/diffprocessor/overlap_test.go index 872c4737..9c5739fe 100644 --- a/internal/diff/diffprocessor/overlap_test.go +++ b/internal/diff/diffprocessor/overlap_test.go @@ -9,6 +9,8 @@ import ( un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime/schema" + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + dt "github.com/crossplane/cli/v2/internal/diff/result" tu "github.com/crossplane/cli/v2/internal/diff/testutils" ) @@ -22,7 +24,97 @@ func errMessage(err error) string { return err.Error() } -func TestManagedBy(t *testing.T) { +func TestCheckInputs(t *testing.T) { + xr := func(name string) *tu.ResourceBuilder { return tu.NewResource("example.org/v1", "XR", name) } + + type verdict struct { + Duplicate bool + Err string + } + + conflict := func(position, other int, name string) string { + return errMessage(conflictingInputError(position, other, xr(name).Build())) + } + + tests := map[string]struct { + reason string + inputs []*un.Unstructured + want []verdict + wantWarnings []string + }{ + "DistinctInputsPass": { + reason: "Inputs naming different objects are each rendered.", + inputs: []*un.Unstructured{xr("a").Build(), xr("b").Build()}, + want: []verdict{{}, {}}, + }, + "IdenticalDuplicateIsDropped": { + reason: "An input identical to an earlier one has one clear intent, so it is dropped with a warning.", + inputs: []*un.Unstructured{xr("a").WithSpecField("f", "v").Build(), xr("a").WithSpecField("f", "v").Build()}, + want: []verdict{{}, {Duplicate: true}}, + wantWarnings: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, + }, + "SameObjectWithDifferentContentFailsBoth": { + reason: "Neither of two differing definitions can be preferred, so each fails, naming the other.", + inputs: []*un.Unstructured{xr("a").WithSpecField("f", "1").Build(), xr("a").WithSpecField("f", "2").Build()}, + want: []verdict{{Err: conflict(1, 2, "a")}, {Err: conflict(2, 1, "a")}}, + }, + "SameObjectAtAnotherAPIVersionIsStillTheSameObject": { + reason: "Identity is version-independent, so one object at two versions is a conflict, not two objects.", + inputs: []*un.Unstructured{xr("a").Build(), tu.NewResource("example.org/v2", "XR", "a").Build()}, + want: []verdict{{Err: conflict(1, 2, "a")}, {Err: conflict(2, 1, "a")}}, + }, + "SameNameInAnotherNamespaceIsAnotherObject": { + reason: "Namespace is part of identity.", + inputs: []*un.Unstructured{xr("a").InNamespace("one").Build(), xr("a").InNamespace("two").Build()}, + want: []verdict{{}, {}}, + }, + "GenerateNameOnlyInputsAreNotJudged": { + reason: "An input with no name yet has no identity: two identical ones are two XRs, not a duplicate.", + inputs: []*un.Unstructured{xr("").WithGenerateName("g-").Build(), xr("").WithGenerateName("g-").Build()}, + want: []verdict{{}, {}}, + }, + "LaterInputIsJudgedAgainstTheFirst": { + reason: "A third input identical to the first is a duplicate of it, even after a conflicting second.", + inputs: []*un.Unstructured{ + xr("a").WithSpecField("f", "1").Build(), + xr("a").WithSpecField("f", "2").Build(), + xr("a").WithSpecField("f", "1").Build(), + }, + want: []verdict{{Err: conflict(1, 2, "a")}, {Err: conflict(2, 1, "a")}, {Duplicate: true}}, + wantWarnings: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + advisories := NewAdvisoryCollector(nil) + + checks := checkInputs(advisories, tt.inputs) + + got := make([]verdict, 0, len(checks)) + for _, c := range checks { + got = append(got, verdict{Duplicate: c.duplicate, Err: errMessage(c.err)}) + } + + if diff := gcmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\ncheckInputs(...): -want, +got:\n%s", tt.reason, diff) + } + + warnings := advisories.Advisories() + + gotWarnings := make([]string, 0, len(warnings)) + for _, w := range warnings { + gotWarnings = append(gotWarnings, w.Message) + } + + if diff := gcmp.Diff(tt.wantWarnings, gotWarnings, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\ncheckInputs(...) warnings: -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestRejectManagedInputs(t *testing.T) { xr := func(name string) *tu.ResourceBuilder { return tu.NewResource("example.org/v1", "XR", name) } claim := func(name string) *tu.ResourceBuilder { return tu.NewResource("example.org/v1", "Claim", name).InNamespace("default") @@ -33,106 +125,298 @@ func TestManagedBy(t *testing.T) { return map[string]string{LabelClaimName: "my-claim", LabelClaimNamespace: namespace} } - // side is one input with what its own diff carries: its cluster copy and rendered form, if any. - type side struct { - res, cluster, rendered *un.Unstructured - } - group := func(s side) dt.XRDiffGroup { - if s.cluster == nil && s.rendered == nil { - return dt.XRDiffGroup{} - } - return dt.XRDiffGroup{Diffs: map[string]*dt.ResourceDiff{ - dt.MakeDiffKeyFromResource(s.res): {Current: dt.ResourceViews{Raw: s.cluster}, Desired: dt.ResourceViews{Raw: s.rendered}}, - }} + const ( + composedErr = "XR/child is composed by input XR/parent, whose composition writes it; supplying it as an " + + "input too gives it a second writer — pass XR/parent only" + controlledErr = "XR/child is controlled by input XR/parent; supplying it as an input too gives it a " + + "second writer — pass XR/parent only" + claimErr = "XR/backing is the XR bound to claim Claim/my-claim, which Crossplane's claim controller writes; " + + "supplying it as an input too gives it a second writer — pass the claim, not its XR" + ) + + // input is one group as the processor would hand it over. + type input struct { + res *un.Unstructured + // cluster is the cluster copy carried by the input's own diff, if any. + cluster *un.Unstructured + // rendered is every resource key the input's render produced. + rendered []string + // failedRender marks the input's own render as failed; rejected marks it failed before rendering. + failedRender, rejected bool } tests := map[string]struct { - reason string - xr, manager side - want management + reason string + inputs []input + want map[int]string }{ - "UnrelatedInputs": { - reason: "Inputs that neither compose nor bind one another manage nothing.", - xr: side{res: xr("b").Build()}, - manager: side{res: xr("a").Build()}, - want: notManaged, - }, - "ChildComposedByParent": { - reason: "The child's rendered form names the parent as its controller.", - xr: side{res: xr("child").Build(), rendered: xr("child").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, - manager: side{res: xr("parent").Build()}, - want: composedBy, - }, - "ChildControlledByParentInCluster": { - reason: "A child the parent still controls in the cluster is managed by it.", - xr: side{res: xr("child").Build(), cluster: xr("child").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, - manager: side{res: xr("parent").Build()}, - want: composedBy, - }, - "ControllerOfTheSameNameInAnotherNamespace": { - reason: "A namespaced parent controls only objects in its own namespace.", - xr: side{res: xr("child").InNamespace("b").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, - manager: side{res: xr("parent").InNamespace("a").Build()}, - want: notManaged, - }, - "ControllerAtAnotherAPIVersion": { - reason: "A controller reference at another version names the same object.", - xr: side{res: xr("child").WithControllerReference("XR", "parent", "example.org/v2", "uid").Build()}, - manager: side{res: xr("parent").Build()}, - want: composedBy, - }, - "ManagerWithNoName": { - reason: "A generateName-only manager has no identity, so it manages nothing.", - xr: side{res: xr("child").WithControllerReference("XR", "", "example.org/v1", "uid").Build()}, - manager: side{res: xr("").WithGenerateName("parent-").Build()}, - want: notManaged, + "UnrelatedInputsPass": { + reason: "Inputs that neither compose nor bind one another are all valid.", + inputs: []input{{res: xr("a").Build()}, {res: xr("b").Build()}}, + }, + "ChildComposedByAParentInputIsRejected": { + reason: "A child in the parent's rendered keys has a second writer if it is also an input.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}}, + {res: xr("child").Build()}, + }, + want: map[int]string{1: composedErr}, + }, + "ChildControlledByAParentInputIsRejected": { + reason: "A child the parent still controls in the cluster is rejected even when the parent's render drops it.", + inputs: []input{ + {res: xr("parent").Build()}, + {res: xr("child").Build(), cluster: xr("child").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, + }, + want: map[int]string{1: controlledErr}, + }, + "ControllerOfTheSameNameInAnotherNamespaceDoesNotMatch": { + reason: "A namespaced parent controls only objects in its own namespace.", + inputs: []input{ + {res: xr("parent").InNamespace("a").Build()}, + {res: xr("child").InNamespace("b").WithControllerReference("XR", "parent", "example.org/v1", "uid").Build()}, + }, + }, + "ChildRenderedAtAnotherAPIVersionIsStillRejected": { + reason: "The parent may render the child at another version; it is the same object.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v2", "XR", "", "child")}}, + {res: xr("child").Build()}, + }, + want: map[int]string{1: composedErr}, + }, + "ManagedInputIsJudgedDespiteItsOwnRenderFailure": { + reason: "The verdict supersedes the managed input's own render failure, which is moot.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}}, + {res: xr("child").Build(), failedRender: true}, + }, + want: map[int]string{1: composedErr}, + }, + "AFailedManagerManagesNothing": { + reason: "What a manager manages is read from its render, so one that failed to render cannot reject.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}, failedRender: true}, + {res: xr("child").Build()}, + }, + }, + "InputRejectedBeforeRenderingIsNotJudged": { + reason: "An input already rejected by checkInputs keeps that error.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "child")}}, + {res: xr("child").Build(), rejected: true}, + }, + }, + "GenerateNameOnlyInputIsNotJudged": { + reason: "An input with no name yet has no identity to be managed by.", + inputs: []input{ + {res: xr("parent").Build(), rendered: []string{dt.MakeDiffKey("example.org/v1", "XR", "", "")}}, + {res: xr("").WithGenerateName("child-").Build()}, + }, }, "AnInputWithNoNameIsNobodysClaim": { - reason: "A generateName-only input must not match an XR's absent claimRef, which is also empty.", - xr: side{res: xr("named").Build()}, - manager: side{res: xr("").WithGenerateName("gen-").Build()}, - want: notManaged, + reason: "A generateName-only input must not match an XR's absent claimRef, which is also empty.", + inputs: []input{{res: xr("named").Build()}, {res: xr("").WithGenerateName("gen-").Build()}}, }, "BackingXRNamedByTheClaimsClusterResourceRef": { - reason: "A bound claim's cluster copy names its XR in spec.resourceRef.", - xr: side{res: xr("backing").Build()}, - manager: side{res: claim("my-claim").Build(), cluster: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, - want: claimedBy, + reason: "A bound claim's cluster copy names its XR in spec.resourceRef.", + inputs: []input{ + {res: claim("my-claim").Build(), cluster: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, + {res: xr("backing").Build()}, + }, + want: map[int]string{1: claimErr}, }, "BackingXRNamedByTheClaimsInputResourceRef": { - reason: "A claim exported from the cluster carries spec.resourceRef in the input itself.", - xr: side{res: xr("backing").Build()}, - manager: side{res: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, - want: claimedBy, + reason: "A claim exported from the cluster carries spec.resourceRef in the input itself.", + inputs: []input{ + {res: claim("my-claim").WithSpecField("resourceRef", resourceRef).Build()}, + {res: xr("backing").Build()}, + }, + want: map[int]string{1: claimErr}, }, "BackingXRWhoseClaimRefNamesTheClaim": { - reason: "The XR's side of the binding is enough on its own.", - xr: side{res: xr("backing").WithSpecField("claimRef", claimRef).Build()}, - manager: side{res: claim("my-claim").Build()}, - want: claimedBy, + reason: "The XR's side of the binding is enough on its own.", + inputs: []input{ + {res: claim("my-claim").Build()}, + {res: xr("backing").WithSpecField("claimRef", claimRef).Build()}, + }, + want: map[int]string{1: claimErr}, }, "BackingXRWhoseClaimLabelsNameTheClaim": { - reason: "Crossplane stamps the claim's name and namespace on the XR as labels.", - xr: side{res: xr("backing").Build(), cluster: xr("backing").WithLabels(claimLabels("default")).Build()}, - manager: side{res: claim("my-claim").Build()}, - want: claimedBy, + reason: "Crossplane stamps the claim's name and namespace on the XR as labels.", + inputs: []input{ + {res: claim("my-claim").Build()}, + {res: xr("backing").Build(), cluster: xr("backing").WithLabels(claimLabels("default")).Build()}, + }, + want: map[int]string{1: claimErr}, + }, + "ClaimLabelsForAnotherNamespaceDoNotMatch": { + reason: "A claim of the same name in another namespace is a different claim.", + inputs: []input{ + {res: claim("my-claim").Build()}, + {res: xr("backing").WithLabels(claimLabels("elsewhere")).Build()}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + var ( + groups = make([]dt.XRDiffGroup, len(tt.inputs)) + checks = make([]inputCheck, len(tt.inputs)) + rendered = make([]map[string]bool, len(tt.inputs)) + failed = make([]error, len(tt.inputs)) + ) + + for i, in := range tt.inputs { + checks[i] = inputCheck{res: in.res} + if in.rejected { + checks[i].err = errors.New("rejected before rendering") + } + + if in.failedRender { + failed[i] = errors.New("render failed") + } + + if in.cluster != nil { + groups[i].Diffs = map[string]*dt.ResourceDiff{ + dt.MakeDiffKeyFromResource(in.res): {Current: dt.ResourceViews{Raw: in.cluster}}, + } + } + + rendered[i] = map[string]bool{} + for _, key := range in.rendered { + rendered[i][key] = true + } + } + + got := map[int]string{} + for i, err := range rejectManagedInputs(groups, checks, rendered, failed) { + got[i] = errMessage(err) + } + + if diff := gcmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { + t.Errorf("%s\nrejectManagedInputs(...): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +// TestManagedByManagerWithNoName pins that a generateName-only manager has no identity, so it manages +// nothing even when the candidate's controller reference has an empty name. +func TestManagedByManagerWithNoName(t *testing.T) { + xr := tu.NewResource("example.org/v1", "XR", "child").WithControllerReference("XR", "", "example.org/v1", "uid").Build() + manager := tu.NewResource("example.org/v1", "XR", "").WithGenerateName("parent-").Build() + + if got := managedBy(dt.XRDiffGroup{}, xr, dt.XRDiffGroup{}, manager); got != notManaged { + t.Errorf("managedBy(...): want notManaged, got %d", got) + } + + if boundToClaim(dt.XRDiffGroup{}, xr, dt.XRDiffGroup{}, manager) { + t.Error("boundToClaim(...): want false, got true") + } +} + +// TestBundleInputValidatorVerdicts drives the default validator through its stages as PerformDiff does, +// pinning what it owns beyond its rules: dropping duplicates from ToRender, and the precedence of a +// managed-input verdict over that input's own render failure. +func TestBundleInputValidatorVerdicts(t *testing.T) { + xr := func(name string) *un.Unstructured { return tu.NewResource("example.org/v1", "XR", name).Build() } + childKey := dt.MakeDiffKey("example.org/v1", "XR", "", "child") + + const composedErr = "XR/child is composed by input XR/parent, whose composition writes it; supplying it as an " + + "input too gives it a second writer — pass XR/parent only" + + conflict34 := conflictingInputError(3, 4, xr("b")).Error() + conflict43 := conflictingInputError(4, 3, xr("b")).Error() + + // render is one input's recorded render: its rendered keys and its own failure. + type render struct { + keys []string + err string + } + + type want struct { + ToRender []string // name, or "name: err" for an input rejected before rendering + Verdicts []string + } + + tests := map[string]struct { + reason string + inputs []*un.Unstructured + renders map[int]render // by ToRender index; an input rejected before rendering is not rendered + want want + }{ + "ManagedVerdictReplacesTheManagedInputsRenderFailure": { + reason: "A managed input whose own render failed reports why it should not have been passed, not the render failure.", + inputs: []*un.Unstructured{xr("parent"), xr("child")}, + renders: map[int]render{ + 0: {keys: []string{childKey}}, + 1: {err: "render failed"}, + }, + want: want{ToRender: []string{"parent", "child"}, Verdicts: []string{"", composedErr}}, + }, + "AnUnmanagedRenderFailureIsKept": { + reason: "Without a managed-input verdict, a group's final error is its own render failure.", + inputs: []*un.Unstructured{xr("a"), xr("b")}, + renders: map[int]render{0: {}, 1: {err: "render failed"}}, + want: want{ToRender: []string{"a", "b"}, Verdicts: []string{"", "render failed"}}, }, - "ClaimLabelsForAnotherNamespace": { - reason: "A claim of the same name in another namespace is a different claim.", - xr: side{res: xr("backing").WithLabels(claimLabels("elsewhere")).Build()}, - manager: side{res: claim("my-claim").Build()}, - want: notManaged, + "DuplicateIsDroppedAndAConflictIsRejectedBeforeRendering": { + reason: "An identical duplicate is not handed back to render; the same object twice differently is, " + + "rejected, and that rejection is its verdict.", + inputs: []*un.Unstructured{ + xr("a"), xr("a"), + tu.NewResource("example.org/v1", "XR", "b").WithSpecField("v", "1").Build(), + tu.NewResource("example.org/v1", "XR", "b").WithSpecField("v", "2").Build(), + }, + renders: map[int]render{0: {}}, + want: want{ + ToRender: []string{"a", "b: " + conflict34, "b: " + conflict43}, + Verdicts: []string{"", conflict34, conflict43}, + }, }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { - xg, mg := group(tt.xr), group(tt.manager) - if got := managedBy(xg, tt.xr.res, mg, tt.manager.res); got != tt.want { - t.Errorf("%s\nmanagedBy(...): want %d, got %d", tt.reason, tt.want, got) + v := NewBundleInputValidator(NewAdvisoryCollector(nil), tt.inputs) + + got := want{} + + toRender := v.ToRender() + for i, in := range toRender { + entry := in.Resource.GetName() + if in.Err != nil { + entry += ": " + in.Err.Error() + } + + got.ToRender = append(got.ToRender, entry) + + r, ok := tt.renders[i] + if !ok { + continue + } + + keys := map[string]bool{} + for _, k := range r.keys { + keys[k] = true + } + + var err error + if r.err != "" { + err = errors.New(r.err) + } + + v.RecordRender(i, keys, err) } - if got, want := boundToClaim(xg, tt.xr.res, mg, tt.manager.res), tt.want == claimedBy; got != want { - t.Errorf("%s\nboundToClaim(...): want %t, got %t", tt.reason, want, got) + + for _, err := range v.Verdicts(make([]dt.XRDiffGroup, len(toRender))) { + got.Verdicts = append(got.Verdicts, errMessage(err)) + } + + if diff := gcmp.Diff(tt.want, got); diff != "" { + t.Errorf("%s\nbundleInputValidator: -want, +got:\n%s", tt.reason, diff) } }) } diff --git a/internal/diff/diffprocessor/processor_config.go b/internal/diff/diffprocessor/processor_config.go index d00a346f..ed32ecf7 100644 --- a/internal/diff/diffprocessor/processor_config.go +++ b/internal/diff/diffprocessor/processor_config.go @@ -154,7 +154,7 @@ type ProcessorConfig struct { // HasChanges and renderer skips equal diffs, so this only adds diffs a caller can refine further. KeepEqualDiffs func(*un.Unstructured) bool - // XRTimeout, when positive, bounds each composite resource's diff, one input of DiffSingleResource or one + // XRTimeout, when positive, bounds each composite resource's diff, one input of PerformDiff or one // affected composite of DiffComposition: its render and AfterXRDiff. A composite that runs out // fails alone, as a failed render does, and the next one still renders. Zero, the default, adds // no deadline to the caller's. @@ -231,6 +231,9 @@ type ComponentFactories struct { // FunctionProvider creates a FunctionProvider FunctionProvider func(fnClient xp.FunctionClient, logger logging.Logger) FunctionProvider + + // InputValidator creates the InputValidator for one PerformDiff run + InputValidator InputValidatorFactory } // ProcessorOption defines a function that can modify a ProcessorConfig. @@ -424,6 +427,13 @@ func WithRequirementsProviderFactory(factory func(k8.ResourceClient, logging.Log } } +// WithInputValidatorFactory sets the InputValidator factory function, called once per PerformDiff run. +func WithInputValidatorFactory(factory InputValidatorFactory) ProcessorOption { + return func(config *ProcessorConfig) { + config.Factories.InputValidator = factory + } +} + // WithFunctionProviderFactory sets the FunctionProvider factory function. func WithFunctionProviderFactory(factory func(xp.FunctionClient, logging.Logger) FunctionProvider) ProcessorOption { return func(config *ProcessorConfig) { @@ -590,4 +600,8 @@ func (c *ProcessorConfig) SetDefaultFactories() { // and enables reuse when diffing multiple XRs with the same composition. c.Factories.FunctionProvider = NewCachedFunctionProvider } + + if c.Factories.InputValidator == nil { + c.Factories.InputValidator = NewBundleInputValidator + } } diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index fbd3cb01..e7d7e70c 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -14,8 +14,9 @@ import ( ) // flattenGroups merges every group's diffs into a single map, for the human -// renderer's flat block. The only caller is the composition renderer, which hands it a single -// identity-less group, so no two inputs' diffs are merged here. +// renderer's flat block. That block is only rendered for at most one input +// with an identity (see RenderDiffs), and for the composition renderer's +// identity-less reuse, so no two inputs' diffs are merged here. // A nil group.Diffs is a safe no-op under maps.Copy. func flattenGroups(groups []dt.XRDiffGroup) map[string]*dt.ResourceDiff { out := make(map[string]*dt.ResourceDiff) @@ -146,6 +147,21 @@ func (r *DefaultDiffRenderer) renderDiffList(diffs map[string]*dt.ResourceDiff) return counts, nil } +// plus returns the field-wise sum of two tallies. Used to roll up per-XR counts +// into the cross-XR total for the aggregate footer. Returning a new value +// (rather than mutating) keeps every diffCounts method a value receiver, which +// also lets String satisfy Stringer on a value passed to the logger. +func (c diffCounts) plus(other diffCounts) diffCounts { + return diffCounts{ + added: c.added + other.added, + modified: c.modified + other.modified, + removed: c.removed + other.removed, + equal: c.equal + other.equal, + replaced: c.replaced + other.replaced, + output: c.output + other.output, + } +} + // String renders the tally as a single log-friendly field. Implementing // Stringer lets callers log a diffCounts as one value ("counts", counts) // instead of spelling out every field; fmt-based logr sinks honor it. (A @@ -186,18 +202,38 @@ func (c diffCounts) summaryLine() string { return line } +// hasIdentity reports whether a group carries input-XR identity. The +// composition renderer reuses this renderer with identity-less groups (no +// single owning XR); those render as a flat, header-less block, preserving +// comp's output. The xr command always sets identity, producing per-XR +// sections. +func hasIdentity(g dt.XRDiffGroup) bool { + return g.XR.Kind != "" || g.XR.Name != "" +} + // identitylessGroups wraps a flat diff map as a single group with no XR // identity, the shape callers use when there is no single owning XR (the -// composition renderer's downstream diffs). +// composition renderer's downstream diffs). RenderDiffs renders an +// identity-less group as a flat, header-less block. See hasIdentity. func identitylessGroups(diffs map[string]*dt.ResourceDiff) []dt.XRDiffGroup { return []dt.XRDiffGroup{{Diffs: diffs}} } -// RenderDiffs formats and prints the diffs to r.diffOpts.Stdout as a single flat block. It does not +// RenderDiffs formats and prints the diffs to r.diffOpts.Stdout. It does not // print errs; the command prints the error the processor returns. // +// Identity-bearing groups (the xr command) render as per-input-XR sections, +// each with a header and per-section summary, followed by an aggregate footer +// when there is more than one such group. Identity-less groups (the +// composition renderer's reuse) render as a single flat block, preserving the +// pre-grouping behavior. // The warnings parameter is intentionally unused: warnings reach humans via stderr when they are // raised, not at render time. See the DiffRenderer interface comment. +// +// The summary of unverified additions is derived from identity-bearing groups only. An identity-less +// group is the composition renderer's internal reuse of this one (see dt.XRDiffGroup), which happens +// once per composition; that renderer writes one run-wide summary itself, so deriving one here as well +// would repeat it. func (r *DefaultDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.OutputError, _ []dt.OutputWarning) error { r.logger.Debug("Rendering diffs to output", "groupCount", len(groups), @@ -205,20 +241,29 @@ func (r *DefaultDiffRenderer) RenderDiffs(groups []dt.XRDiffGroup, errs []dt.Out "useColors", r.diffOpts.UseColors, "compact", r.diffOpts.Compact) - if err := r.renderFlat(flattenGroups(groups)); err != nil { - return err - } - - // The unverified-additions summary comes from groups with an input XR only: the composition renderer - // reuses this one with an identity-less group once per composition and writes one run-wide summary itself. + // Per-XR sections (and the aggregate footer) are only meaningful when more + // than one input XR is present — that's what there is to disambiguate. A + // single XR (or identity-less comp reuse) renders as a flat block, exactly + // as before grouping was introduced. var xrGroups []dt.XRDiffGroup for _, g := range groups { - if g.XR.Kind != "" || g.XR.Name != "" { + if hasIdentity(g) { xrGroups = append(xrGroups, g) } } + var err error + if len(xrGroups) > 1 { + err = r.renderGrouped(groups) + } else { + err = r.renderFlat(flattenGroups(groups)) + } + + if err != nil { + return err + } + return writeWarnings(r.diffOpts.Stderr, dt.XRDryRunWarnings(xrGroups)) } @@ -242,3 +287,102 @@ func (r *DefaultDiffRenderer) renderFlat(diffs map[string]*dt.ResourceDiff) erro return nil } + +// renderGrouped renders each group as a per-input-XR section (header + diffs + +// per-section summary, or an inline error / "No changes."), then an aggregate +// footer. Only called when there is more than one input XR (a single XR renders +// flat), so the footer always applies. Sections are emitted in input order. +func (r *DefaultDiffRenderer) renderGrouped(groups []dt.XRDiffGroup) error { + stdout := r.diffOpts.Stdout + + var ( + total diffCounts + unchangedXR int + errorXR int + ) + + for _, g := range groups { + // An identity-less group (should not normally be mixed in here) has no + // owning XR to head a section, so fold its diffs into the aggregate + // without a header or per-section summary. + if !hasIdentity(g) { + counts, err := r.renderDiffList(g.Diffs) + if err != nil { + return err + } + + total = total.plus(counts) + + continue + } + + if _, err := fmt.Fprintf(stdout, "=== %s/%s ===\n\n", g.XR.Kind, g.XR.Name); err != nil { + return errors.Wrap(err, "failed to write XR section header") + } + + if g.Err != nil { + errorXR++ + + if _, err := fmt.Fprintf(stdout, "Error: %s\n\n", g.Err.Message); err != nil { + return errors.Wrap(err, "failed to write XR error") + } + + continue + } + + counts, err := r.renderDiffList(g.Diffs) + if err != nil { + return err + } + + total = total.plus(counts) + + if counts.output == 0 { + unchangedXR++ + + if _, err := fmt.Fprintf(stdout, "No changes.\n\n"); err != nil { + return errors.Wrap(err, "failed to write no-changes message") + } + + continue + } + + if line := counts.summaryLine(); line != "" { + if _, err := fmt.Fprintf(stdout, "\nSummary: %s\n\n", line); err != nil { + return errors.Wrap(err, "failed to write section summary") + } + } + } + + return r.renderAggregateFooter(len(groups), total, unchangedXR, errorXR) +} + +// renderAggregateFooter writes the cross-XR totals line shown when more than one +// input XR was diffed. +func (r *DefaultDiffRenderer) renderAggregateFooter(xrCount int, total diffCounts, unchangedXR, errorXR int) error { + line := total.summaryLine() + if line == "" { + line = "no changes" + } + + var qualifiers []string + if unchangedXR > 0 { + qualifiers = append(qualifiers, fmt.Sprintf("%d unchanged", unchangedXR)) + } + + if errorXR > 0 { + qualifiers = append(qualifiers, fmt.Sprintf("%d error", errorXR)) + } + + suffix := "" + if len(qualifiers) > 0 { + suffix = fmt.Sprintf(" (%s)", strings.Join(qualifiers, ", ")) + } + + if _, err := fmt.Fprintf(r.diffOpts.Stdout, "%s\nTotal: %s across %d XRs%s\n", + strings.Repeat("=", 80), line, xrCount, suffix); err != nil { + return errors.Wrap(err, "failed to write aggregate footer") + } + + return nil +} diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index d032ef12..454f92f4 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -188,6 +188,43 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { }, notExpected: []string{"===", "Total:"}, }, + // Multiple identity-bearing groups: each gets a header in input order, an + // unchanged group says "No changes.", and an aggregate footer tallies all. + "MultipleXRsWithUnchanged": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "first-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "second-xr", map[string]*dt.ResourceDiff{}), + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/first-xr ===", + "=== XNopResource/second-xr ===", + "No changes.", + "Total: 1 modified across 2 XRs (1 unchanged)", + }, + }, + // Multiple XRs where one errored: the errored XR gets an inline Error + // section on stdout and is tallied in the footer. + "MultipleXRsWithError": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "ok-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + { + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNopResource", Name: "broken-xr"}, + Err: &dt.OutputError{ResourceID: "XNopResource/broken-xr", Message: "cannot get composition"}, + }, + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/ok-xr ===", + "=== XNopResource/broken-xr ===", + "Error: cannot get composition", + "Total: 1 modified across 2 XRs (1 error)", + }, + }, // An identity-less group (the composition renderer's reuse) renders flat: // no section header, no aggregate footer. "IdentityLessRendersFlat": { @@ -201,6 +238,73 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { }, notExpected: []string{"===", "Total:"}, }, + // Changed + unchanged + errored in one batch: the footer reports all + // three qualifiers together (the design doc's illustrative example). + "AllThreeFooterQualifiers": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "changed-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "unchanged-xr", map[string]*dt.ResourceDiff{}), + { + XR: corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNopResource", Name: "broken-xr"}, + Err: &dt.OutputError{ResourceID: "XNopResource/broken-xr", Message: "boom"}, + }, + }, + options: noColor, + expectedOutputs: []string{ + "Total: 1 modified across 3 XRs (1 unchanged, 1 error)", + }, + }, + // A group whose only diff is equal renders "No changes." and counts as + // unchanged in the footer. + "GroupedEqualOnlyIsUnchanged": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "equal-xr", map[string]*dt.ResourceDiff{ + "equal": { + Gvk: schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}, + ResourceName: "bucket-eq", + DiffType: dt.DiffTypeEqual, + }, + }), + xrGroup("XNopResource", "changed-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/equal-xr ===", + "No changes.", + "Total: 1 modified across 2 XRs (1 unchanged)", + }, + }, + // A mixed batch (identity-less group alongside identity-bearing ones): + // the identity-less group's diffs fold into the aggregate with no header. + // Two identity-bearing groups ensure the footer fires so the fold-in is + // observable in the total. + "MixedIdentityLessFoldsInWithoutHeader": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "real-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "real-xr-2", map[string]*dt.ResourceDiff{ + "q": modifiedDiffFor("Queue", "real-queue"), + }), + {Diffs: map[string]*dt.ResourceDiff{ + "extra": modifiedDiffFor("Topic", "loose-topic"), + }}, + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/real-xr ===", + "=== XNopResource/real-xr-2 ===", + "~~~ Topic/loose-topic", // identity-less diff still rendered + "Total: 3 modified across 3 XRs", // all three counted + }, + notExpected: []string{ + "=== /", "=== (unknown)", // no header for the identity-less group + }, + }, // --- The Replaced hook --- @@ -217,6 +321,25 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { "Summary: 1 added, 1 modified (1 requires replacement)\n", }, }, + // The hook reaches every summary: each section's, and the footer's. + "ReplacedGroupedSummaries": { + groups: []dt.XRDiffGroup{ + xrGroup("XNopResource", "first-xr", map[string]*dt.ResourceDiff{ + changedBucket.GetDiffKey(): changedBucket, + }), + xrGroup("XNopResource", "second-xr", map[string]*dt.ResourceDiff{ + "q": modifiedDiffFor("Queue", "real-queue"), + }), + }, + options: replacing(noColor, "my-xr-bucket"), + expectedOutputs: []string{ + "~~~ Bucket/my-xr-bucket (requires replacement)\n", + "~~~ Queue/real-queue\n", + "Summary: 1 modified (1 requires replacement)\n", + "Summary: 1 modified\n", + "Total: 2 modified (1 requires replacement) across 2 XRs\n", + }, + }, // An equal diff renders nothing, so it is never reported replaced. "ReplacedEqualIsSkipped": { groups: identitylessGroups(map[string]*dt.ResourceDiff{ diff --git a/internal/diff/result/types.go b/internal/diff/result/types.go index 7c93e147..c0ee37fa 100644 --- a/internal/diff/result/types.go +++ b/internal/diff/result/types.go @@ -37,9 +37,37 @@ import ( // DiffRenderer mock — can reference it without importing renderer, which would // close an import cycle through renderer's in-package tests. type XRDiffGroup struct { - XR corev1.ObjectReference - Diffs map[string]*ResourceDiff - Err *OutputError + XR corev1.ObjectReference + // NameGenerated is true when the input XR had only a generateName, so XR.Name — and the name + // its render used — is a placeholder this tool synthesized, not one the API server would assign. + // Two such XRs sharing a generateName are distinct in the cluster but indistinguishable here; see + // the diff processor's render-overlap check. + NameGenerated bool + Diffs map[string]*ResourceDiff + Err *OutputError +} + +// XRReport is what one xr diff run produced: everything a DiffRenderer renders. +type XRReport struct { + // Groups holds one entry per input XR/claim, in input order. + Groups []XRDiffGroup + // Errors is the run's union error list: every group's error, plus errors no single group owns + // (render overlaps, an interruption). + Errors []OutputError + // Warnings are the advisories the run raised, sorted. + Warnings []OutputWarning +} + +// HasChanges reports whether any group has a non-equal diff. Groups may hold DiffTypeEqual entries +// (e.g. the XR, stored for removal detection), which are not changes. +func (r *XRReport) HasChanges() bool { + for _, group := range r.Groups { + if group.HasChanges() { + return true + } + } + + return false } // HasChanges reports whether any diff in the group is not equal. diff --git a/internal/diff/testutils/mock_builder.go b/internal/diff/testutils/mock_builder.go index 06694eac..aef8dbc0 100644 --- a/internal/diff/testutils/mock_builder.go +++ b/internal/diff/testutils/mock_builder.go @@ -720,6 +720,21 @@ func NewMockCompositionClient() *MockCompositionClientBuilder { } } +// WithInitialize sets the Initialize behavior. +func (b *MockCompositionClientBuilder) WithInitialize(fn func(context.Context) error) *MockCompositionClientBuilder { + b.mock.InitializeFn = fn + return b +} + +// WithSuccessfulInitialize mocks a successful call to Initialize. +func (b *MockCompositionClientBuilder) WithSuccessfulInitialize() *MockCompositionClientBuilder { + b.mock.InitializeFn = func(context.Context) error { + return nil + } + + return b +} + // WithFindMatchingComposition sets the FindMatchingComposition behavior. func (b *MockCompositionClientBuilder) WithFindMatchingComposition(fn func(context.Context, *un.Unstructured) (dtypes.ResolvedComposition, error)) *MockCompositionClientBuilder { b.mock.FindMatchingCompositionFn = fn @@ -734,6 +749,13 @@ func (b *MockCompositionClientBuilder) WithSuccessfulCompositionMatch(comp *xpex }) } +// WithNoMatchingComposition sets FindMatchingComposition to return "not found". +func (b *MockCompositionClientBuilder) WithNoMatchingComposition() *MockCompositionClientBuilder { + return b.WithFindMatchingComposition(func(context.Context, *un.Unstructured) (dtypes.ResolvedComposition, error) { + return dtypes.ResolvedComposition{}, errors.New("composition not found") + }) +} + // WithSuccessfulCompositionFetch sets up a successful composition fetch. func (b *MockCompositionClientBuilder) WithSuccessfulCompositionFetch(comp *xpextv1.Composition) *MockCompositionClientBuilder { b.mock.GetCompositionFn = func(_ context.Context, name string) (*xpextv1.Composition, error) { @@ -1142,6 +1164,13 @@ func (b *TreeNodeBuilder) Build() *resource.Resource { // endregion +// region DiffProcessor mock builder + +// ====================================================================================== +// DiffProcessor Mock Builder +// ====================================================================================== +// endregion + // region Resource builders // ====================================================================================== @@ -1226,6 +1255,15 @@ func (b *ResourceBuilder) WithSpecField(name string, value any) *ResourceBuilder return b } +// WithStatus sets the status field of the resource. +func (b *ResourceBuilder) WithStatus(status map[string]any) *ResourceBuilder { + if len(status) > 0 { + _ = un.SetNestedMap(b.resource.Object, status, "status") + } + + return b +} + // WithStatusField sets a specific field in the status. func (b *ResourceBuilder) WithStatusField(name string, value any) *ResourceBuilder { status, _, _ := un.NestedMap(b.resource.Object, "status") @@ -1670,6 +1708,11 @@ func (b *XRDBuilder) WithClaimNames(kind, plural string) *XRDBuilder { return b } +// WithDefaultVersion adds a default "v1" version to the XRD. +func (b *XRDBuilder) WithDefaultVersion() *XRDBuilder { + return b.WithVersion("v1", true, true) +} + // WithVersion adds a version to the XRD. func (b *XRDBuilder) WithVersion(name string, served, referenceable bool) *XRDBuilder { version := xpextv1.CompositeResourceDefinitionVersion{ diff --git a/internal/diff/testutils/mocks.go b/internal/diff/testutils/mocks.go index eb98451f..761055d5 100644 --- a/internal/diff/testutils/mocks.go +++ b/internal/diff/testutils/mocks.go @@ -257,6 +257,7 @@ func (m *MockResourceInterface) ApplyStatus(_ context.Context, _ string, _ *un.U type MockDiffProcessor struct { // Function fields for mocking behavior InitializeFn func(ctx context.Context) error + PerformDiffFn func(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) DiffSingleResourceFn func(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, map[string]*dt.ResourceDiff, error) CleanupFn func(ctx context.Context) error } @@ -270,6 +271,15 @@ func (m *MockDiffProcessor) Initialize(ctx context.Context) error { return nil } +// PerformDiff implements the DiffProcessor.PerformDiff method. +func (m *MockDiffProcessor) PerformDiff(ctx context.Context, resources []*un.Unstructured, compositionProvider types.CompositionProvider) (*dt.XRReport, error) { + if m.PerformDiffFn != nil { + return m.PerformDiffFn(ctx, resources, compositionProvider) + } + + return &dt.XRReport{}, nil +} + // DiffSingleResource implements the DiffProcessor.DiffSingleResource method. func (m *MockDiffProcessor) DiffSingleResource(ctx context.Context, res *un.Unstructured, compositionProvider types.CompositionProvider) (map[string]*dt.ResourceDiff, map[string]*dt.ResourceDiff, error) { if m.DiffSingleResourceFn != nil { @@ -343,6 +353,52 @@ func (m *MockDefaulter) Default(ctx context.Context, obj *un.Unstructured) (*un. // endregion +// region MockInputValidator + +// MockInputValidator Mock input validator, for driving PerformDiff's wiring directly. +type MockInputValidator struct { + ToRenderFn func() []types.ValidatedInput + RecordRenderFn func(i int, rendered map[string]bool, err error) + VerdictsFn func(groups []dt.XRDiffGroup) []error + RenderOverlapsFn func(groups []dt.XRDiffGroup) []error +} + +// ToRender returns the inputs to render. +func (m *MockInputValidator) ToRender() []types.ValidatedInput { + if m.ToRenderFn != nil { + return m.ToRenderFn() + } + + return nil +} + +// RecordRender records one render. +func (m *MockInputValidator) RecordRender(i int, rendered map[string]bool, err error) { + if m.RecordRenderFn != nil { + m.RecordRenderFn(i, rendered, err) + } +} + +// Verdicts returns each group's final error; by default, none. +func (m *MockInputValidator) Verdicts(groups []dt.XRDiffGroup) []error { + if m.VerdictsFn != nil { + return m.VerdictsFn(groups) + } + + return make([]error, len(groups)) +} + +// RenderOverlaps returns the render-overlap errors; by default, none. +func (m *MockInputValidator) RenderOverlaps(groups []dt.XRDiffGroup) []error { + if m.RenderOverlapsFn != nil { + return m.RenderOverlapsFn(groups) + } + + return nil +} + +// endregion + // region Kubernetes client mocks // ============================================================================== @@ -778,3 +834,27 @@ func (m *MockDiffCalculator) CalculateRemovedResourceDiffs(ctx context.Context, return nil, nil } + +// MockFunctionProvider provides a mock implementation for FunctionProvider. +type MockFunctionProvider struct { + GetFunctionsForCompositionFn func(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) + CleanupFn func(ctx context.Context) error +} + +// GetFunctionsForComposition implements FunctionProvider. +func (m *MockFunctionProvider) GetFunctionsForComposition(ctx context.Context, comp *xpextv1.Composition) ([]pkgv1.Function, error) { + if m.GetFunctionsForCompositionFn != nil { + return m.GetFunctionsForCompositionFn(ctx, comp) + } + + return nil, nil +} + +// Cleanup implements FunctionProvider. +func (m *MockFunctionProvider) Cleanup(ctx context.Context) error { + if m.CleanupFn != nil { + return m.CleanupFn(ctx) + } + + return nil +} diff --git a/internal/diff/types/types.go b/internal/diff/types/types.go index 1cb26e24..b3daf84a 100644 --- a/internal/diff/types/types.go +++ b/internal/diff/types/types.go @@ -82,6 +82,15 @@ type ResolvedComposition struct { RevisionName string } +// ValidatedInput is one input an input validator hands back for rendering: the resource, and the error +// that rejected it before rendering, if any. Lives here (not in diffprocessor, beside the InputValidator +// interface that returns it) so the validator's mock in cmd/diff/testutils can name it without an import +// cycle. +type ValidatedInput struct { + Resource *un.Unstructured + Err error // non-nil if rejected before rendering +} + // FindCompositesOptions narrows what CompositionClient.FindComposites returns. // Lives here (not in the crossplane client package) so test mocks in cmd/diff/testutils // can implement the interface without creating an import cycle with cmd/diff/client/crossplane. From 8f1d2d82fc6aec9ae2b24d6312f8e9302ff5b857 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:54:56 -0500 Subject: [PATCH 02/23] feat(simulate): classify composite resources and claims for xr simulate Inputs that are neither Compositions nor project files are composite resource candidates, which checkComposites confirms against the cluster's XRDs. Every rejection uses RejectInput's template, so composition and xr simulate point at each other symmetrically. The runner gains a Composites input that runs PerformDiff and renders through renderXRReport. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/composition/simulate_test.go | 2 +- internal/simulate/annotate.go | 24 +++++++- internal/simulate/inputs.go | 24 ++++++++ internal/simulate/inputs_test.go | 68 +++++++++++++++++++++ internal/simulate/report.go | 48 +++++++++++++-- internal/simulate/run.go | 48 ++++++++++++++- 6 files changed, 204 insertions(+), 10 deletions(-) diff --git a/cmd/crossplane/composition/simulate_test.go b/cmd/crossplane/composition/simulate_test.go index 08732e81..89e6d51a 100644 --- a/cmd/crossplane/composition/simulate_test.go +++ b/cmd/crossplane/composition/simulate_test.go @@ -117,7 +117,7 @@ func TestSimulateRejectsNonComposition(t *testing.T) { } // 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` + want := `-f inputs include XNetwork "net" (example.org/v1); composition simulate takes Composition files; xr simulate takes composite resource or claim files` if err == nil || err.Error() != want { t.Errorf("Run(...): want error %q, got %v", want, err) } diff --git a/internal/simulate/annotate.go b/internal/simulate/annotate.go index 3e768138..3c1c4ea0 100644 --- a/internal/simulate/annotate.go +++ b/internal/simulate/annotate.go @@ -42,9 +42,29 @@ const ( diffHidden = "# diff hidden: it holds sensitive values that can't be redacted" ) +// AnnotateXRReport returns r with each resource diff annotated with its +// provider plan from plans (see ProviderPlans.For and annotateDiff), for the +// -o diff renderer. It never mutates r: the result is a shallow copy with +// cloned groups and diff maps, and each annotated diff is a copy with new +// LineDiffs. A core Secret's data and stringData values are redacted with or +// without plans. Otherwise empty plans return an equivalent copy, which +// renders exactly as r does. +func AnnotateXRReport(r *dt.XRReport, plans *ProviderPlans) *dt.XRReport { + if r == nil { + return nil + } + + out := *r + out.Groups = slices.Clone(r.Groups) + for i := range out.Groups { + out.Groups[i].Diffs, _ = annotateDiffs(out.Groups[i].Diffs, plans) + } + + return &out +} + // AnnotateCompReport returns o with each affected composite's diffs annotated -// with their provider plans (see ProviderPlans.For and annotateDiff), for the -// -o diff renderer. It never mutates o. An unchanged composite +// with their provider plans, as AnnotateXRReport does. An unchanged composite // whose provider plans find drift becomes changed, so the composition // renderer lists and counts it. func AnnotateCompReport(o *dt.CompDiffOutput, plans *ProviderPlans) *dt.CompDiffOutput { diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go index a6260e0e..18481c71 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -17,15 +17,20 @@ limitations under the License. package simulate import ( + "context" "fmt" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" ld "github.com/crossplane/cli/v2/pkg/load" ) +// XRTakes is what xr simulate takes, for RejectInput. +const XRTakes = "xr simulate takes composite resource or claim files" + // RejectInput returns the error for an -f input r that the command doesn't // take. takes describes what the command does take, e.g. "composition // simulate takes Composition files". @@ -71,3 +76,22 @@ func RejectDuplicateNames(comps []*un.Unstructured) error { } return nil } + +// checkComposites rejects resources that no XRD in the cluster defines as a +// composite resource or claim, such as package metadata, a Provider or a +// ConfigMap, which the engine would otherwise fail on with no hint at what -f +// accepts. The definition client caches the XRDs when it initializes, so the +// check costs no extra calls. +func checkComposites(ctx context.Context, defs xpclient.DefinitionClient, resources []*un.Unstructured) error { + for _, r := range resources { + gvk := r.GroupVersionKind() + if xrd, err := defs.GetXRDForClaim(ctx, gvk); err == nil && xrd != nil { + continue + } + if xrd, err := defs.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { + continue + } + return RejectInput(r, XRTakes) + } + return nil +} diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index 837f5f62..dfe41820 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -17,6 +17,8 @@ limitations under the License. package simulate import ( + "context" + "errors" "os" "path/filepath" "strings" @@ -24,8 +26,11 @@ import ( "github.com/google/go-cmp/cmp" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/diff/testutils" ) func TestLoadInputs(t *testing.T) { @@ -211,3 +216,66 @@ spec: }) } } + +func TestCheckComposites(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XThing"} + claimGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Thing"} + xrd := &un.Unstructured{} + lookup := func(want schema.GroupVersionKind) func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { + return func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if gvk == want { + return xrd, nil + } + return nil, errors.New("no XRD found") + } + } + defs := &testutils.MockDefinitionClient{GetXRDForXRFn: lookup(xrGVK), GetXRDForClaimFn: lookup(claimGVK)} + res := func(apiVersion, kind, name string) *un.Unstructured { + return &un.Unstructured{Object: map[string]any{"apiVersion": apiVersion, "kind": kind, "metadata": map[string]any{"name": name}}} + } + + cases := map[string]struct { + reason string + resources []*un.Unstructured + err string + }{ + "XRAndClaim": { + reason: "Resources an XRD defines as a composite resource or claim should be accepted.", + resources: []*un.Unstructured{res("example.org/v1", "XThing", "x"), res("example.org/v1", "Thing", "t")}, + }, + "ConfigurationMetadata": { + reason: "A Configuration package metadata file should be rejected in the shared template, with no other command to point at.", + resources: []*un.Unstructured{res("meta.pkg.crossplane.io/v1", "Configuration", "cfg")}, + err: `-f inputs include Configuration "cfg" (meta.pkg.crossplane.io/v1); xr simulate takes composite resource or claim files`, + }, + "Provider": { + reason: "A Provider should be rejected.", + resources: []*un.Unstructured{res("example.org/v1", "XThing", "x"), res("pkg.crossplane.io/v1", "Provider", "provider-aws")}, + err: `-f inputs include Provider "provider-aws" (pkg.crossplane.io/v1); xr simulate takes composite resource or claim files`, + }, + "ConfigMap": { + reason: "A non-Crossplane kind should be rejected.", + resources: []*un.Unstructured{res("v1", "ConfigMap", "cm")}, + err: `-f inputs include ConfigMap "cm" (v1); xr simulate takes composite resource or claim files`, + }, + "WrongVersion": { + reason: "A composite resource at a version its XRD doesn't define should be rejected.", + resources: []*un.Unstructured{res("example.org/v2", "XThing", "x")}, + err: `-f inputs include XThing "x" (example.org/v2); xr simulate takes composite resource or claim files`, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := checkComposites(context.Background(), defs, tc.resources) + if tc.err == "" { + if err != nil { + t.Fatalf("\n%s\ncheckComposites(...): %v", tc.reason, err) + } + return + } + if err == nil || err.Error() != tc.err { + t.Fatalf("\n%s\ncheckComposites(...): want error %q, got %v", tc.reason, tc.err, err) + } + }) + } +} diff --git a/internal/simulate/report.go b/internal/simulate/report.go index 3edbd084..5ea4cba8 100644 --- a/internal/simulate/report.go +++ b/internal/simulate/report.go @@ -44,14 +44,52 @@ const ( NoteNoDiffServers = "Provider plan not run: no provider diff server planned these resources." ) +// FromXRReport maps an xr or project diff run onto a ProjectResult: one plan per +// resource diff (the XR's own included), each grouped under its input XR or +// claim, and one Composites entry per input. plans are the provider plans +// the run collected, each merged into the engine's plan of a diff that +// renders its resource as the planned one did (see ProviderPlans.For); nil +// means none ran. keep is the filter PlanDiffs used: a kept equal diff no +// provider planned is dropped, as one whose provider plan is a no-op is. nil +// keeps none. +func FromXRReport(r *dt.XRReport, plans *ProviderPlans, keep func(*unstructured.Unstructured) bool) ProjectResult { + if r == nil { + r = &dt.XRReport{} + } + + a := newAssembler(plans, keep) + owned := make([]dt.OutputError, 0, len(r.Groups)) + + for _, g := range r.Groups { + // The group's XR is the input's identity, so a claim input keeps the + // claim's kind and name. + xr := resourceRef(g.XR) + c := CompositeResult{Ref: xr, Status: CompositeUnchanged} + + switch { + case g.Err != nil: + c.Status = CompositeError + a.addError(xr, *g.Err, &xr) + owned = append(owned, *g.Err) + case g.HasChanges(): + c.Status = CompositeChanged + } + + if a.addDiffs(g.Diffs, &xr) && c.Status == CompositeUnchanged { + c.Status = CompositeChanged + } + a.composites = append(a.composites, c) + } + + a.addRunErrors(r.Errors, owned) + + return a.result(slices.Concat(r.Warnings, dt.XRDryRunWarnings(r.Groups))) +} + // FromCompReport maps a composition diff run onto a ProjectResult: a plan and a // Compositions entry per Composition, and a Composites entry per affected // composite, with that composite's downstream plans grouped under it. plans -// is the provider plans the run collected (nil means none ran), merged into the -// engine's plan of a diff that renders its resource as the planned one did (see -// ProviderPlans.For). keep is the filter PlanDiffs used: a kept equal diff no -// provider planned is dropped, as one whose provider plan is a no-op is. nil -// keeps none. +// and keep are used as FromXRReport uses them. func FromCompReport(o *dt.CompDiffOutput, plans *ProviderPlans, keep func(*unstructured.Unstructured) bool) ProjectResult { if o == nil { o = &dt.CompDiffOutput{} diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 1e396ab5..9278a081 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -229,6 +229,29 @@ func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp } } +// diffComposites returns the diff simulate runs for composite resources and +// claims: each rendered with the cluster's Compositions and functions, as in +// crossplane-diff's xr command. A claim is simulated as its XR, but the +// report names the input, so its result is listed under the claim's name +// (crossplane/cli#367). +func (c *Flags) diffComposites(k *kong.Context, logger logging.Logger, proc dp.DiffProcessor, xp *xpclient.Clients, xrs []*un.Unstructured) func(context.Context) (bool, error) { + return func(ctx context.Context) (bool, error) { + if err := checkComposites(ctx, xp.Definition, xrs); err != nil { + return false, err + } + report, err := proc.PerformDiff(ctx, xrs, xp.Composition.FindMatchingComposition) + + // Render the report even when there are errors, so successful XRs + // are shown and structured output stays valid. The renderer + // includes the errors in structured output. + changed := report != nil && report.HasChanges() + if report != nil { + changed, err = c.renderXRReport(k, logger, report, err) + } + return changed, errors.Wrap(err, "cannot simulate composite resources") + } +} + // setFlags returns the names of the flags set on the command line, not // those left at their defaults or set from the environment. func setFlags(k *kong.Context) map[string]bool { @@ -431,6 +454,17 @@ func (c *Flags) notes() []string { return planNotes(c.SkipPlan, true, c.xrPlanner.Routed(), c.xrPlanner.Considered()) } +// renderXRReport writes an xr run's report in the format -o selects, as +// renderReport does. +func (c *Flags) renderXRReport(k *kong.Context, logger logging.Logger, r *dt.XRReport, runErr error) (bool, error) { + plans := c.providerPlans() + return c.renderReport(k, logger, runErr, FromXRReport(r, plans, c.keep), r.HasChanges(), dt.XRDryRunWarnings(r.Groups), func(opts renderer.DiffOptions) error { + // Annotated even without plans, so a Secret's values are redacted. + r = AnnotateXRReport(r, plans) + return renderer.NewDiffRenderer(logger, opts).RenderDiffs(r.Groups, r.Errors, r.Warnings) + }) +} + // renderCompReport writes a composition run's report in the format -o // selects, as renderReport does. func (c *Flags) renderCompReport(k *kong.Context, logger logging.Logger, o *dt.CompDiffOutput, runErr error) (bool, error) { @@ -518,7 +552,8 @@ func isTerminal(w io.Writer) bool { } // Run previews in against the cluster: a project's Compositions, with its -// functions built locally, or Composition files, with the cluster's +// functions built locally, Composition files, with the cluster's functions, +// or composite resource or claim files, with the cluster's Compositions and // functions. func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter, in *Inputs) error { if in.Selection != nil { @@ -597,6 +632,9 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg opts = append(opts, planOpts...) } xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) + if in.Composites != nil { + return c.simulate(sigCtx, logger, clients.Initialize, xrProc, c.diffComposites(k, logger, xrProc, &clients.XpClients, in.Composites)) + } proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, msg)) } @@ -643,13 +681,19 @@ type Built struct { type Inputs struct { // Build builds the project. Run calls it after loading the kubeconfig, // so a bad context fails before the slow build. - // Build is nil for a command that takes Compositions instead. + // Build is nil for a command that takes Compositions or composite + // resources instead. Build func(ctx context.Context) (*Built, error) // Compositions are the Compositions to simulate, with the cluster's // functions, when Build is nil. Compositions []*un.Unstructured + // Composites are the composite resources or claims to simulate, with the + // cluster's Compositions and functions, when Build is nil. When set, + // Compositions is unused. + Composites []*un.Unstructured + // Selection limits the run to the composite resources it selects. A // command without CompositionFlags leaves it nil, which selects them all. Selection *CompositionFlags From c4e0dc0ae43cc4217896e1d46d38d49392dd8065 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 10:56:14 -0500 Subject: [PATCH 03/23] feat(xr): add xr simulate xr simulate previews composite resource or claim files against the cluster with its Compositions and functions, through the shared simulate runner. It takes no selection flags or --max-concurrency. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/xr/help/simulate.md | 23 ++++ cmd/crossplane/xr/help/simulate_examples.md | 26 ++++ cmd/crossplane/xr/simulate.go | 59 +++++++++ cmd/crossplane/xr/simulate_test.go | 129 ++++++++++++++++++++ cmd/crossplane/xr/xr.go | 1 + 5 files changed, 238 insertions(+) create mode 100644 cmd/crossplane/xr/help/simulate.md create mode 100644 cmd/crossplane/xr/help/simulate_examples.md create mode 100644 cmd/crossplane/xr/simulate.go create mode 100644 cmd/crossplane/xr/simulate_test.go diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md new file mode 100644 index 00000000..a83a1631 --- /dev/null +++ b/cmd/crossplane/xr/help/simulate.md @@ -0,0 +1,23 @@ +The `xr simulate` command previews how applying changed composite resources or +claims would change a cluster. It renders each one with the Composition the +cluster selects for it and the functions installed in the cluster, and shows +the difference from what's in the cluster. It doesn't change the cluster. + +`-f` (`--input`) names a file of composite resources or claims to simulate, and +is required. Repeat it for more files; `-f -` reads standard input. Every +resource in the files must be a kind an XRD in the cluster defines as a +composite resource or claim; to simulate a Composition, use +`crossplane composition simulate`, and to simulate a project, use +`crossplane project simulate`. A claim is simulated as its composite resource, +and its result is listed under the claim's name. +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/xr/help/simulate_examples.md b/cmd/crossplane/xr/help/simulate_examples.md new file mode 100644 index 00000000..b31b2776 --- /dev/null +++ b/cmd/crossplane/xr/help/simulate_examples.md @@ -0,0 +1,26 @@ + +## Examples + +Simulate a changed composite resource against the current context: + +```shell +crossplane xr simulate -f xr.yaml +``` + +Simulate a composite resource and a claim: + +```shell +crossplane xr simulate -f network.yaml -f database-claim.yaml +``` + +Simulate a composite resource read from standard input: + +```shell +cat xr.yaml | crossplane xr simulate -f - +``` + +Print the plan as JSON, without provider plans: + +```shell +crossplane xr simulate -f xr.yaml -o json --skip-plan +``` diff --git a/cmd/crossplane/xr/simulate.go b/cmd/crossplane/xr/simulate.go new file mode 100644 index 00000000..bb373901 --- /dev/null +++ b/cmd/crossplane/xr/simulate.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xr + +import ( + "context" + + "github.com/alecthomas/kong" + + "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 composite resources or claims would change +// a cluster. +type simulateCmd struct { + simulate.Flags `embed:""` + + Input []string `help:"Composite resource or claim 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(false) + simulateExamples +} + +// Run previews the composite resources or claims -f names against the +// cluster, with the cluster's Compositions and functions. +func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter) error { + xrs, err := simulate.LoadInputs(c.Input, simulate.ClassComposite) + if err != nil { + return err + } + return c.Flags.Run(sigCtx, k, logger, sp, &simulate.Inputs{Composites: xrs}) +} diff --git a/cmd/crossplane/xr/simulate_test.go b/cmd/crossplane/xr/simulate_test.go new file mode 100644 index 00000000..254b576d --- /dev/null +++ b/cmd/crossplane/xr/simulate_test.go @@ -0,0 +1,129 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xr + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/simulate" +) + +// parseSimulate parses args as `simulate` flags, running kong's validation. +func parseSimulate(t *testing.T, args ...string) (*simulateCmd, error) { + t.Helper() + + var cli struct { + Simulate simulateCmd `cmd:""` + } + parser, err := kong.New(&cli, kong.Exit(func(int) {})) + if err != nil { + t.Fatalf("kong.New(...): %v", err) + } + _, err = parser.Parse(append([]string{"simulate"}, args...)) + return &cli.Simulate, err +} + +func TestSimulateFlags(t *testing.T) { + cases := map[string]struct { + reason string + args []string + input []string + err string + }{ + "Defaults": { + reason: "With only -f the shared defaults apply.", + args: []string{"-f", "xr.yaml"}, + input: []string{"xr.yaml"}, + }, + "RepeatedInput": { + reason: "-f repeats, 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", + }, + "NoSelectionFlags": { + reason: "Selection flags are for Composition changes; xr simulate doesn't define them.", + args: []string{"-f", "xr.yaml", "--name=x"}, + err: "unknown flag --name", + }, + "NoNamespace": { + reason: "--namespace is a selection flag too.", + args: []string{"-f", "xr.yaml", "-n", "team-a"}, + err: "unknown flag", + }, + "NoMaxConcurrency": { + reason: "--max-concurrency is project-only.", + args: []string{"-f", "xr.yaml", "--max-concurrency=2"}, + err: "max-concurrency", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c, err := parseSimulate(t, tc.args...) + if tc.err != "" { + if err == nil || !strings.Contains(err.Error(), tc.err) { + t.Fatalf("\n%s\nParse(%v): want error containing %q, got %v", tc.reason, tc.args, tc.err, err) + } + return + } + if err != nil { + t.Fatalf("\n%s\nParse(%v): %v", tc.reason, tc.args, err) + } + if diff := cmp.Diff(tc.input, c.Input); diff != "" { + t.Errorf("\n%s\nInput: -want, +got:\n%s", tc.reason, diff) + } + if c.Output != "plan" || c.Timeout != 2*time.Minute { + t.Errorf("\n%s\nOutput, Timeout: want plan, 2m, got %q, %v", tc.reason, c.Output, c.Timeout) + } + }) + } +} + +func TestSimulateHelp(t *testing.T) { + h := (&simulateCmd{}).Help() + for _, want := range []string{"xr simulate", simulate.Help(false), "## Examples"} { + if !strings.Contains(h, want) { + t.Errorf("Help(): want it to contain %q", want) + } + } +} + +func TestSimulateRejectsComposition(t *testing.T) { + path := filepath.Join(t.TempDir(), "comp.yaml") + if err := os.WriteFile(path, []byte("apiVersion: apiextensions.crossplane.io/v1\nkind: Composition\nmetadata:\n name: net\n"), 0o600); err != nil { + t.Fatal(err) + } + // Run loads -f before it uses k or Docker. + err := (&simulateCmd{Input: []string{path}}).Run(context.Background(), nil, logging.NewNopLogger(), nil) + want := `-f inputs include Composition "net" (apiextensions.crossplane.io/v1); xr simulate takes composite resource or claim files; composition simulate takes Composition files` + if err == nil || err.Error() != want { + t.Errorf("Run(...): want error %q, got %v", want, err) + } +} diff --git a/cmd/crossplane/xr/xr.go b/cmd/crossplane/xr/xr.go index 03786b5b..65b8e27b 100644 --- a/cmd/crossplane/xr/xr.go +++ b/cmd/crossplane/xr/xr.go @@ -21,4 +21,5 @@ package xr type Cmd struct { Generate generateCmd `cmd:"" help:"Generate a Composite Resource (XR) from a Claim."` Patch patchCmd `cmd:"" help:"Patch a Composite Resource (XR)."` + Simulate simulateCmd `cmd:"" help:"Preview what applying changed Composite Resources (XRs) or claims would do." maturity:"alpha"` } From c3c77f322601befd20f7bc31c5fb0f4e6386c993 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:06:51 -0500 Subject: [PATCH 04/23] test(xr): restore the xr simulate integration tests Restore TestDiffIntegration, TestSimulateProviderPlan, the XR plan-output cases of TestSimulatePlanOutput and the remaining testdata. The harness now runs XRDiffTest cases as `xr simulate` with -f per file, and the TestCompDiffIntegration comments that point at TestDiffIntegration are restored. Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- .../project/integration_plan_output_test.go | 95 +- .../project/integration_project_test.go | 25 - .../project/integration_provider_plan_test.go | 304 ++ .../integration_simulate_harness_test.go | 31 +- cmd/crossplane/project/integration_test.go | 3147 ++++++++++++++++- cmd/crossplane/project/testdata/README.md | 56 +- ...clusternopresources.nop.crossplane.io.yaml | 61 + .../diff/crds/xapimigrateresource-crd.yaml | 84 + .../xdefaulteddownstreamresource-ns-crd.yaml | 32 + .../crds/xdownstreamresource-cluster-crd.yaml | 56 + ...xdownstreamresource-legacycluster-crd.yaml | 56 + .../diff/crds/xdownstreamresource-ns-crd.yaml | 59 + .../diff/crds/xstatusresource-ns-crd.yaml | 66 + .../xversioneddownstreamresource-ns-crd.yaml | 52 + .../existing-xr-revision-ref-omitted.yaml | 16 + .../diff/first-xr-with-composition-ref.yaml | 11 + .../project/testdata/diff/first-xr.yaml | 7 + .../testdata/diff/generated-name-xr.yaml | 7 + .../testdata/diff/invalid-schema-xr.yaml | 8 + ...ied-api-version-xr-rev2-changed-field.yaml | 13 + .../diff/modified-api-version-xr-rev2.yaml | 13 + .../testdata/diff/modified-claim-nested.yaml | 12 + ...ified-claim-omitting-defaulted-fields.yaml | 11 + .../project/testdata/diff/modified-claim.yaml | 10 + .../testdata/diff/modified-cluster-xr.yaml | 6 + .../testdata/diff/modified-cross-ns-xr.yaml | 8 + .../testdata/diff/modified-env-xr.yaml | 7 + ...dified-legacy-xr-manual-upgrade-to-v2.yaml | 11 + ...egacy-xr-manual-v1-omits-revision-ref.yaml | 12 + .../testdata/diff/modified-legacy-xr.yaml | 7 + .../diff/modified-manual-pinned-claim.yaml | 12 + .../testdata/diff/modified-nested-xr.yaml | 8 + .../testdata/diff/modified-xr-automatic.yaml | 11 + .../modified-xr-manual-upgrade-to-v2.yaml | 13 + ...ified-xr-manual-v1-omits-revision-ref.yaml | 16 + .../testdata/diff/modified-xr-manual-v1.yaml | 13 + .../diff/modified-xr-selector-bound.yaml | 13 + .../diff/modified-xr-selector-nomatch.yaml | 14 + .../diff/modified-xr-selector-stable.yaml | 14 + .../diff/modified-xr-switch-to-automatic.yaml | 11 + .../diff/modified-xr-with-external-dep.yaml | 8 + .../project/testdata/diff/modified-xr.yaml | 7 + .../new-claim-with-claimref-composition.yaml | 10 + .../project/testdata/diff/new-claim.yaml | 11 + .../project/testdata/diff/new-cycle-xr.yaml | 8 + .../testdata/diff/new-manual-default-xr.yaml | 15 + .../testdata/diff/new-nested-child-xr.yaml | 9 + .../project/testdata/diff/new-nested-xr.yaml | 8 + ...arent-claim-omitting-defaulted-fields.yaml | 11 + .../diff/new-rendered-defaults-xrs.yaml | 16 + .../testdata/diff/new-xr-manual-no-ref.yaml | 11 + .../diff/new-xr-missing-namespace-second.yaml | 10 + .../diff/new-xr-missing-namespace.yaml | 13 + .../project/testdata/diff/new-xr-second.yaml | 7 + .../testdata/diff/new-xr-with-creds.yaml | 10 + .../new-xr-with-cross-ns-external-dep.yaml | 11 + .../project/testdata/diff/new-xr.yaml | 7 + .../resources/admission-policy-canary.yaml | 15 + .../api-version-composition-revision-v1.yaml | 34 + .../resources/api-version-composition.yaml | 34 + .../claim-composition-with-claimref.yaml | 35 + .../diff/resources/claim-composition.yaml | 31 + .../claim-nested/child-composition.yaml | 34 + .../claim-nested/child-definition.yaml | 29 + .../claim-nested/existing-child-xr.yaml | 11 + .../claim-nested/existing-claim.yaml | 18 + .../existing-managed-resource.yaml | 14 + .../claim-nested/existing-parent-xr.yaml | 24 + .../claim-nested/parent-composition.yaml | 31 + .../parent-defaults-composition.yaml | 35 + .../claim-nested/parent-definition.yaml | 35 + .../testdata/diff/resources/claim-xrd.yaml | 30 + .../composition-multi-functions.yaml | 80 + .../composition-no-namespace-patch.yaml | 32 + .../composition-revision-preview.yaml | 38 + .../composition-revision-stable.yaml | 38 + .../resources/composition-revision-v1.yaml | 34 + .../diff/resources/composition-v2.yaml | 32 + .../composition-with-builtin-defaults.yaml | 61 + .../resources/composition-with-configmap.yaml | 48 + ...osition-with-crd-defaulted-downstream.yaml | 33 + .../resources/composition-with-defaults.yaml | 16 + .../composition-with-missing-function.yaml | 17 + ...n-with-non-storage-version-downstream.yaml | 33 + .../resources/composition-with-status.yaml | 41 + .../composition-with-unserved-k8s-group.yaml | 42 + .../diff/resources/conditional-gating-xr.yaml | 7 + .../credentials/cli-credentials.yaml | 9 + .../credentials/composition-with-creds.yaml | 39 + .../function-credentials-secret.yaml | 14 + .../resources/cross-namespace-configmap.yaml | 21 + .../cross-namespace-fn-composition.yaml | 72 + .../diff/resources/default-composition.yaml | 36 + .../diff/resources/env-composition.yaml | 46 + .../testdata/diff/resources/env-xrd.yaml | 22 + .../resources/environment-config-v1beta1.yaml | 8 + ...isting-api-version-downstream-v1beta1.yaml | 10 + .../existing-api-version-xr-rev1.yaml | 13 + ...-claim-defaulted-fields-other-manager.yaml | 10 + .../existing-claim-downstream-resource.yaml | 9 + .../diff/resources/existing-claim-xr.yaml | 21 + .../diff/resources/existing-claim.yaml | 18 + .../diff/resources/existing-cluster-xr.yaml | 10 + .../existing-cross-ns-downstream.yaml | 10 + .../diff/resources/existing-cross-ns-xr.yaml | 11 + ...xisting-defaulted-downstream-composed.yaml | 14 + ...ng-defaulted-downstream-other-manager.yaml | 10 + .../existing-downstream-automatic.yaml | 10 + .../existing-downstream-manual-v1.yaml | 10 + ...sting-downstream-resource-with-argocd.yaml | 15 + .../existing-downstream-resource.yaml | 12 + .../existing-downstream-selector-stable.yaml | 10 + ...existing-downstream-with-external-dep.yaml | 11 + ...isting-downstream-with-generated-name.yaml | 13 + .../existing-env-downstream-resource.yaml | 16 + .../diff/resources/existing-env-xr.yaml | 9 + .../resources/existing-generate-name-xr.yaml | 13 + .../existing-legacy-downstream-manual-v1.yaml | 9 + .../existing-legacy-xr-manual-v1.yaml | 11 + .../diff/resources/existing-legacy-xr.yaml | 9 + .../diff/resources/existing-namespace.yaml | 4 + .../resources/existing-removal-test-xr.yaml | 13 + ...xisting-sequencer-gating-stage0-ready.yaml | 19 + .../existing-sequencer-gating-stage0.yaml | 19 + ...xisting-sequencer-gating-stage1-ready.yaml | 18 + .../existing-sequencer-gating-xr.yaml | 13 + .../diff/resources/existing-sequencer-xr.yaml | 12 + .../existing-v2xrd-v1paths-downstream.yaml | 10 + .../resources/existing-v2xrd-v1paths-xr.yaml | 14 + .../diff/resources/existing-xr-automatic.yaml | 13 + .../diff/resources/existing-xr-deleting.yaml | 14 + ...ing-xr-listing-uncontrolled-resources.yaml | 23 + .../diff/resources/existing-xr-manual-v1.yaml | 13 + .../existing-xr-selector-stable.yaml | 16 + .../resources/existing-xr-with-argocd.yaml | 19 + ...isting-xr-with-defaults-other-manager.yaml | 9 + .../existing-xr-with-defaults-reconciled.yaml | 16 + ...-xr-with-external-dep-multistep-fatal.yaml | 14 + ...isting-xr-with-external-dep-templated.yaml | 13 + ...isting-xr-with-external-dep-unguarded.yaml | 14 + .../existing-xr-with-external-dep.yaml | 11 + .../testdata/diff/resources/existing-xr.yaml | 13 + .../resources/external-named-clusterrole.yaml | 8 + .../external-res-fn-composition.yaml | 64 + .../external-res-gotpl-composition.yaml | 46 + ...ernal-res-matchname-gotpl-composition.yaml | 46 + ...al-resource-configmap-other-namespace.yaml | 22 + .../external-resource-configmap.yaml | 21 + .../resources/generated-name-composition.yaml | 32 + .../legacy-composition-revision-v1.yaml | 33 + .../diff/resources/legacy-composition-v2.yaml | 31 + .../testdata/diff/resources/legacy-xrd.yaml | 49 + .../resources/manual-default/definitions.yaml | 96 + .../resources/modified-v2xrd-v1paths-xr.yaml | 13 + ...tep-fatal-after-extra-res-composition.yaml | 65 + .../resources/nested/child-composition.yaml | 33 + ...child-revision-templating-composition.yaml | 38 + .../diff/resources/nested/child-xrd.yaml | 55 + .../existing-child-xr-revision-ref.yaml | 18 + .../existing-child-xr-tier-other-manager.yaml | 9 + .../resources/nested/existing-child-xr.yaml | 13 + ...xisting-managed-resource-revision-ref.yaml | 15 + .../nested/existing-managed-resource.yaml | 11 + .../resources/nested/existing-parent-xr.yaml | 11 + .../resources/nested/parent-composition.yaml | 32 + .../diff/resources/nested/parent-xrd.yaml | 50 + .../resources/pinned-claim/definitions.yaml | 96 + .../existing-automatic-downstream.yaml | 10 + .../pinned-claim/existing-automatic-xr.yaml | 15 + .../pinned-claim/existing-automatic.yaml | 19 + ...existing-lagging-automatic-downstream.yaml | 10 + .../existing-lagging-automatic-xr.yaml | 16 + .../existing-manual-downstream.yaml | 10 + .../pinned-claim/existing-manual-xr.yaml | 17 + .../pinned-claim/existing-manual.yaml | 19 + .../resources/production-composition.yaml | 36 + .../resources/rejecting-admission-policy.yaml | 38 + .../removal-test-cluster-composition.yaml | 31 + ...val-test-cluster-downstream-resource1.yaml | 9 + ...removal-test-legacy-child-composition.yaml | 31 + .../removal-test-legacy-child-xrd.yaml | 24 + .../removal-test-legacy-composition.yaml | 31 + ...removal-test-legacy-nested-xr-managed.yaml | 11 + .../removal-test-legacy-nested-xr.yaml | 13 + ...st-legacycluster-downstream-resource1.yaml | 9 + .../removal-test-nested-xr-managed.yaml | 11 + .../resources/removal-test-nested-xr.yaml | 15 + .../rendered-defaults/definitions.yaml | 129 + .../selector-bound/existing-downstream.yaml | 11 + .../resources/selector-bound/existing-xr.yaml | 16 + .../diff/resources/selector-bound/setup.yaml | 74 + .../diff/resources/sequencer-composition.yaml | 53 + .../sequencer-stage0-downstream.yaml | 10 + .../sequencer-stage1-downstream.yaml | 10 + .../sequencer-with-envconfig-composition.yaml | 67 + .../sequencer-with-envconfig-xr.yaml | 10 + .../testdata/diff/resources/sequencer-xr.yaml | 7 + .../diff/resources/staging-composition.yaml | 36 + .../diff/resources/unestablished-xrd.yaml | 25 + ...uarded-external-res-gotpl-composition.yaml | 44 + .../v2-xrd-with-v1-paths-composition.yaml | 32 + .../diff/resources/v2-xrd-with-v1-paths.yaml | 101 + .../resources/xdownstreamenvresource-xrd.yaml | 31 + .../diff/resources/xrd-concurrent.yaml | 30 + .../diff/resources/xrd-with-defaults.yaml | 79 + .../unchanged-automatic-pinned-claim.yaml | 12 + .../diff/unchanged-lagging-automatic-xr.yaml | 11 + .../project/testdata/diff/unmodified-xr.yaml | 7 + .../diff/xr-with-ambiguous-selector.yaml | 11 + .../diff/xr-with-argocd-annotations.yaml | 13 + .../diff/xr-with-argocd-mixed-changes.yaml | 13 + .../diff/xr-with-composition-ref.yaml | 10 + .../diff/xr-with-composition-selector.yaml | 12 + .../diff/xr-with-missing-defaults.yaml | 9 + .../diff/xr-with-overridden-defaults.yaml | 19 + internal/simulate/simtest/plan_assertions.go | 301 +- .../simulate/simtest/structured_assertions.go | 447 ++- .../simtest/structured_assertions_test.go | 250 +- 218 files changed, 9436 insertions(+), 129 deletions(-) create mode 100644 cmd/crossplane/project/integration_provider_plan_test.go create mode 100644 cmd/crossplane/project/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xapimigrateresource-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-ns-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xstatusresource-ns-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/existing-xr-revision-ref-omitted.yaml create mode 100644 cmd/crossplane/project/testdata/diff/first-xr-with-composition-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/first-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/generated-name-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/invalid-schema-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-claim-nested.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-claim-omitting-defaulted-fields.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-claim.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-cluster-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-cross-ns-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-env-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-legacy-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-manual-pinned-claim.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-nested-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-automatic.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-manual-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-selector-bound.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-selector-nomatch.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-selector-stable.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-switch-to-automatic.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr-with-external-dep.yaml create mode 100644 cmd/crossplane/project/testdata/diff/modified-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-claim-with-claimref-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-claim.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-cycle-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-manual-default-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-nested-child-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-nested-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-rendered-defaults-xrs.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr-manual-no-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr-missing-namespace-second.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr-missing-namespace.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr-second.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr-with-creds.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr-with-cross-ns-external-dep.yaml create mode 100644 cmd/crossplane/project/testdata/diff/new-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/admission-policy-canary.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/api-version-composition-revision-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/api-version-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-composition-with-claimref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/child-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/child-definition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-child-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-claim.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-managed-resource.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-parent-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-definition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/claim-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-no-namespace-patch.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-revision-preview.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-revision-stable.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-revision-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-v2.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-builtin-defaults.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-configmap.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-defaults.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-missing-function.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-status.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-with-unserved-k8s-group.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/conditional-gating-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/credentials/cli-credentials.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/credentials/composition-with-creds.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/credentials/function-credentials-secret.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/cross-namespace-configmap.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/cross-namespace-fn-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/default-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/env-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/env-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/environment-config-v1beta1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-api-version-xr-rev1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-claim-downstream-resource.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-claim-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-claim.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-cluster-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-composed.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-automatic.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-manual-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-selector-stable.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-external-dep.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-generated-name.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-env-downstream-resource.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-env-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-generate-name-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-namespace.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-removal-test-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-sequencer-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-automatic.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-deleting.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-manual-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-selector-stable.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-argocd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/existing-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/external-named-clusterrole.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/external-res-fn-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/external-res-gotpl-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/external-resource-configmap-other-namespace.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/external-resource-configmap.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/generated-name-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/legacy-composition-revision-v1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/legacy-composition-v2.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/legacy-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/manual-default/definitions.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/child-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/child-revision-templating-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/child-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/existing-parent-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/parent-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/nested/parent-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/definitions.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/production-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/rejecting-admission-policy.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr-managed.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/rendered-defaults/definitions.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/selector-bound/setup.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-stage0-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-stage1-downstream.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/sequencer-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/staging-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/unestablished-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/xdownstreamenvresource-xrd.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml create mode 100644 cmd/crossplane/project/testdata/diff/resources/xrd-with-defaults.yaml create mode 100644 cmd/crossplane/project/testdata/diff/unchanged-automatic-pinned-claim.yaml create mode 100644 cmd/crossplane/project/testdata/diff/unchanged-lagging-automatic-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/unmodified-xr.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-ambiguous-selector.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-argocd-annotations.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-argocd-mixed-changes.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-composition-ref.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-composition-selector.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-missing-defaults.yaml create mode 100644 cmd/crossplane/project/testdata/diff/xr-with-overridden-defaults.yaml diff --git a/cmd/crossplane/project/integration_plan_output_test.go b/cmd/crossplane/project/integration_plan_output_test.go index 38ca236e..549416e2 100644 --- a/cmd/crossplane/project/integration_plan_output_test.go +++ b/cmd/crossplane/project/integration_plan_output_test.go @@ -36,12 +36,24 @@ import ( "github.com/crossplane/cli/v2/internal/testutils/integration" ) -// TestSimulatePlanOutput runs project simulate's -o json simulate.ProjectResult -// against the plain project fixture, which the diff cases cannot -// express. Each case passes simulate args directly. +// TestSimulatePlanOutput runs simulate's own output formats, -o plan and +// -o json's simulate.ProjectResult, for xr, composition and the plain project +// fixture, which crossplane-diff's cases cannot express. Each case passes +// simulate args directly rather than crossplane-diff's. func TestSimulatePlanOutput(t *testing.T) { t.Parallel() + xrCase := integration.Setup{ + CRDDirs: []string{"testdata/diff/crds"}, + SetupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("testdata/diff/resources/existing-xr.yaml", "testdata/diff/resources/existing-downstream-resource.yaml"), + }, + } compCase := integration.Setup{ CRDDirs: []string{"testdata/comp/crds"}, SetupFiles: []string{ @@ -60,19 +72,79 @@ func TestSimulatePlanOutput(t *testing.T) { another.Name = "another-resource" comp := plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "xnopresources.diff.example.org"} + xrArgs := []string{"-f", "testdata/diff/modified-xr.yaml"} compArgs := []string{"-f", "testdata/comp/updated-composition.yaml", "--namespace=default"} cases := map[string]struct { reason string - // args follow the plain project's -f, or with compArgv set, `composition simulate`. + // args follow the plain project's -f, or `composition simulate`'s with compArgv, or `xr simulate`'s with tt. args []string // compArgv runs `composition simulate` with args instead. compArgv bool + // tt, when set, runs `xr simulate` with args against its cluster + // instead of compCase's. + tt *integration.Setup // wantText is the -o plan output; empty for a json case. wantText string // wantResult is the -o json output, reduced by summarize. wantResult *planShape }{ + "XRPlanText": { + reason: "-o plan, the default, prints an XR's updates grouped under it, then the summary and notes.", + tt: &xrCase, + args: xrArgs, + wantText: `XR: XNopResource.ns.diff.example.org default/test-resource + [~] XDownstreamResource.ns.nop.example.org default/test-resource (update) + spec: + forProvider: + ~ configData: "existing-value" -> "modified-value" + + [~] XNopResource.ns.diff.example.org default/test-resource (update) + spec: + ~ coolField: "existing-value" -> "modified-value" + +Plan: 0 to add, 2 to change, 0 to destroy. + +Note: Provider plan not run: no provider diff server planned these resources. +`, + }, + "XRCreatePlanText": { + reason: "-o plan prints a dotted annotation or label key, such as crossplane.io/composite, whole as one level.", + tt: &integration.Setup{CRDDirs: xrCase.CRDDirs, SetupFiles: xrCase.SetupFiles}, + args: []string{"-f", "testdata/diff/new-xr.yaml"}, + wantText: `XR: XNopResource.ns.diff.example.org default/test-resource + [+] XDownstreamResource.ns.nop.example.org default/test-resource (create) + metadata: + annotations: + + crossplane.io/composition-resource-name: "nop-resource" + labels: + + crossplane.io/composite: "test-resource" + spec: + forProvider: + + configData: "new-value" + + [+] XNopResource.ns.diff.example.org default/test-resource (create) + spec: + + coolField: "new-value" + +Plan: 2 to add, 0 to change, 0 to destroy. + +Note: Provider plan not run: no provider diff server planned these resources. +`, + }, + "XRPlanJSON": { + reason: "-o json emits simulate.ProjectResult: the XR as a changed composite, with both its plans grouped under it.", + tt: &xrCase, + args: append([]string{"-o", "json"}, xrArgs...), + wantResult: &planShape{ + Summary: plan.Summary{Update: 2}, + Composites: []simulate.CompositeResult{{Ref: xnop, Status: simulate.CompositeChanged}}, + Plans: []planEntry{ + {Kind: "XDownstreamResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + {Kind: "XNopResource", Name: "test-resource", Action: plan.ActionUpdate, Composite: "test-resource"}, + }, + }, + }, "CompPlanText": { reason: "-o plan prints the Composition's plan with its revision impact, then each affected XR's plans under it.", compArgv: true, @@ -170,14 +242,21 @@ Note: Provider plan not run: no provider diff server planned these resources. t.Skip(reason) } - cfg := integration.Start(t, compCase).Config + setup := compCase + if tc.tt != nil { + setup = *tc.tt + } + cfg := integration.Start(t, setup).Config var stdout, stderr bytes.Buffer argv := append(plainProjectArgv(t), tc.args...) - if tc.compArgv { + switch { + case tc.compArgv: argv = append([]string{"composition", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) + case tc.tt != nil: + argv = append([]string{"xr", "simulate", "--timeout=" + defaultTimeout.String()}, tc.args...) } - code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) + code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), nil, &stdout, &stderr) if err != nil { t.Fatalf("\n%s\nsimulate: unexpected error: %v\nstderr:\n%s", tc.reason, err, stderr.String()) } @@ -428,7 +507,7 @@ func TestSimulateNameRejected(t *testing.T) { var stdout, stderr bytes.Buffer argv := append(plainProjectArgv(t), tc.args...) - code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), &stdout, &stderr) + code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), nil, &stdout, &stderr) if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Fatalf("\n%s\nsimulate: want error containing %q, got %v (exit %d)", tc.reason, tc.wantErr, err, code) } diff --git a/cmd/crossplane/project/integration_project_test.go b/cmd/crossplane/project/integration_project_test.go index ff904f86..eaff9f2c 100644 --- a/cmd/crossplane/project/integration_project_test.go +++ b/cmd/crossplane/project/integration_project_test.go @@ -20,7 +20,6 @@ package project import ( "bytes" - "context" "encoding/json" "os" "path/filepath" @@ -28,9 +27,6 @@ import ( diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" "github.com/google/go-cmp/cmp" - "google.golang.org/grpc" - "google.golang.org/protobuf/types/known/structpb" - "k8s.io/apimachinery/pkg/runtime/schema" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/plan" @@ -38,27 +34,6 @@ import ( "github.com/crossplane/cli/v2/internal/testutils/integration" ) -// fakeProviderRouter routes the composed XDownstreamResource kind, and only -// it, to a fake diff server that answers with fn. -type fakeProviderRouter struct { - fn func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) -} - -func (f fakeProviderRouter) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { - if gk.Kind != "XDownstreamResource" { - return nil, false - } - return f, true -} - -func (f fakeProviderRouter) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { - return f.fn(in) -} - -func strValue(s string) *diffv1alpha1.FieldValue { - return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Value{Value: structpb.NewStringValue(s)}} -} - // projectTestCase is a `project simulate -f ` run against a // fixture project, exercising how a project varies rather than the XR and // Composition logic the other tables cover. diff --git a/cmd/crossplane/project/integration_provider_plan_test.go b/cmd/crossplane/project/integration_provider_plan_test.go new file mode 100644 index 00000000..c4a66d40 --- /dev/null +++ b/cmd/crossplane/project/integration_provider_plan_test.go @@ -0,0 +1,304 @@ +//go:build integration + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + diffv1alpha1 "github.com/crossplane/upjet/v2/proto/diff/v1alpha1" + "google.golang.org/genproto/googleapis/rpc/errdetails" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/structpb" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" + "github.com/crossplane/cli/v2/internal/simulate" + "github.com/crossplane/cli/v2/internal/testutils/integration" +) + +// fakeProviderRouter routes the composed XDownstreamResource kind, and only +// it, to a fake diff server that answers with fn. +type fakeProviderRouter struct { + fn func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) +} + +func (f fakeProviderRouter) Route(gk schema.GroupKind) (diffv1alpha1.PlanServiceClient, bool) { + if gk.Kind != "XDownstreamResource" { + return nil, false + } + return f, true +} + +func (f fakeProviderRouter) Plan(_ context.Context, in *diffv1alpha1.PlanRequest, _ ...grpc.CallOption) (*diffv1alpha1.PlanResponse, error) { + return f.fn(in) +} + +func strValue(s string) *diffv1alpha1.FieldValue { + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Value{Value: structpb.NewStringValue(s)}} +} + +func unknownValue() *diffv1alpha1.FieldValue { + return &diffv1alpha1.FieldValue{Kind: &diffv1alpha1.FieldValue_Absence{Absence: diffv1alpha1.Absence_ABSENCE_UNKNOWN}} +} + +func TestSimulateProviderPlan(t *testing.T) { + t.Parallel() + + xrCase := integration.Setup{ + CRDDirs: []string{"testdata/diff/crds"}, + SetupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + CrossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("testdata/diff/resources/existing-xr.yaml", "testdata/diff/resources/existing-downstream-resource.yaml"), + }, + } + + update := func(changes ...*diffv1alpha1.FieldChange) func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_UPDATE, Changes: changes}, nil + } + } + region := &diffv1alpha1.FieldChange{Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1")} + + cases := map[string]struct { + reason string + args []string + fn func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) + skip string + // ownCRD makes the composed kind's CRD provider-installed. + ownCRD bool + xrFile string + wantCode int + // wantOut are substrings of stdout; wantErr of stderr. + wantOut, wantNotOut []string + wantErr []string + // check inspects the -o json result, when args ask for it. + check func(t *testing.T, res simulate.ProjectResult) + }{ + "ProviderUpdate": { + reason: "A provider's extra field change joins the composed resource's plan, and the cloud-layer note is gone.", + args: []string{"-o", "json"}, + fn: update(region), + wantCode: dp.ExitCodeDiffDetected, + wantNotOut: []string{"Provider plan not run"}, + check: func(t *testing.T, res simulate.ProjectResult) { + t.Helper() + for _, p := range res.Plans { + if p.Kind != "XDownstreamResource" { + continue + } + for _, c := range p.Changes { + if c.Path == "spec.forProvider.region" { + return + } + } + } + t.Errorf("no XDownstreamResource plan carries the provider's spec.forProvider.region change: %+v", res.Plans) + }, + }, + "Replace": { + reason: "A field requiring replacement shows [-/+] and # forces replacement in -o plan.", + args: []string{"-o", "plan"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1"), RequiresReplace: true}, + }}, nil + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"[-/+]", "# forces replacement"}, + }, + "ReplaceDiff": { + reason: "-o diff says requires replacement in the header and marks the changed line.", + args: []string{"-o", "diff"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.configData", Actual: strValue("existing-value"), Planned: strValue("modified-value"), RequiresReplace: true}, + }}, nil + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"# forces replacement"}, + }, + "ReplaceDiffHeader": { + reason: "-o diff says requires replacement in the resource header.", + args: []string{"-o", "diff"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return &diffv1alpha1.PlanResponse{Action: diffv1alpha1.Action_ACTION_REPLACE, Changes: []*diffv1alpha1.FieldChange{ + {Field: "spec.forProvider.configData", Actual: strValue("existing-value"), Planned: strValue("modified-value"), RequiresReplace: true}, + }}, nil + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"~~~ XDownstreamResource/test-resource (requires replacement)", "(1 requires replacement)"}, + }, + "KnownAfterApply": { + reason: "Computed paths show (known after apply) in -o diff.", + args: []string{"-o", "diff"}, + fn: update(&diffv1alpha1.FieldChange{Field: "spec.forProvider.configData", Actual: strValue("existing-value"), Planned: unknownValue()}), + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"(known after apply)"}, + }, + "PlanError": { + reason: "A plan error makes the XR errored, drops its plans, names the resource and exits 1.", + args: []string{"-o", "json"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return nil, status.Error(codes.Internal, "boom") + }, + wantCode: dp.ExitCodeToolError, + check: func(t *testing.T, res simulate.ProjectResult) { + t.Helper() + if !res.Errored { + t.Errorf("want errored result, got %+v", res) + } + if !strings.Contains(planJSON(t, res), "test-resource") || !strings.Contains(planJSON(t, res), "boom") { + t.Errorf("result does not name the resource and error: %s", planJSON(t, res)) + } + // The failed XR is all-or-nothing: none of its composed + // resources keep a plan. + for _, p := range res.Plans { + if p.Kind == "XDownstreamResource" { + t.Errorf("failed XR still carries a plan for its composed resource: %+v", p) + } + } + }, + }, + "NotSupported": { + reason: "A provider that cannot diff leaves the cluster diff, marked approximate, with no error.", + args: []string{"-o", "json"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + st, err := status.New(codes.FailedPrecondition, "cannot compute diff").WithDetails(&errdetails.PreconditionFailure{Violations: []*errdetails.PreconditionFailure_Violation{{Type: "DIFF_COMPUTATION_NOT_SUPPORTED"}}}) + if err != nil { + return nil, err + } + return nil, st.Err() + }, + wantCode: dp.ExitCodeDiffDetected, + check: func(t *testing.T, res simulate.ProjectResult) { + t.Helper() + if res.Errored || res.Summary.Approximate == 0 { + t.Errorf("want non-errored result with approximate plans, got errored=%t summary=%+v", res.Errored, res.Summary) + } + }, + }, + "SkipPlan": { + reason: "--skip-plan never calls the provider: the run succeeds although every call would fail.", + args: []string{"-o", "plan", "--skip-plan"}, + fn: func(*diffv1alpha1.PlanRequest) (*diffv1alpha1.PlanResponse, error) { + return nil, status.Error(codes.Internal, "must not be called") + }, + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{simulate.NoteProviderPlanSkipped}, + }, + "Drift": { + reason: "A provider-managed resource the cluster sees unchanged still shows the provider's change.", + xrFile: "testdata/diff/unmodified-xr.yaml", + args: []string{"-o", "plan"}, + ownCRD: true, + fn: update(&diffv1alpha1.FieldChange{Field: "spec.forProvider.region", Actual: strValue("us-east-1"), Planned: strValue("eu-west-1")}), + wantCode: dp.ExitCodeDiffDetected, + wantOut: []string{"XDownstreamResource", "region"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if tc.skip != "" { + t.Skip(tc.skip) + } + if reason := integrationEnvGap(); reason != "" { + t.Skip(reason) + } + + cluster := integration.Start(t, xrCase) + cfg, kube, ctx := cluster.Config, cluster.Client, cluster.Context + if tc.ownCRD { + ownByProviderRevision(ctx, t, kube, "xdownstreamresources.ns.nop.example.org") + } + + xr := tc.xrFile + if xr == "" { + xr = "testdata/diff/modified-xr.yaml" + } + argv := append([]string{"xr", "simulate", "--timeout=" + defaultTimeout.String(), "-f", xr}, tc.args...) + + var stdout, stderr bytes.Buffer + code, err := runSimulateArgv(t, cfg, argv, localCrossplaneBinary(t), fakeProviderRouter{fn: tc.fn}, &stdout, &stderr) + if code != tc.wantCode { + t.Errorf("\n%s\nwant exit code %d, got %d (err %v)\nstdout:\n%s\nstderr:\n%s", tc.reason, tc.wantCode, code, err, stdout.String(), stderr.String()) + } + for _, w := range tc.wantOut { + if !strings.Contains(stdout.String(), w) { + t.Errorf("\n%s\nstdout lacks %q:\n%s", tc.reason, w, stdout.String()) + } + } + for _, w := range tc.wantNotOut { + if strings.Contains(stdout.String(), w) { + t.Errorf("\n%s\nstdout has %q:\n%s", tc.reason, w, stdout.String()) + } + } + if tc.check != nil { + var res simulate.ProjectResult + if err := json.Unmarshal(stdout.Bytes(), &res); err != nil { + t.Fatalf("\n%s\nstdout is not a simulate.ProjectResult: %v\n%s\nstderr:\n%s", tc.reason, err, stdout.String(), stderr.String()) + } + tc.check(t, res) + } + }) + } +} + +func planJSON(t *testing.T, res simulate.ProjectResult) string { + t.Helper() + b, err := json.Marshal(res) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// ownByProviderRevision makes the named CRD look provider-installed, as +// simulate.ProviderManaged reads it: controlled by a ProviderRevision. +func ownByProviderRevision(ctx context.Context, t *testing.T, kube client.Client, crdName string) { + t.Helper() + + crd := &unstructured.Unstructured{} + crd.SetGroupVersionKind(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}) + if err := kube.Get(ctx, client.ObjectKey{Name: crdName}, crd); err != nil { + t.Fatalf("cannot get CRD %s: %v", crdName, err) + } + crd.SetOwnerReferences([]metav1.OwnerReference{{ + APIVersion: "pkg.crossplane.io/v1", Kind: "ProviderRevision", Name: "provider-fake-1", UID: "00000000-0000-0000-0000-000000000001", + Controller: new(true), BlockOwnerDeletion: new(true), + }}) + if err := kube.Update(ctx, crd); err != nil { + t.Fatalf("cannot own CRD %s: %v", crdName, err) + } +} diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index ceca5659..b1ee3c5a 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -54,6 +54,7 @@ import ( "github.com/crossplane/crossplane-runtime/v2/pkg/logging" "github.com/crossplane/cli/v2/cmd/crossplane/composition" + "github.com/crossplane/cli/v2/cmd/crossplane/xr" "github.com/crossplane/cli/v2/internal/config" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/ref" @@ -69,7 +70,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 simulate's flags. +// them into composition or xr simulate's flags. type simArgs struct { Timeout time.Duration `default:"1m" help:"How long to run before timing out."` CrossplaneRenderBinary string `help:"Local crossplane binary to render with." name:"crossplane-render-binary"` @@ -105,7 +106,7 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA } // simulateArgv translates the parsed crossplane-diff args into composition -// simulate's: the input files as -f, each --resource as a --name (and +// or xr simulate's: the input files as -f, each --resource as a --name (and // --namespace), --include-manual as --include-pinned, and // --analyze-unchanged as its replacement --analyze-on=always. The output // is passed explicitly: crossplane-diff's default diff, since simulate @@ -113,12 +114,15 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { t.Helper() - // NOTE: only Composition cases run here; xr simulate adds its type. - if testType != CompositionDiffTest { - t.Fatalf("simulateArgv: no simulate command for test type %q", testType) + cmd := "project" + switch testType { + case CompositionDiffTest: + cmd = "composition" + case XRDiffTest: + cmd = "xr" } argv := []string{ - "composition", "simulate", + cmd, "simulate", "--timeout=" + a.Timeout.String(), "--output=" + a.Output, "--dry-run-on=" + a.DryRunOn, @@ -174,15 +178,16 @@ func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []s a := parseSimulateArgs(t, testType, args) - return runSimulateArgv(t, cfg, simulateArgv(t, testType, a), a.CrossplaneRenderBinary, stdout, stderr) + return runSimulateArgv(t, cfg, simulateArgv(t, testType, a), a.CrossplaneRenderBinary, nil, stdout, stderr) } -// runSimulateArgv runs `crossplane simulate` with argv against cfg, -// rendering with renderBinary when it is set, as runSimulate does. -func runSimulateArgv(t *testing.T, cfg *rest.Config, argv []string, renderBinary string, stdout, stderr io.Writer) (int, error) { +// runSimulateArgv runs `crossplane simulate` with argv against cfg, +// rendering with renderBinary when it is set, as runSimulate does, and routing provider +// plans through router, when it is non-nil, instead of started diff servers. +func runSimulateArgv(t *testing.T, cfg *rest.Config, argv []string, renderBinary string, router simulate.Router, stdout, stderr io.Writer) (int, error) { t.Helper() - return runSimulateArgvWith(t, cfg, withRenderBinary(argv, renderBinary), &simulate.Hooks{}, stdout, stderr) + return runSimulateArgvWith(t, cfg, withRenderBinary(argv, renderBinary), &simulate.Hooks{Router: router}, stdout, stderr) } // withRenderBinary returns argv rendering with the local crossplane binary @@ -194,7 +199,7 @@ func withRenderBinary(argv []string, bin string) []string { return append(slices.Clip(argv), "--crossplane-binary="+bin) } -// runSimulateArgvWith runs `crossplane simulate` with argv against +// runSimulateArgvWith runs `crossplane simulate` with argv against // cfg and hooks, whose RESTConfig and Terminal it sets. func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *simulate.Hooks, stdout, stderr io.Writer) (int, error) { t.Helper() @@ -202,6 +207,7 @@ func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *s var cli struct { Project Cmd `cmd:""` Composition composition.Cmd `cmd:""` + XR xr.Cmd `cmd:""` } parser, err := kong.New(&cli, kong.Name("crossplane"), @@ -237,6 +243,7 @@ func runSimulateArgvWith(t *testing.T, cfg *rest.Config, argv []string, hooks *s hooks.Terminal = true cli.Project.Simulate.Hooks = hooks cli.Composition.Simulate.Hooks = hooks + cli.XR.Simulate.Hooks = hooks err = kctx.Run() // Exit as main does: a silent ExitError, such as changes found, exits diff --git a/cmd/crossplane/project/integration_test.go b/cmd/crossplane/project/integration_test.go index 9da6d1ca..a5ed562a 100644 --- a/cmd/crossplane/project/integration_test.go +++ b/cmd/crossplane/project/integration_test.go @@ -65,6 +65,7 @@ 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 @@ -74,9 +75,25 @@ 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 - skip bool - skipReason string + 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 @@ -158,6 +175,11 @@ 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") @@ -214,6 +236,12 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tt IntegrationTestC RBACRules: tt.rbacRules, }) + // Block until any ValidatingAdmissionPolicy in the setup is actually enforcing, so the diff + // cannot race the apiserver's policy informer. See the field's doc comment. + if err := integration.AwaitAdmissionDenial(cluster.Context, cluster.Client, tt.awaitAdmissionDenialFor); err != nil { + t.Fatalf("admission policy never took effect: %v", err) + } + // runOnce executes the command against the running cluster, leaving its output in stdout / // stderr. Everything the command layer touches is rebuilt per call, so a second run starts // with cold client caches just as a second invocation of the CLI would. @@ -226,6 +254,16 @@ 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 @@ -338,6 +376,3105 @@ func leastPrivilegeRules(xrVerbs ...string) []rbacv1.PolicyRule { } } +// TestDiffIntegration runs an integration test for the diff command. +func TestDiffIntegration(t *testing.T) { + t.Parallel() + + tests := map[string]IntegrationTestCase{ + "NewResourceDiff": { + reason: "Shows color diff for new resources", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + // TODO: For v2, we need to upgrade the XRD apiversion to v2 + put the xr in a namespace. + // this might be better served as a separate test(s). + // since we aren't actually creating resources (there's no crossplane actually running in unit tests), + // we don't need to worry about upgrading providers or anything. + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedOutput: strings.Join([]string{ + `+++ XDownstreamResource/test-resource +`, tu.Green(`+ apiVersion: ns.nop.example.org/v1alpha1 ++ kind: XDownstreamResource ++ metadata: ++ annotations: ++ crossplane.io/composition-resource-name: nop-resource ++ labels: ++ crossplane.io/composite: test-resource ++ name: test-resource ++ namespace: default ++ spec: ++ forProvider: ++ configData: new-value +`), ` +--- ++++ XNopResource/test-resource +`, tu.Green(`+ apiVersion: ns.diff.example.org/v1alpha1 ++ kind: XNopResource ++ metadata: ++ name: test-resource ++ namespace: default ++ spec: ++ coolField: new-value +`), ` +--- +`, + }, ""), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "AutomaticNamespacePropagation": { + reason: "Validates automatic namespace propagation for namespaced managed resources", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-no-namespace-patch.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A built-in Kubernetes type has no CRD, so this exercises the paths + // that would otherwise read scope and schema from one. Before scope + // came from discovery this failed outright, with "cannot determine + // scope for resource ConfigMap/... CRD not found". + "RendersBuiltInResourceWithoutCRD": { + reason: "A composition rendering a built-in Kubernetes resource (no CRD) produces a diff for it", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-configmap.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(3, 0, 0). + WithAddedResource("ConfigMap", "test-resource-config", "default"). + WithField("data.configData", "new-value"). + And(). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "UnservedGroupIsReportedAsAnUnknownTypeNotAMissingNamespace": { + // From the PR #502 review. A resource whose group/version the cluster does not serve must be + // reported as an unknown type; blaming its namespace would send the user after the wrong root + // cause entirely, since the namespace here exists and is irrelevant. + // + // Worth being precise about what this covers, because it is NOT the sentinel branch in + // dryRunCreateAddition. For a *.k8s.io group, two earlier guards let the resource through — + // IsCRDRequired skips discovery for that suffix, and FetchCurrentObject swallows NotFound as + // "this is new" — but removeNamespacesFromClusterScopedResources then fails first, because + // scope determination cannot resolve the GVK either. So this pins the outer behaviour (an + // accurate, type-naming error rather than a namespace one) without depending on which layer + // produces it. + // + // The sentinel branch is what covers the case this CANNOT reach: scope determination falls + // back to a CRD lookup when discovery fails, whereas GVKToGVR is discovery-only, so a CRD + // whose version is not `served` passes the scope check and fails inside DryRunCreate. That + // route is unit-covered in diff_calculator_test.go, since arranging a served:false CRD whose + // scope still resolves is far more setup than the classification warrants. + reason: "An addition whose group/version the cluster does not serve is reported as an unknown type, not as a missing namespace", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-unserved-k8s-group.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedError: true, + expectedErrorContains: "totallynotserved.k8s.io/v1", + expectedExitCode: dp.ExitCodeToolError, + }, + "AdditionInMissingNamespaceDegradesRatherThanFailing": { + // Regression test for the e2e failure the first cut of this feature caused + // (TestDiffConcurrentDirectory diffs 21 XRs into a namespace that is never created). + // + // NamespaceLifecycle admission refuses a dry-run create into a namespace that does not exist. + // That must degrade, not fail the run: a Namespace and its contents are routinely applied + // together, so the namespace's absence at diff time says nothing about whether the apply will + // succeed — which is what separates it from a quota or webhook refusal, where the verdict + // describes the object and will still hold. + // + // The exit code is the real assertion. Before the fix this was exit 1 with no diff at all. + reason: "An addition whose namespace does not exist yet still produces a diff, marked as unverified", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr-missing-namespace.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "test-resource", "nonexistent-namespace"). + WithField("spec.coolField", "new-value"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"). + And(). + WithAddedResource("XDownstreamResource", "test-resource", "nonexistent-namespace"). + WithDryRunSkipped("namespaceNotFound", "nonexistent-namespace"). + And(). + // One warning per GVK, and each cause exactly the apiserver's message. Through the REAL + // ApplyClient, whose error is wrapped with "failed to dry-run create resource /": + // if that wrapper leaked into the cause, every resource would be a distinct warning. The unit + // test mirrors the wrapping by hand; this pins it against the client that actually produces it. + WithWarning("their namespace does not exist yet"). + WithWarningContext(map[string]string{ + "gvk": "ns.diff.example.org/v1alpha1, Kind=XNopResource", + "namespace": "nonexistent-namespace", + "cause": `namespaces "nonexistent-namespace" not found`, + "count": "1", + }). + And(). + WithWarning("their namespace does not exist yet"). + WithWarningContext(map[string]string{ + "gvk": "ns.nop.example.org/v1alpha1, Kind=XDownstreamResource", + "namespace": "nonexistent-namespace", + "cause": `namespaces "nonexistent-namespace" not found`, + "count": "1", + }), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Human-readable output shows no dryRun at all, so stderr is the only place a person learns that + // additions went unverified, and how many. Two inputs into one missing namespace share each GVK's + // warning rather than raising one per resource. + "UnverifiedAdditionsAcrossInputsAreCountedInOneWarning": { + reason: "In text mode, additions that could not be verified are summarised on stderr once per GVK + namespace + cause, with a count of the resources behind it", + inputFiles: []string{ + "testdata/diff/new-xr-missing-namespace.yaml", + "testdata/diff/new-xr-missing-namespace-second.yaml", + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + noColor: true, + expectedStderrContains: []string{ + `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=ns.diff.example.org/v1alpha1, Kind=XNopResource, namespace=nonexistent-namespace)`, + `WARNING: skipped apiserver verification of added resources: their namespace does not exist yet, so their diffs omit server-side defaulting and admission ` + + `(cause=namespaces "nonexistent-namespace" not found, count=2, gvk=ns.nop.example.org/v1alpha1, Kind=XDownstreamResource, namespace=nonexistent-namespace)`, + }, + expectedOutput: "+++ XDownstreamResource/second-resource", + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "BuiltInResourceAdditionPicksUpApiserverDefaults": { + // The choice of a built-in type keeps this test non-vacuous whatever crossplane-diff defaults + // locally. The lenient Defaulter (defaulter.go) predicts CRD-derived `default:` values for an + // addition that gets no apiserver result, and passes anything without a CRD through unchanged. + // Deployment has no CRD, so IsCRDRequired is false, and the fields asserted below can ONLY have + // come from round-tripping the addition through the apiserver. + // + // Every asserted default is string-valued on purpose: assertChangeFields compares with + // reflect.DeepEqual against JSON-decoded values, so a numeric default (revisionHistoryLimit: 10) + // would arrive as float64 and an int literal here would fail for the wrong reason. + reason: "An added built-in resource is dry-run created against the apiserver, so apiserver-side defaults appear in its addition diff", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-builtin-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("Deployment", "test-resource-deploy", "default"). + // Absence of dryRun is the machine-readable claim that this desired state was + // verified against the apiserver. Asserted alongside the defaults so that a + // regression which stopped dry-running additions fails on the contract as well as + // on whichever fields happen to depend on server-side defaulting. + WithDryRunPerformed(). + WithField("spec.strategy.type", "RollingUpdate"). + WithField("spec.template.spec.restartPolicy", "Always"). + WithField("spec.template.spec.dnsPolicy", "ClusterFirst"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithDryRunPerformed(). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The inverse of the case above, and the pair is what makes either meaningful: on its own, + // "the defaults appear" could be satisfied by something other than the apiserver round-trip, + // and "the defaults are absent" could be satisfied by the feature being broken. Together they + // pin the flag as the thing that decides. + // + // This also pins that `existing` reproduces the behaviour from before additions were dry-run + // (#334), which is why the fields are asserted absent rather than merely unasserted. + "BuiltInResourceAdditionSkipsApiserverWhenDryRunOnExisting": { + reason: "With --dry-run-on=existing an added built-in resource is not sent to the apiserver, so its diff carries no server-side defaults and reports skipReason disabled", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-builtin-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("Deployment", "test-resource-deploy", "default"). + // No detail substring: "disabled" is the one skip reason the user asked for, so + // there is no cluster explanation to propagate. + WithDryRunSkipped("disabled", ""). + WithFieldAbsent("spec.strategy.type"). + WithFieldAbsent("spec.template.spec.restartPolicy"). + WithFieldAbsent("spec.template.spec.dnsPolicy"). + // The rendered fields must still be there — `existing` degrades fidelity, it does + // not degrade the diff. + WithField("spec.template.spec.containers[0].image", "nginx:1.27"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A status authored by the composition pipeline must survive the apiserver round-trip. + // XStatusResource declares the status subresource, so the apiserver strips status from the + // dry-run create and returns an object without one; the value below can therefore only come + // from the calculator re-attaching what was rendered. Every other composed kind in this + // testdata tree omits the subresource, which would have made this assertion vacuous. + // + // SKIPPED, and the reason is a finding rather than an environment problem: the re-attach + // works, but nothing renders it. See skipReason. + "AddedResourceRetainsCompositionAuthoredStatus": { + skip: true, + skipReason: "The re-attached status cannot be observed in any output, so this assertion " + + "cannot pass and could not be made to fail by breaking the production code it targets. " + + "cleanupForDiff (compare/compare.go) deletes metadata.status unconditionally from " + + "both sides of every diff, in the same block that deletes ownerReferences — the strip whose " + + "identical consequence for ownerReferences this feature's own spec documented (§1a) and then " + + "missed for status. ResourceViews.Clean is the only view any renderer reads" + + "; ResourceViews.Desired.Raw, which does carry the " + + "re-attached status, has no reader at all. Verified empirically: the apiserver's dry-run " + + "create response for XStatusResource carries no status, the object handed to cleanupForDiff " + + "carries status.phase=authored-by-composition, and the object cleanupForDiff returns does " + + "not. Deciding whether status belongs in diff output is a rendering-design question well " + + "outside a test task, so this is left failing-by-skip with its instrument intact rather " + + "than quietly reworded into an assertion that passes.", + reason: "A status written by the composition pipeline survives the dry-run create of an added resource", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-status.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XStatusResource", "test-resource", "default"). + WithDryRunPerformed(). + WithField("status.phase", "authored-by-composition"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The apiserver runs names.Generator in rest.BeforeCreate, ahead of the dry-run + // short-circuit at the storage layer, so it mints a real random name even for a request it + // never persists. Unless that identity is restored, a generateName addition's diff differs on + // every invocation — which only a repeated run can detect, hence assertDeterministicAcrossRuns. + "GenerateNameAdditionIsDeterministicAcrossRuns": { + reason: "An addition using generateName renders identically across two runs, i.e. no server-generated name leaks into the diff", + outputFormat: "json", + dryRunOn: "all", + assertDeterministicAcrossRuns: true, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + // Both the XR and its composed resource use generateName, so both exercise the + // identity restore. The pattern is the renderer's display form for a + // not-yet-named object; a leaked server name would be a random suffix instead. + WithAddedResource("XDownstreamResource", "", "default"). + WithDryRunPerformed(). + WithNamePattern(`^test-resource-\(generated\)$`). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "", "default"). + WithDryRunPerformed(). + WithNamePattern(`^generated-xr-\(generated\)$`). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Cluster rejection of an ADDITION. A ValidatingAdmissionPolicy is the instrument rather than a CEL + // x-kubernetes-validations rule on the CRD, because crossplane-diff evaluates CEL rules + // locally before the diff calculator runs: a CEL rule would reject the resource without the + // apiserver ever being consulted, so the test would pass on code that never dry-ran anything. + "AddedResourceRejectedByAdmissionPolicyIsAValidationFailure": { + reason: "An apiserver rejection of an added resource is exit 2 with a typed admission validation failure, not a bare tool error", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/rejecting-admission-policy.yaml", + }, + awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", + expectedError: true, + expectedExitCode: dp.ExitCodeSchemaValidation, + expectedStructured: simtest.Expect(). + // errors[].resourceID names the input the user supplied, so the rejected composed + // resource has to be identifiable from the message and the validationFailures entry. + WithError("XNopResource/test-resource"). + WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). + WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). + WithStatus("invalid"). + // The "admission" type is what distinguishes a cluster rejection from a local schema + // rejection; field is empty because an admission refusal is not localized to one field. + WithFieldError("admission", ""). + WithMessageContaining("this cluster refuses that configData value"), + }, + // Cluster rejection of a MODIFICATION — the deliberate behaviour change. The identical cluster fact used to + // surface as a plain tool error (exit 1) for an existing resource while an addition's rejection + // would have been exit 2, i.e. the same rejection reported in two different tiers depending + // only on whether the resource happened to exist. This pins the reclassification. + "ModifiedResourceRejectedByAdmissionPolicyIsAValidationFailure": { + reason: "An apiserver rejection of an EXISTING resource's dry-run apply is reported in the same tier as a rejected addition", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/rejecting-admission-policy.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + awaitAdmissionDenialFor: "testdata/diff/resources/admission-policy-canary.yaml", + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedError: true, + expectedExitCode: dp.ExitCodeSchemaValidation, + expectedStructured: simtest.Expect(). + WithError("XNopResource/test-resource"). + WithMessageContaining("the cluster rejected XDownstreamResource/test-resource"). + WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "test-resource", "default"). + WithStatus("invalid"). + WithFieldError("admission", ""). + WithMessageContaining("this cluster refuses that configData value"), + }, + "MultipleXRsGroupedByInputXR": { + reason: "Two input XRs in one invocation are grouped per input XR in the xrs[] structured view", + outputFormat: "json", + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/new-xr-second.yaml", + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + // Aggregate flat view still reports the merged total (2 adds per XR). + WithSummary(4, 0, 0). + // Grouped view: one XR(...) sub-expression per input XR, each with + // its own changed tree. + WithXRs( + simtest.XR("XNopResource", "test-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + Change("added", "XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"), + simtest.XR("XNopResource", "second-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "second-resource", "default"). + WithField("spec.coolField", "second-value"). + Change("added", "XDownstreamResource", "second-resource", "default"). + WithField("spec.forProvider.configData", "second-value"), + ). + Build(), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MultipleXRsGroupedByInputXRV2": { + reason: "The composites[] entries group each input's changes", + outputFormat: "json", + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/new-xr-second.yaml", + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + // The top-level summary counts distinct resources. + WithSummary(4, 0, 0). + WithXRs( + simtest.XR("XNopResource", "test-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + Change("added", "XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"), + simtest.XR("XNopResource", "second-resource", "default"). + Status("changed"). + Summary(2, 0, 0). + Change("added", "XNopResource", "second-resource", "default"). + WithField("spec.coolField", "second-value"). + Change("added", "XDownstreamResource", "second-resource", "default"). + WithField("spec.forProvider.configData", "second-value"), + ), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ModifiedResourceDiff": { + reason: "Shows color diff for modified resources", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // put an existing XR in the cluster to diff against + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedOutput: ` +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +` + tu.Red("- configData: existing-value") + ` +` + tu.Green("+ configData: modified-value") + ` + +--- +~~~ XNopResource/test-resource + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: +` + tu.Red("- coolField: existing-value") + ` +` + tu.Green("+ coolField: modified-value") + ` + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-b616215 + compositionUpdatePolicy: Automatic + +--- + +Summary: 2 modified`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "IgnorePathsArgoCD": { + reason: "Ignores ArgoCD annotations and labels when --ignore-paths is specified", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // put an existing resource with different ArgoCD annotations + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-with-argocd.yaml", + "testdata/diff/resources/existing-downstream-resource-with-argocd.yaml", + ), + }, + inputFiles: []string{"testdata/diff/xr-with-argocd-annotations.yaml"}, + ignorePaths: []string{ + "metadata.annotations[argocd.argoproj.io/tracking-id]", + "metadata.labels[argocd.argoproj.io/instance]", + }, + outputFormat: "json", + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedError: false, + }, + "IgnorePathsMixedChanges": { + // Regression test: when a resource has BOTH ignored path changes + // AND non-ignored spec changes, the summary must count the + // resource as modified once and the emitted diff bodies must not + // leak the ignored paths. The renderer-level unit test + // TestStructuredDiffRenderer_RespectsIgnorePaths asserts the + // absence of ignored fields in diff.old / diff.new; this test + // pins the summary counts and the visible non-ignored changes + // through the full CLI stack. + reason: "Ignores ArgoCD annotations/labels while still reporting a non-ignored spec change", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-with-argocd.yaml", + "testdata/diff/resources/existing-downstream-resource-with-argocd.yaml", + ), + }, + inputFiles: []string{"testdata/diff/xr-with-argocd-mixed-changes.yaml"}, + ignorePaths: []string{ + "metadata.annotations[argocd.argoproj.io/tracking-id]", + "metadata.labels[argocd.argoproj.io/instance]", + }, + outputFormat: "json", + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "new-value", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "new-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ModifiedXRCreatesDownstream": { + reason: "Shows diff when modified XR creates new downstream resource", + outputFormat: "json", + // The XR has its composition refs but no composed resource yet: Crossplane selects the + // composition and revision before it composes, so this is an XR whose first compose has not + // created its resource (a function error, say). That is what makes the resource an addition. + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/existing-xr.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(1, 1, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #452, `xr` side, delivered through the warning channel added for #459. Unlike `comp`, + // which excludes deleting XRs from impact analysis, `xr` diffs exactly the resource the user + // named — so it still emits the diff and raises an advisory. This asserts BOTH halves of the + // dual emission: the stderr line for humans and the warnings[] entry for machines. + "DeletingXRWarnsButStillDiffs": { + reason: "xr against an XR whose cluster copy is being deleted still emits the diff, plus a warning on stderr and in warnings[]", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // A Terminating XR still has the resource it composed until garbage collection removes it. + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("testdata/diff/resources/existing-xr-deleting.yaml", "testdata/diff/resources/existing-downstream-resource.yaml"), + }, + deleteAfterSetup: []string{"testdata/diff/resources/existing-xr-deleting.yaml"}, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + And(). + WithWarning("being deleted in the cluster"), + expectedStderrContains: []string{ + "WARNING: The resource being diffed is being deleted in the cluster", + "resource=XNopResource/test-resource", + }, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #495: the XR still references a composed resource that was deleted out of band. The + // resource tree records that child as NotFound, which is tolerated: the resource really is + // gone, so it is neither observed nor a removal — but the user is told. The other per-node + // errors (Forbidden, transport) are fatal; envtest grants the harness everything, so those are + // covered by the FetchObservedResources unit tests instead. + "ComposedResourceDeletedOutOfBandWarns": { + reason: "xr against an XR whose resourceRefs name a deleted composed resource omits it from observed state and warns", + outputFormat: "json", + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, + "testdata/diff/resources/removal-test-ns-downstream-resource2.yaml": nil, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + deleteAfterSetup: []string{"testdata/diff/resources/removal-test-ns-downstream-resource2.yaml"}, + inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 0, 0). + WithWarning("was not found in the cluster"). + WithWarningContext(map[string]string{ + "resource": "XDownstreamResource/resource-to-be-removed", + "namespace": "default", + }), + expectedStderrContains: []string{ + "WARNING: A composed resource referenced by its XR was not found in the cluster", + "resource=XDownstreamResource/resource-to-be-removed", + }, + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, + }, + "EnvironmentConfigIncorporation": { + reason: "Validates EnvironmentConfig (v1beta1) incorporation in diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/env-xrd.yaml", + "testdata/diff/resources/env-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/environment-config-v1beta1.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-env-xr.yaml", + "testdata/diff/resources/existing-env-downstream-resource.yaml", + ), + }, + xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/modified-env-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamEnvResource", "test-env-resource", ""). + WithFieldChange("spec.forProvider.configData", "existing-config-value", "modified-config-value"). + And(). + WithModifiedResource("XEnvResource", "test-env-resource", ""). + WithFieldChange("spec.configKey", "existing-config-value", "modified-config-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ExternalResourceDependencies": { + reason: "Validates diff with external resource dependencies via fn-external-resources", + outputFormat: "json", + // this test does a weird thing where it changes the XR but all the downstream changes come from external + // resources, including a field path from the XR itself. + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/external-res-fn-composition.yaml", + "testdata/diff/resources/external-named-clusterrole.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-with-external-dep.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "testing-external-resource-data"). + WithFieldChange("spec.forProvider.roleName", "old-role-name", "external-named-clusterrole"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-with-external-dep"). + WithFieldChange("spec.environment", "staging", "testing"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Same-namespace case: the matchLabels ExtraResources selector omits the + // namespace, and the matched ConfigMap lives in "default" — the SAME + // namespace as the XR. This passed even before the #376 fix, because the + // old resolveNamespace defaulted an empty selector namespace to the XR + // namespace, which happened to be where the ConfigMap was. See + // "TemplatedExtraResourcesCrossNamespace" for the cross-namespace case + // that requires the all-namespaces lookup. + "TemplatedExtraResourcesSameNamespace": { + reason: "Validates diff with templated ExtraResources (matchLabels, no namespace) resolving a resource in the XR's own namespace", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/external-res-gotpl-composition.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-with-external-dep-templated.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-with-external-dep"). + WithFieldChange("spec.forProvider.roleName", "old-role-name", "templated-external-resource-testing"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-with-external-dep"). + WithFieldChange("spec.environment", "staging", "testing"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Reproduction for crossplane-contrib/crossplane-diff#376: a matchLabels + // ExtraResources selector that omits the namespace is a cluster-wide + // (all-namespaces) lookup in Crossplane (internal/xfn/required_resources.go + // lists with client.InNamespace(rs.GetNamespace()); empty = all + // namespaces). Here the matched ConfigMap lives in "other-namespace" while + // the XR is in "default". Pre-fix, resolveNamespace defaulted the empty + // selector namespace to the XR namespace ("default"), so the dynamic + // client listed only "default", found nothing, and the go-templating + // requirement came back empty — the downstream XDownstreamResource would + // not render at all (or a real template reading the value would fatal with + // ValueError). Post-fix the lookup spans all namespaces, finds the + // ConfigMap, and the downstream renders with roleName derived from it. + "TemplatedExtraResourcesCrossNamespace": { + reason: "matchLabels ExtraResources (no namespace) must resolve a resource in a DIFFERENT namespace than the XR via an all-namespaces lookup (issue #376)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap-other-namespace.yaml", + "testdata/diff/resources/external-res-gotpl-composition.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr-with-cross-ns-external-dep.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + // roleName is templated from the matched ConfigMap's name, proving + // the cross-namespace resource was resolved. + WithField("spec.forProvider.roleName", "templated-external-resource-crossns"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + WithField("spec.environment", "crossns"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "TemplatedExtraResourcesMatchNameNotFound": { + // Regression test for crossplane-contrib/crossplane-diff#355: a + // go-templating ExtraResources block using matchName for a + // resource that does not exist in the cluster must NOT abort the + // diff. Pre-fix, the iterative render loop received an error + // from requirements_provider.processNameSelector and surfaced it + // as a per-XR failure. Post-fix, the unmet requirement is silently + // skipped (mirroring upstream xfn.required_resources.go) and the + // composition renders its downstream resource normally. + reason: "matchName requirement for a missing resource must not abort the diff (issue #355)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-res-matchname-gotpl-composition.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + WithField("spec.forProvider.roleName", "static-role"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MultiStepFatalAfterExtraResources": { + // Reproduces the PR #295 bug #1 scenario: a multi-step pipeline where + // step 1 successfully emits ExtraResources requirements and composed + // resources, and a LATER step fatals. Per crossplane/cmd/crank/render/ + // render.go, a fatal in step N still returns the accumulated + // requirements from step 0 + // + // On iteration 2 (after requirements are cached) newReqCount == 0, + // and the OLD condition + // renderErr != nil && newReqCount == 0 && len(output.Requirements) == 0 + // is false — the error falls through to checkStability which declares + // the pipeline "stable" on an Outputs with nil CompositeResource, + // causing a SIGSEGV in CalculateNonRemovalDiffs. + // + // Expected behavior (with PR #295's render-loop fix): a clean fatal + // error bubbles up instead of a segfault. The diff is NOT produced + // because the pipeline legitimately failed. + reason: "Multi-step pipeline with step-1 requirements + later-step fatal should surface the error cleanly, not SIGSEGV", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedError: true, + expectedErrorContains: `"always-fatal" returned a fatal result`, + expectedExitCode: dp.ExitCodeToolError, + }, + "UnguardedTemplatedExtraResources": { + // Regression guard: a user template that accesses .extraResources + // directly (e.g. `index .extraResources "configmaps"`) without a + // `{{- with .extraResources }}` guard fails fatally on the first + // function invocation. function-go-templating's template data has no + // "extraResources" entry on that first call — crossplane's + // FetchingFunctionRunner only populates req.ExtraResources AFTER a + // function returns a non-fatal rsp.Requirements, and the Fatal from + // this template short-circuits before that ever happens. + // + // Verified empirically (2026-04-29) against a kind cluster with + // Crossplane + function-go-templating v0.11.0: a live controller + // produces the identical error, and the XR stays Synced=False with + // ReconcileError. So crossplane-diff must surface the same fatal + // error — not mask it with a diff — to match production behavior. + // + // This test also guards against a hypothetical "fixed" prefetch that + // pre-populated .extraResources for the template: that would make + // crossplane-diff report a successful diff for a composition that + // actually fails in-cluster, which is a false positive. If this test + // ever starts passing with an unexpected exit code or producing a + // diff, something has diverged from prod behavior. + reason: "Unguarded .extraResources access must surface a fatal error matching what a live Crossplane controller produces (verified empirically against kind cluster on 2026-04-29)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/external-resource-configmap.yaml", + "testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml", + "testdata/diff/resources/existing-downstream-with-external-dep.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-with-external-dep.yaml"}, + expectedError: true, + expectedErrorContains: `: error calling index: index of untyped nil`, + expectedExitCode: dp.ExitCodeToolError, + }, + "CrossNamespaceResourceDependencies": { + reason: "Validates cross-namespace resource dependencies via fn-external-resources", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/cross-namespace-configmap.yaml", + "testdata/diff/resources/cross-namespace-fn-composition.yaml", + "testdata/diff/resources/external-named-clusterrole.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-cross-ns-xr.yaml", + "testdata/diff/resources/existing-cross-ns-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-cross-ns-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-cross-ns-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-cross-ns-data-existing-named-data-old-cross-ns-role", "cross-namespace-data-another-cross-namespace-data-external-named-clusterrole"). + And(). + WithModifiedResource("XNopResource", "test-cross-ns-resource", "default"). + WithFieldChange("spec.coolField", "existing-cross-ns-value", "modified-cross-ns-value"). + WithFieldChange("spec.environment", "staging", "production"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceRemovalHierarchyV1ClusterScoped": { + reason: "Validates resource removal detection with hierarchy using v1 style resourceRefs and cluster scoped downstreams", + xrdAPIVersion: integration.XRDV1, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-legacy-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-legacy-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/removal-test-legacy-composition.yaml", + "testdata/diff/resources/removal-test-legacy-child-xrd.yaml", + "testdata/diff/resources/removal-test-legacy-child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-legacy-xr.yaml"}, + expectedOutput: ` +~~~ XDownstreamResource/resource-to-be-kept + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + labels: + crossplane.io/composite: test-resource + name: resource-to-be-kept + spec: + forProvider: +- configData: existing-value ++ configData: modified-value + +--- +--- XDownstreamResource/resource-to-be-removed-managed +- apiVersion: legacycluster.nop.example.org/v1alpha1 +- kind: XDownstreamResource +- metadata: +- annotations: +- crossplane.io/composition-resource-name: managed-resource +- labels: +- crossplane.io/composite: test-resource +- name: resource-to-be-removed-managed +- spec: +- forProvider: +- configData: child-value + +--- +--- XLegacyChildResource/resource-to-be-removed +- apiVersion: legacycluster.diff.example.org/v1alpha1 +- kind: XLegacyChildResource +- metadata: +- annotations: +- crossplane.io/composition-resource-name: resource2 +- labels: +- crossplane.io/composite: test-resource +- name: resource-to-be-removed +- spec: +- childField: child-value +- compositionRef: +- name: xlegacychildresources.legacycluster.diff.example.org +- compositionRevisionRef: +- name: xlegacychildresources.legacycluster.diff.example.org-bf2c887 +- compositionUpdatePolicy: Automatic + +--- +~~~ XNopResource/test-resource + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + labels: + crossplane.io/composite: test-resource + name: test-resource + spec: + compositionRef: + name: removal-test-composition + compositionRevisionRef: + name: removal-test-composition-4b6f9f1 + compositionUpdatePolicy: Automatic +- coolField: existing-value ++ coolField: modified-value + +--- + +Summary: 2 modified, 2 removed`, + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + noColor: true, + }, + // Issue #259: function-sequencer hides resources from later stages, but those + // resources should be shown as removed if they exist in the cluster. + // https://github.com/crossplane-contrib/crossplane-diff/issues/259 + "FunctionSequencerPreservesExistingResources": { + reason: "Function-sequencer should NOT hide resources that already exist in the cluster (fix for issue #259)", + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-sequencer-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + // Stage 0 resource - exists in cluster + "testdata/diff/resources/sequencer-stage0-downstream.yaml": nil, + // Stage 1 resource - exists in cluster, should NOT be hidden by sequencer + // because observed resources are passed correctly to the function pipeline. + // Stage 0 has no Ready condition, so the sequencer would gate stage 1 if it + // were not observed: this case depends on the XR's own composed resources + // reaching render as observed state. + "testdata/diff/resources/sequencer-stage1-downstream.yaml": nil, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + outputFormat: "json", + // Expected: With the fix, observed resources ARE passed to function-sequencer. + // Function-sequencer sees both resources exist and does NOT remove them from desired state. + // Since existing resources match rendered resources, there should be no changes. + // XR diff outputs nothing when there are no changes (unlike comp diff which says "No changes detected"). + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, + }, + // Issue #259: Validates that removal detection works even when the XR has no changes. + // This is the core fix for the function-sequencer bug where existingXR was nil because + // the XR diff wasn't stored when there were no changes. + // https://github.com/crossplane-contrib/crossplane-diff/issues/259 + "ResourceRemovalWithUnmodifiedXR": { + reason: "Validates resource removal detection works when XR has no changes (uses existingXRFromCluster fallback)", + outputFormat: "json", + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, // XR has NO changes + // Expected: the nested XR resource2 and what it composes are removed, but XR and resource1 have no changes + expectedStructured: simtest.Expect(). + WithSummary(0, 0, 2). + WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). + // dryRun must be absent on a removal. A removal has no desired state to preview, so it can + // never carry a dryRun object, and one appearing would wrongly mark it unverified; WithDryRunPerformed asserts exactly that absence. The method + // name reads oddly here — nothing was dry run — but the claim it makes ("no dryRun + // object is present") is the right one, and having a second spelling for the same + // assertion would be worse. + WithDryRunPerformed(). + WithField("spec.childField", "child-value"). + And(). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed-managed", "default"). + WithDryRunPerformed(). + WithField("spec.forProvider.configData", "child-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #552: removal mirrors Crossplane's garbage collector, which deletes a composed resource only if + // the composite controls it. The XR's resourceRefs name four resources, and its render desires only + // the first: one it controls and renders, one it controls and no longer renders, one another XR + // controls, and one with no controller reference at all. Only the second is removed. + "ResourceRemovalSkipsResourcesTheXRDoesNotControl": { + reason: "A composed resource the XR's resourceRefs name but the XR does not control is not reported removed", + // What this case pins: test-resource's resourceRefs still name two resources it doesn't control. + outputFormat: "json", + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml", + "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", + ), + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // Deliberately not in crossplaneManagedResources: its controller reference was removed, so + // no XR controls it, though test-resource's resourceRefs still name it. + "testdata/diff/resources/removal-test-ns-downstream-without-controller.yaml", + }, + inputFiles: []string{"testdata/diff/unmodified-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 0, 1). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed", "default"). + WithField("spec.forProvider.configData", "existing-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceRemovalHierarchyV2Namespaced": { + reason: "Validates resource removal detection with hierarchy using v2 style resourceRefs and namespaced downstreams", + outputFormat: "json", + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-removal-test-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-ns-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 2). + WithModifiedResource("XDownstreamResource", "resource-to-be-kept", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). + WithField("spec.childField", "child-value"). + And(). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed-managed", "default"). + WithField("spec.forProvider.configData", "child-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceRemovalHierarchyV2ClusterScoped": { + reason: "Validates resource removal detection with hierarchy using v2 style resourceRefs and cluster scoped downstreams", + outputFormat: "json", + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-cluster-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml": nil, // Will be kept + "testdata/diff/resources/removal-test-nested-xr.yaml": { + // A nested XR, no longer rendered: removed, with what it composes + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/removal-test-nested-xr-managed.yaml": nil, // Child will also be removed + }, + }, + }, + }, + }, + setupFiles: []string{ + "testdata/diff/resources/cluster-xrd.yaml", + "testdata/diff/resources/removal-test-cluster-composition.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 2). + WithModifiedResource("XDownstreamResource", "resource-to-be-kept", ""). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithRemovedResource("XChildResource", "resource-to-be-removed", "default"). + WithField("spec.childField", "child-value"). + And(). + WithRemovedResource("XDownstreamResource", "resource-to-be-removed-managed", "default"). + WithField("spec.forProvider.configData", "child-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", ""). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ResourceWithGenerateName": { + reason: "Validates handling of resources with generateName", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + // Set up the XR as the owner of the generated composed resource + OwnerFile: "testdata/diff/resources/existing-generate-name-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + // This file has a generated name and is owned by the XR + "testdata/diff/resources/existing-downstream-with-generated-name.yaml": nil, + }, + }, + }, + // Use a composition that uses generateName for composed resources + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The generateName counterpart of NewXRWithExistingComposedResource. A new XR renders its composed + // resource with only a generateName, and nothing is observed for it, so no name identifies the + // existing resource: only its crossplane.io/composite label and composition-resource-name + // annotation can. ResourceWithGenerateName cannot reach that lookup, because there the XR exists and + // render names the resource after the observed one. + "NewXRAdoptsOrphanedGenerateNamedComposedResource": { + reason: "A new XR's generateName composed resource is matched to an existing orphan by its composite label", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/generated-name-composition.yaml", + // Deliberately not in crossplaneManagedResources: an orphan has no controller + // ownerReference (the garbage collector strips it when the XR is deleted with + // --cascade=orphan) and no XR lists it, but it keeps its labels and annotations. + "testdata/diff/resources/existing-downstream-with-generated-name.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(1, 1, 0). + WithAddedResource("XNopResource", "test-resource", "default"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource-abc123", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A generateName-only input next to an ordinary one is an ordinary invocation. The unnamed input + // has no identity, so input validation must not mistake it for the named XR's claim: an XR with + // no claimRef has an empty reference identity too, and the two must not match. + "GenerateNameInputAlongsideANamedInput": { + reason: "A generateName-only input beside a named one is valid; both are diffed", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml", "testdata/diff/generated-name-xr.yaml"}, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithSummary(4, 0, 0). + WithXRs( + simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0), + simtest.XR("XNopResource", "generated-xr-(generated)", "default").Status("changed").Summary(2, 0, 0), + ), + }, + "NewXRWithGenerateName": { + reason: "Shows diff for new XR with generateName", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // We don't add any existing XR, as we're testing creation of a new one + }, + inputFiles: []string{"testdata/diff/generated-name-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "", "default"). + WithNamePattern(`generated-xr-\(generated\)`). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "", "default"). + WithNamePattern(`generated-xr-\(generated\)`). + WithField("spec.coolField", "new-value"). + And(). + // Issue #477: the grouped view must attribute these changes to a + // named XR. metadata.name is empty on the input, so the identity + // carries the effective (synthesized) name — the same one the + // changes above are keyed by. + WithXRs( + simtest.XR("XNopResource", "generated-xr-(generated)", "default"). + Status("changed"). + Summary(2, 0, 0), + ), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #476. The same file passed twice — a fat-fingered command line, or CI enumerating one + // file twice — has one clear intent. It is diffed once, with a warning, and the run succeeds. + "IdenticalDuplicateInputIsDeduplicated": { + reason: "An input identical to an earlier one is diffed once, with a warning, not rejected", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/new-xr.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStderrContains: []string{"Ignoring a duplicate input: it is identical to an earlier one"}, + // The warning channel's contract is both halves: stderr above, and warnings[] here. + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithXRs(simtest.XR("XNopResource", "test-resource", "default").Status("changed").Summary(2, 0, 0)). + WithWarning("Ignoring a duplicate input: it is identical to an earlier one"). + WithWarningContext(map[string]string{"resource": "XNopResource/test-resource", "input": "2", "duplicateOf": "1"}), + }, + // The same object twice with different content fails loudly: applying both would leave whichever + // is applied last, and the order inputs are given in is not a statement of intent. + "SameObjectTwiceWithDifferentContentFails": { + reason: "One object given twice with different content is an input error on both inputs", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/new-xr.yaml", + "testdata/diff/modified-xr.yaml", + }, + expectedError: true, + expectedErrorContains: "input 2 defines XNopResource/test-resource differently from input 1", + expectedExitCode: dp.ExitCodeToolError, + }, + // A child XR passed alongside the parent whose composition renders it: the parent's composition + // writes that child, so the child input would be a second writer. Rejected on the child; the + // parent's own diff is unaffected. + "InputComposedByAnotherInputIsRejected": { + reason: "An XR supplied alongside the input whose composition composes it is an input error", + setupFiles: []string{ + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/new-nested-xr.yaml", + "testdata/diff/new-nested-child-xr.yaml", + }, + expectedError: true, + expectedErrorContains: "XChildResource/test-parent-child is composed by input XParentResource/test-parent, " + + "whose composition writes it", + expectedExitCode: dp.ExitCodeToolError, + }, + // Issue #476. Two distinct XRs whose composition gives one child a fixed name render the same + // object — here with byte-identical content. Crossplane gives it to whichever XR creates it first + // and the other fails to reconcile it, so the run must fail however alike the renderings are. + // This depends on real renders carrying the controller reference; unit tests build that by hand. + "TwoXRsComposingOneObjectContend": { + reason: "Two XRs that would both control one object fail, even when their renderings are identical", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/shared-child-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/shared-child-xrs.yaml"}, + expectedError: true, + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: `resource "ns.nop.example.org/v1alpha1/XDownstreamResource/default/shared-child" would be ` + + `controlled by more than one XR (XNopResource/shared-child-owner-a, XNopResource/shared-child-owner-b)`, + }, + // Reproduces the PR #294 scenario: a net-new XR is diffed while the + // composed resource it would manage already exists in the cluster + // (e.g. after the prior XR was deleted with --cascade=orphan, or while + // importing existing resources under Crossplane management). The render + // pipeline emits ownerReferences with an empty UID for new XRs; + // without the fallback in CalculateNonRemovalDiffs, UpdateOwnerRefs + // runs as a no-op and the real apiserver rejects the dry-run apply + // with "metadata.ownerReferences.uid: must not be empty". + "NewXRWithExistingComposedResource": { + reason: "Shows diff for new XR whose composed resource already exists in the cluster (PR #294)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + // Pre-existing composed resource; no backing XR in the cluster. Deliberately not in + // crossplaneManagedResources: an orphaned resource has no controller ownerReference (the + // garbage collector strips it when the XR is deleted with --cascade=orphan), and adopting + // exactly such a resource is what this case tests. + "testdata/diff/resources/existing-downstream-resource.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(1, 1, 0). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MultipleXRs": { + reason: "Validates diff for multiple XRs", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + // Add an existing XR and downstream resource to test modification + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + inputFiles: []string{ + "testdata/diff/first-xr.yaml", + "testdata/diff/modified-xr.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 2, 0). + WithAddedResource("XDownstreamResource", "first-resource", "default"). + WithField("spec.forProvider.configData", "first-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithAddedResource("XNopResource", "first-resource", "default"). + WithField("spec.coolField", "first-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "LeastPrivilegeCredentialsDiffSucceeds": { + reason: "A user who can only get Crossplane's types, not list them, gets the admin's diff of a new and a modified XR", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr.yaml", + "testdata/diff/resources/existing-downstream-resource.yaml", + ), + }, + // The new XR names its composition: one that names none is matched against every Composition, + // which takes a list of them. + inputFiles: []string{"testdata/diff/first-xr-with-composition-ref.yaml", "testdata/diff/modified-xr.yaml"}, + rbacRules: leastPrivilegeRules("get", "create", "patch"), + outputFormat: "json", + expectedStructured: simtest.Expect(). + WithSummary(2, 2, 0). + WithAddedResource("XDownstreamResource", "first-resource", "default"). + WithField("spec.forProvider.configData", "first-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithAddedResource("XNopResource", "first-resource", "default"). + WithField("spec.coolField", "first-value"). + And(). + WithModifiedResource("XNopResource", "test-resource", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "UnrelatedXRDWithoutCRDDoesNotFailRun": { + reason: "An XRD unrelated to the input whose CRD does not exist yet does not affect the diff", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + unestablishedXRDs: []string{"testdata/diff/resources/unestablished-xrd.yaml"}, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + outputFormat: "json", + // The same diff NewResourceDiff shows, which has no such XRD. + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "new-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "MissingFunctionFailsWithSameError": { + reason: "A pipeline naming a Function the cluster does not have fails, naming the function and its step", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-missing-function.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + expectedError: true, + expectedErrorContains: `cannot get functions from pipeline: function "function-not-installed" referenced in pipeline step "generate-resources" not found`, + expectedExitCode: dp.ExitCodeToolError, + }, + "SelectCompositionByDirectReference": { + reason: "Validates composition selection by direct reference", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + // Add multiple compositions for the same XR type + "testdata/diff/resources/default-composition.yaml", + "testdata/diff/resources/production-composition.yaml", + "testdata/diff/resources/staging-composition.yaml", + }, + inputFiles: []string{ + "testdata/diff/xr-with-composition-ref.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "production"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "test-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "SelectCompositionByLabelSelector": { + reason: "Validates composition selection by label selector", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + // Add multiple compositions for the same XR type + "testdata/diff/resources/default-composition.yaml", + "testdata/diff/resources/production-composition.yaml", + "testdata/diff/resources/staging-composition.yaml", + }, + inputFiles: []string{ + "testdata/diff/xr-with-composition-selector.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "staging"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithField("spec.coolField", "test-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "AmbiguousCompositionSelection": { + reason: "Validates error on ambiguous composition selection", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + // Add multiple compositions for the same XR type + "testdata/diff/resources/default-composition.yaml", + "testdata/diff/resources/production-composition.yaml", + "testdata/diff/resources/staging-composition.yaml", + }, + inputFiles: []string{ + "testdata/diff/xr-with-ambiguous-selector.yaml", + }, + expectedError: true, + expectedExitCode: dp.ExitCodeToolError, + expectedErrorContains: "ambiguous composition selection: multiple compositions match", + noColor: true, + }, + "SchemaValidationError": { + reason: "Validates exit code 2 for schema validation errors and structured per-resource failure detail in JSON output", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{ + "testdata/diff/invalid-schema-xr.yaml", + }, + outputFormat: "json", + expectedError: true, + expectedExitCode: dp.ExitCodeSchemaValidation, + // Structured assertion: lock down the typed + // validationFailures payload for both the failing XR and + // its composed resource. Field paths and error types are + // ours to assert; the embedded k8s message wording is + // left unasserted (apimachinery upgrades can shift its + // phrasing without changing our output contract). + expectedStructured: simtest.Expect(). + WithError("XNopResource/invalid-schema-xr"). + WithValidationFailure("ns.diff.example.org/v1alpha1", "XNopResource", "invalid-schema-xr", "default"). + WithStatus("invalid"). + WithFieldError("schema", "spec.coolField"). + AndFieldError(). + AndValidationFailure(). + WithValidationFailure("ns.nop.example.org/v1alpha1", "XDownstreamResource", "invalid-schema-xr", "default"). + WithStatus("invalid"). + WithFieldError("schema", "spec.forProvider.configData"). + AndFieldError(). + AndValidationFailure(). + AndError(), + noColor: true, + }, + "NewClaimShowsDiff": { + reason: "Shows diff for new claim", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + // Add the necessary CRDs and compositions for claim diffing + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-claim.yaml"}, + // The claim is defaulted with its own CRD (generated from claim-xrd.yaml), which shares the XRD's + // user default spec.tier with the XR's CRD but, unlike it, does not default compositionUpdatePolicy. + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("NopClaim", "test-claim", "existing-namespace"). + WithField("spec.coolField", "new-value"). + WithField("spec.tier", "standard"). + WithFieldAbsent("spec.compositionUpdatePolicy"). + And(). + WithAddedResource("XDownstreamResource", "test-claim", ""). + WithField("spec.forProvider.configData", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "NewClaimWithClaimRefComposition": { + reason: "Shows diff for new claim when composition uses spec.claimRef - claimRef is synthesized for new claims", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition-with-claimref.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-claim-with-claimref-composition.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("NopClaim", "test-claim", "existing-namespace"). + WithField("spec.coolField", "new-value"). + And(). + WithAddedResource("XDownstreamResource", "test-claim", ""). + WithField("spec.forProvider.configData", "existing-namespace/test-claim"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "ModifiedClaimShowsDiff": { + reason: "Shows diff for modified claim", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + // Add necessary CRDs and composition + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/functions.yaml", + // Add existing resources for comparison + "testdata/diff/resources/existing-claim.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-claim-xr.yaml", + "testdata/diff/resources/existing-claim-downstream-resource.yaml", + ), + }, + xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/modified-claim.yaml"}, + expectedStructured: simtest.Expect(). + // The composed resource is labelled with the backing XR the claim's resourceRef names, so it + // already belongs to this claim: no ownership advisory (#534). + WithNoWarnings(). + WithSummary(0, 2, 0). + WithModifiedResource("NopClaim", "test-claim", "existing-namespace"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-claim-82crv", ""). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503, for a claim. A claim is diffed as the claim itself, so its dry-run payload must carry + // none of the defaults its CRD declares for fields the manifest omits. + "XRDDefaultedFieldOnClaimOwnedByAnotherManagerIsNotReportedChanged": { + reason: "An XRD-defaulted field the claim manifest omits keeps another manager's non-default value in the diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/claim-xrd.yaml", + "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/existing-claim.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-claim-xr.yaml", + "testdata/diff/resources/existing-claim-downstream-resource.yaml", + ), + }, + xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + fieldManagerApplies: []integration.FieldManagerApply{ + {File: "testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml", FieldManager: "platform-operator"}, + }, + inputFiles: []string{"testdata/diff/modified-claim-omitting-defaulted-fields.yaml"}, + expectedStructured: simtest.Expect(). + // As in ModifiedClaimShowsDiff: the composed resource belongs to the claim's backing XR (#534). + WithNoWarnings(). + WithSummary(0, 2, 0). + WithModifiedResource("NopClaim", "test-claim", "existing-namespace"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + WithFieldChange("spec.compositeDeletePolicy", "Foreground", "Foreground"). + WithFieldChange("spec.compositionUpdatePolicy", "Manual", "Manual"). + And(). + WithModifiedResource("XDownstreamResource", "test-claim-82crv", ""). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A claim is defaulted with its own CRD, never its XR's; only the XR that render consumes gets + // the XR CRD's defaults. The claimnested CRDs mirror what Crossplane generates: both carry the + // XRD's user default spec.tier, only the claim's defaults spec.compositeDeletePolicy, and only + // the XR's defaults spec.compositionUpdatePolicy. The composition writes the render input's + // tier and policy into the composed resource, so each case pins all three views at once: the + // claim's payload, its predicted or apiserver-defaulted form, and the render input. + "NewClaimIsDefaultedWithItsOwnCRDWhenDryRunCreated": { + reason: "A new claim shows its own CRD's defaults, from the apiserver, and render sees its XR's", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/parent-defaults-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). + WithDryRunPerformed(). + WithField("spec.tier", "standard"). + WithField("spec.compositeDeletePolicy", "Background"). + WithFieldAbsent("spec.compositionUpdatePolicy"). + And(). + WithAddedResource("XDownstreamResource", "new-parent-claim-defaults", ""). + WithField("spec.forProvider.configData", "standard/Automatic"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "NewClaimIsPredictedWithItsOwnCRDsDefaultsWithoutDryRun": { + reason: "With --dry-run-on=existing a new claim is predicted with its own CRD's defaults, not its XR's", + outputFormat: "json", + dryRunOn: "existing", + setupFiles: []string{ + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/parent-defaults-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("ParentNopClaim", "new-parent-claim", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.tier", "standard"). + WithField("spec.compositeDeletePolicy", "Background"). + WithFieldAbsent("spec.compositionUpdatePolicy"). + And(). + WithAddedResource("XDownstreamResource", "new-parent-claim-defaults", ""). + WithDryRunSkipped("disabled", ""). + WithField("spec.forProvider.configData", "standard/Automatic"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "XRDDefaultsAppliedBeforeRendering": { + reason: "Validates that XRD defaults are applied to XR before rendering", + outputFormat: "json", + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(1, 0, 0). + WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). + WithField("spec.region", "us-east-1"). + WithField("spec.size", "large"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The sibling of XRDDefaultsAppliedBeforeRendering, whose composition renders nothing, so it checks + // only the XR's dry run. Here a composition reads the composite's defaulted fields into a composed + // resource: a user default (spec.region) for a v2 and a legacy XR, and for the legacy XR also the + // machinery default spec.compositionUpdatePolicy. Rendering an undefaulted XR writes "". + "XRDDefaultsReachTheRender": { + reason: "Validates that a composition observes an XR's CRD defaults, for both a v2 and a legacy XR", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/rendered-defaults/definitions.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-rendered-defaults-xrs.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(4, 0, 0). + WithAddedResource("XRegionalDatabase", "modern-database", "default"). + WithField("spec.region", "us-east-1"). + And(). + WithAddedResource("XLegacyRegionalDatabase", "legacy-database", ""). + WithField("spec.region", "eu-west-1"). + And(). + WithAddedResource("XDownstreamResource", "modern-database", "default"). + WithField("spec.forProvider.configData", "us-east-1/large"). + And(). + WithAddedResource("XDownstreamResource", "legacy-database", ""). + WithField("spec.forProvider.configData", "eu-west-1/small/Automatic"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503: a composed resource's dry-run apply payload must be exactly what the composition + // rendered. If it carried a locally-defaulted spec.forProvider.size: small, the apply would + // claim the field away from the manager that set it to large, predicting a change real + // Crossplane (which never sends the field) would not make. The composition renders + // everything else identically, so any modification reported for the composed resource could + // only have come from the defaulted field. + "CRDDefaultedFieldOwnedByAnotherManagerIsNotReportedChanged": { + reason: "A CRD-defaulted field the composition does not render keeps another manager's non-default value in the diff", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + // The composed resource deliberately has no controlling XR, as after its XR was deleted with + // --cascade=orphan: the XR is new, and what this case pins is the composed resource's field + // managers, which an orphan keeps. Giving it an XR would make the XR modified rather than new. + fieldManagerApplies: []integration.FieldManagerApply{ + { + File: "testdata/diff/resources/existing-defaulted-downstream-composed.yaml", + FieldManager: "apiextensions.crossplane.io/composed/2b9d3c4e-0000-4000-8000-000000000503", + }, + { + File: "testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml", + FieldManager: "platform-operator", + }, + }, + expectedStructured: simtest.Expect(). + WithSummary(1, 0, 0). + WithAddedResource("XNopResource", "test-resource", "default"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503, for the XR itself. The XR is defaulted before rendering, and must be: the composition + // has to see the spec Crossplane would. But the XR's own dry-run apply payload must not carry + // those defaults, or it claims spec.region from the manager that set it to eu-west-1 and reports + // a change applying the manifest would not make. Nothing is composed, so the XR is the only + // thing that could be reported changed. + "XRDDefaultedFieldOwnedByAnotherManagerIsNotReportedChanged": { + reason: "An XRD-defaulted field the XR manifest omits keeps another manager's non-default value in the diff", + outputFormat: "json", + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + fieldManagerApplies: []integration.FieldManagerApply{ + {File: "testdata/diff/xr-with-missing-defaults.yaml", FieldManager: "kubectl"}, + {File: "testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml", FieldManager: integration.CompositeReconcilerFieldManager}, + {File: "testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml", FieldManager: "platform-operator"}, + }, + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedError: false, + expectedExitCode: dp.ExitCodeSuccess, + }, + // With no apiserver result for an addition, its defaults have to be predicted locally. These + // pin that keeping local defaults out of dry-run payloads did not also take them out of the + // additions that are never sent: an XRD default on the XR, and a CRD default on a composed + // resource. + "XRDDefaultsShownForAddedXRWithoutDryRun": { + reason: "With --dry-run-on=existing an added XR still shows the defaults its XRD declares", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/xr-with-missing-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(1, 0, 0). + WithAddedResource("XTestDefaultResource", "test-resource-with-defaults", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.region", "us-east-1"). + WithField("spec.size", "large"). + WithField("spec.tags.environment", "development"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "CRDDefaultsShownForAddedComposedResourceWithoutDryRun": { + reason: "With --dry-run-on=existing an added composed resource still shows the defaults its CRD declares", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDefaultedDownstreamResource", "test-resource-defaulted", "default"). + WithDryRunSkipped("disabled", ""). + WithField("spec.forProvider.size", "small"). + WithField("spec.forProvider.configData", "new-value"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The local prediction defaults an addition against the version it was requested at. Its CRD + // stores another version, whose defaults (and conversion) the cluster may also apply, so the + // prediction cannot be trusted to match and the advisory says so — even under + // --dry-run-on=existing, whose unverified additions are otherwise not warned about. Defaulting + // itself is unchanged: size is the requested version's default, not the storage version's. + // The XR's own XRD has one version, so it raises no warning, which the count pins. + "AdditionAtANonStorageVersionWarnsThatItsDiffOmitsTheStorageVersion": { + reason: "With --dry-run-on=existing an addition requested at a version its CRD does not store warns that the cluster stores it at another version", + outputFormat: "json", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XVersionedDownstreamResource", "test-resource-versioned", "default"). + WithDryRunSkipped("disabled", ""). + WithDryRunStorageVersion("v1"). + WithField("spec.forProvider.size", "small"). + And(). + WithAddedResource("XNopResource", "test-resource", "default"). + WithDryRunSkipped("disabled", ""). + And(). + WithWarning("they are stored at a version other than the one requested"). + WithWarningContext(map[string]string{ + "gvk": "ns.nop.example.org/v1alpha1, Kind=XVersionedDownstreamResource", + "namespace": "default", + "versions": "requested v1alpha1, stored v1", + "count": "1", + }), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "AdditionAtANonStorageVersionWarnsOnStderr": { + reason: "In text mode the storage-version note reaches stderr, the only place a person sees it", + dryRunOn: "existing", + inputFiles: []string{"testdata/diff/new-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-with-non-storage-version-downstream.yaml", + "testdata/diff/resources/functions.yaml", + }, + noColor: true, + expectedStderrContains: []string{ + `WARNING: did not verify added resources against the apiserver (--dry-run-on=existing); they are stored at a version other than the one requested, ` + + `whose defaults and conversion the cluster may apply but their diffs do not ` + + `(count=1, gvk=ns.nop.example.org/v1alpha1, Kind=XVersionedDownstreamResource, namespace=default, versions=requested v1alpha1, stored v1)`, + }, + expectedOutput: "+++ XVersionedDownstreamResource/test-resource-versioned", + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "XRDDefaultsNoOverride": { + reason: "Validates that XRD defaults do not override user-specified values", + outputFormat: "json", + inputFiles: []string{"testdata/diff/xr-with-overridden-defaults.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd-with-defaults.yaml", + "testdata/diff/resources/composition-with-defaults.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedStructured: simtest.Expect(). + WithSummary(1, 0, 0). + WithAddedResource("XTestDefaultResource", "test-resource-with-overrides", "default"). + WithField("spec.region", "us-west-2"). + WithField("spec.size", "xlarge"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "NewNestedXRCreatesChildren": { + reason: "Validates that new nested XR creates child XR and downstream resources", + outputFormat: "json", + setupFiles: []string{ + // XRDs for parent and child + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + // Compositions for parent and child + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + // XRD for downstream managed resource + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-nested-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(3, 0, 0). + WithAddedResource("XChildResource", "test-parent-child", "default"). + WithField("spec.childField", "parent-value"). + And(). + WithAddedResource("XDownstreamResource", "test-parent-child-managed", "default"). + WithField("spec.forProvider.configData", "parent-value"). + And(). + WithAddedResource("XParentResource", "test-parent", "default"). + WithField("spec.parentField", "parent-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #485. The unit test for the depth guard uses a render stub that caps its own recursion, + // so it cannot show what a real cycle does across real renders: real templates, real names that + // grow at every level (kept under the generated CRDs' 63-character limit by a short suffix), real + // schema validation and dry-run at each level. This drives the whole path. It fails at exactly one level past the + // default --max-nested-depth of 10, attributed to the XR the user named. + "CyclicCompositionStopsAtMaxNestedDepth": { + reason: "A composition cycle (XCycleA -> XCycleB -> XCycleA ...) must stop at --max-nested-depth with a clear error, not recurse until the stack overflows (#485)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/cycle/xrds.yaml", + "testdata/diff/resources/cycle/compositions.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-cycle-xr.yaml"}, + expectedError: true, + expectedErrorContains: "maximum nesting depth exceeded: XCycleB/test-cycle-c-c-c-c-c-c-c-c-c-c-c (nested depth 11) is nested 11 levels deep, but --max-nested-depth is 10", + expectedExitCode: dp.ExitCodeToolError, + expectedStructured: simtest.Expect(). + WithError("XCycleA/test-cycle"). + WithMessageContaining("maximum nesting depth exceeded"), + }, + "ModifiedNestedXRPropagatesChanges": { + reason: "Validates that modified nested XR propagates changes through child XR to downstream resources", + outputFormat: "json", + setupFiles: []string{ + // XRDs for parent and child + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + // Compositions for parent and child + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + // XRD for downstream managed resource + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + // Existing resources: the parent XR composes the child XR, which composes the managed resource + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 3, 0). + WithModifiedResource("XChildResource", "test-parent-child", "default"). + WithFieldChange("spec.childField", "existing-value", "modified-value"). + And(). + WithModifiedResource("XDownstreamResource", "test-parent-child-managed", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XParentResource", "test-parent", "default"). + WithFieldChange("spec.parentField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // #503, for a nested XR. It is defaulted before its own render, so that render input carries + // spec.tier: standard although the parent's composition never sets it. Sending it would claim + // spec.tier from the manager that set it to premium. + "XRDDefaultedFieldOnNestedXROwnedByAnotherManagerIsNotReportedChanged": { + reason: "An XRD-defaulted field a parent composition omits from a nested XR keeps another manager's non-default value in the diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + fieldManagerApplies: []integration.FieldManagerApply{ + {File: "testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml", FieldManager: "platform-operator"}, + }, + inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 3, 0). + WithModifiedResource("XChildResource", "test-parent-child", "default"). + WithFieldChange("spec.childField", "existing-value", "modified-value"). + WithFieldChange("spec.tier", "premium", "premium"). + And(). + WithModifiedResource("XDownstreamResource", "test-parent-child-managed", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XParentResource", "test-parent", "default"). + WithFieldChange("spec.parentField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Composition Revision tests for v2 XRDs + "V2ManualPolicyPinnedRevision": { + reason: "Validates v2 XR with Manual update policy stays on pinned revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-manual-v1.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2AutomaticPolicyLatestRevision": { + reason: "Validates v2 XR with Automatic update policy uses latest revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + // The XR is still on v1, and its composed resource holds v1's data. + integration.Composes( + "testdata/diff/resources/existing-xr-automatic.yaml", + "testdata/diff/resources/existing-downstream-automatic.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-automatic", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-automatic", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2AutomaticPolicyRevisionSelectorPinsMatchingRevision": { + // Issue #388 (Bug B): under Automatic policy WITH a compositionRevisionSelector, the XR + // resolves to the latest revision MATCHING the selector, not the latest overall. Here the + // selector pins channel=stable (revision 1) even though channel=preview (revision 2) is + // newer — so the diff must render against the stable template (stable-*), not preview-*. + reason: "Validates v2 Automatic XR with compositionRevisionSelector resolves to the selector-matching revision, not the newest overall", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-stable.yaml", // revision 1, channel=stable + "testdata/diff/resources/composition-revision-preview.yaml", // revision 2, channel=preview (newest) + "testdata/diff/resources/composition-v2.yaml", // current composition + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-selector-stable.yaml", + "testdata/diff/resources/existing-downstream-selector-stable.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-selector-stable.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-selector", "default"). + // stable-* (not preview-*) proves the selector, not "latest overall", chose the revision. + WithFieldChange("spec.forProvider.configData", "stable-existing-value", "stable-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-selector", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2AutomaticPolicyRevisionSelectorNoMatchErrors": { + // The mirror of the above: a selector matching NO revision must fail the diff rather than + // silently rendering against a non-matching revision (accuracy over guessing). + reason: "Validates v2 Automatic XR whose compositionRevisionSelector matches no revision fails the diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-stable.yaml", + "testdata/diff/resources/composition-revision-preview.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/modified-xr-selector-nomatch.yaml"}, + expectedError: true, + expectedErrorContains: "match selector", + expectedExitCode: dp.ExitCodeToolError, + }, + "V2ManualRevisionUpgradeDiff": { + reason: "Validates v2 XR changing revision in Manual mode shows upgrade diff", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-manual-upgrade-to-v2.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "CompositionRevisionUpgradesResourceAPIVersion": { + // NOTE: This test validates that resources are correctly matched across API version changes, + // avoiding delete/recreate. The composition template changes from v1beta1 to v1beta2, but + // Kubernetes automatically converts resources between served API versions. When we query for + // the v1beta2 resource, Kubernetes finds the v1beta1 resource and returns it auto-converted + // to v1beta2. From Kubernetes' perspective, the resource exists as both versions simultaneously, + // so there's no apiVersion field change to show in the diff. The important thing is that the + // resource is matched (unchanged, not ---/+++), preventing delete/recreate operations. + // + // The XR composes the resource as Crossplane leaves it: controlled by the XR, and named in the + // XR's resourceRefs at v1beta1, the version it was composed at. Crossplane's garbage collector + // matches what an XR composed to what it renders by composition resource name, so it keeps the + // resource; removal detection must too, rather than report the v1beta1 reference unmatched by + // the v1beta2 render (#552). + reason: "Validates XR upgrading composition revision that changes resource API version shows as update not remove/add", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + // NOTE: xapimigrate CRD is auto-loaded from testdata/diff/crds/ + // We apply no XRD for XApiMigrateResource because it is a regular managed resource + // (not a composite), and an XRD would make it composite. + "testdata/diff/resources/api-version-composition-revision-v1.yaml", + "testdata/diff/resources/api-version-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-api-version-xr-rev1.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2.yaml"}, + // Key assertion: nothing is added or removed, proving API version migration works. The + // XApiMigrateResource is stored identically at either version, so it renders unchanged; an + // unmatched one would be a +++ addition plus a --- removal. Only the XR's revision ref changes. + expectedStructured: simtest.Expect(). + WithSummary(0, 1, 0). + WithModifiedResource("XNopResource", "test-api-version-xr", "default"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "CompositionRevisionUpgradesResourceAPIVersionAndChangesIt": { + // The same upgrade, with a change to the XR that the new revision renders into the resource too. + // The resource is one object, modified once at the version it is rendered at. Matched by API + // version, the v1beta1 reference was a removal contradicting that modification, and the xr + // command refused to combine the two diffs of one object (#552). + reason: "Validates an API version change plus a field change is one modification, not a removal and a modification", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/api-version-composition-revision-v1.yaml", + "testdata/diff/resources/api-version-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/existing-api-version-xr-rev1.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml": nil, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-api-version-xr-rev2-changed-field.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XApiMigrateResource", "test-api-version-xr-api-resource", "default"). + WithFieldChange("spec.forProvider.configData", "test-value", "changed-value"). + And(). + WithModifiedResource("XNopResource", "test-api-version-xr", "default"). + WithFieldChange("spec.coolField", "test-value", "changed-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2SwitchManualToAutomatic": { + reason: "Validates v2 XR switching from Manual to Automatic mode uses latest revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-switch-to-automatic.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "V2NetNewManualNoRevRef": { + reason: "Validates v2 net new XR with Manual policy but no revision ref uses latest revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-xr-manual-no-ref.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-manual-no-ref", "default"). + WithField("spec.forProvider.configData", "v2-new-value"). + And(). + WithAddedResource("XNopResource", "test-manual-no-ref", "default"). + WithField("spec.coolField", "new-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Composition Revision tests for v1 XRDs (Crossplane 1.20 compatibility) + "V1ManualRevisionUpgradeDiff": { + reason: "Validates v1 XR with Manual update policy changing revision shows upgrade diff", + outputFormat: "json", + xrdAPIVersion: integration.XRDV1, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-legacy-xr-manual-v1.yaml", + "testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v2-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #499: Crossplane's composite reconciler writes compositionRevisionRef, so a hand-written + // manifest omits it — and an apply that omits a field leaves it where it is. The render must + // therefore see the cluster copy's ref. Here the composition propagates the revision name into the + // composed resource, so a render that dropped the ref reported configData changing from the real + // revision name to empty for an input identical to what is deployed. + "ExistingXROmittingRevisionRefRendersWithClusterRef": { + reason: "An existing XR whose input omits compositionRevisionRef renders with the cluster's ref, so a template reading the revision name shows no spurious change", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/comp/resources/revision-templating-composition.yaml", + "testdata/comp/resources/revision-templating-composition-revision.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/comp/resources/existing-xr-revision-ref.yaml", + "testdata/comp/resources/existing-downstream-revision-ref.yaml", + ), + }, + inputFiles: []string{"testdata/diff/existing-xr-revision-ref-omitted.yaml"}, + expectedStructured: simtest.Expect().WithSummary(0, 0, 0), + expectedExitCode: dp.ExitCodeSuccess, + }, + // Issue #499, the consequence for which revision renders. The cluster's ref is inherited before the + // composition is resolved, so a Manual composite whose input omits the ref stays on the revision it + // is pinned to (-911318d, the v1 template), as it would in the cluster, instead of being rendered + // against the latest one (-430f3f3, v2). Compare V2ManualPolicyPinnedRevision, the same change with + // the ref spelled out. + "V2ManualPolicyOmittedRevisionRefStaysPinned": { + reason: "Validates v2 XR with Manual update policy whose input omits compositionRevisionRef stays on the cluster's pinned revision", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-xr-manual-v1.yaml", + "testdata/diff/resources/existing-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-manual-v1", "default"). + // v1-* (not v2-*) proves the pinned revision, not the latest, rendered. + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-manual-v1", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // The v1-path (spec.compositionRevisionRef) twin of the above. + "V1ManualPolicyOmittedRevisionRefStaysPinned": { + reason: "Validates v1 XR with Manual update policy whose input omits compositionRevisionRef stays on the cluster's pinned revision", + outputFormat: "json", + xrdAPIVersion: integration.XRDV1, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-legacy-xr-manual-v1.yaml", + "testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.forProvider.configData", "v1-existing-value", "v1-modified-value"). + And(). + WithModifiedResource("XNopResource", "test-legacy-manual-v1", ""). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #499 for a nested XR. It is rendered from its parent's output, which never carries + // compositionRevisionRef, so it must inherit the cluster copy's, which ProcessNestedXRs hands it + // rather than fetching it again. The child's composition propagates the revision name into the + // managed resource; the parent's change reaches the child's childField but not that name, so the + // managed resource must not be reported. + "NestedXRInheritsClusterRevisionRef": { + reason: "A nested XR renders with its cluster copy's compositionRevisionRef, so a template reading the revision name shows no spurious change", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/nested/parent-xrd.yaml", + "testdata/diff/resources/nested/child-xrd.yaml", + "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/child-revision-templating-composition.yaml", + "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml": nil, + }, + }, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-nested-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XChildResource", "test-parent-child", "default"). + WithFieldChange("spec.childField", "existing-value", "modified-value"). + // The nested XR's own reconciler wrote the ref, not the parent's composed apply, so the + // parent's render, which omits it, leaves it in place. + WithFieldChange("spec.crossplane.compositionRevisionRef.name", + "xchildresources.nested.example.org-96afc06", "xchildresources.nested.example.org-96afc06"). + And(). + WithModifiedResource("XParentResource", "test-parent", "default"). + WithFieldChange("spec.parentField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #498, as a user meets it: an Automatic claim's manifest omits the compositionRevisionRef the + // claim syncer copied onto the cluster copy. Under Automatic the claim's ref is never propagated to + // the backing XR anyway; the XR controller owns the XR's own, which is what render must see. Before, + // the backing XR's ref was dropped, so the template read no revision name and configData was + // reported changing to "v2::existing-value" for an unchanged claim. + "AutomaticClaimOmittingRevisionRefRendersWithBackingXRRef": { + reason: "An Automatic claim renders with its backing XR's compositionRevisionRef, so a template reading the revision name shows no spurious change", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/pinned-claim/definitions.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/pinned-claim/existing-automatic.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml", + ), + }, + xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/unchanged-automatic-pinned-claim.yaml"}, + // No warnings either: the composed resource belongs to the claim's backing XR (#534). + expectedStructured: simtest.Expect().WithNoWarnings().WithSummary(0, 0, 0), + expectedExitCode: dp.ExitCodeSuccess, + }, + // A Manual claim's pin lives on its backing XR: the claim syncer copies the ref back to a claim only + // under Automatic. Composition resolution and render must both see that pin. Before, the composition + // was resolved from the claim, which names no revision, so the latest one (v2) was chosen, while the + // render read the backing XR's ref (revision 1): configData became "v2:...-3d33597:...", a revision's template + // paired with another revision's name. + "ManualClaimRendersAgainstItsBackingXRsPinnedRevision": { + reason: "A Manual claim whose backing XR is pinned to an older revision renders that revision, with that revision's name", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/pinned-claim/definitions.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/pinned-claim/existing-manual.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/pinned-claim/existing-manual-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml", + ), + }, + xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/modified-manual-pinned-claim.yaml"}, + expectedStructured: simtest.Expect(). + // The composed resource is labelled with the backing XR's name, which the claim's resourceRef + // names, so it already belongs to this claim: no ownership advisory (#534). + WithNoWarnings(). + WithSummary(0, 2, 0). + WithModifiedResource("Pinned", "manual-claim", "existing-namespace"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"). + And(). + WithModifiedResource("XDownstreamResource", "manual-claim-x1y2z", ""). + WithFieldChange("spec.forProvider.configData", + "v1:xpinneds.pinned.diff.example.org-3d33597:existing-value", + "v1:xpinneds.pinned.diff.example.org-3d33597:modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Issue #536. Crossplane's composite reconciler resolves the revision, writes it to + // compositionRevisionRef, and then composes, so an Automatic composite whose ref lags behind the + // latest revision is composed from the latest one AND sees its name. Before, the latest revision + // rendered while the template read the stale ref: configData became "v2:...-3d33597:...", a revision's + // template paired with another revision's name. + "AutomaticXRWithLaggingRevisionRefRendersLatestRevisionAndName": { + reason: "An Automatic XR whose cluster ref names an older revision renders the latest revision, with that revision's name", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/pinned-claim/definitions.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml", + "testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml", + ), + }, + xrdAPIVersion: integration.XRDV1, // the XRD is v1, so Crossplane writes the XR's resourceRefs at spec.resourceRefs + inputFiles: []string{"testdata/diff/unchanged-lagging-automatic-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 1, 0). + WithModifiedResource("XDownstreamResource", "lagging-xr", ""). + WithFieldChange("spec.forProvider.configData", + "v1:xpinneds.pinned.diff.example.org-3d33597:existing-value", + "v2:xpinneds.pinned.diff.example.org-806289b:existing-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // An XRD's defaultCompositionUpdatePolicy applies to a new composite before Crossplane resolves its + // revision, so a new composite that names a revision and leaves the policy to an XRD defaulting to + // Manual renders the revision it names. Before, the composition was resolved before defaulting, as + // Automatic, so the latest revision (v2) rendered. + "NewXRUnderManualDefaultingXRDRendersTheRevisionItNames": { + reason: "A new XR whose XRD defaults its update policy to Manual is resolved as Manual", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/manual-default/definitions.yaml", + "testdata/diff/resources/functions.yaml", + }, + inputFiles: []string{"testdata/diff/new-manual-default-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XManualDefault", "new-manual-default", "default"). + And(). + WithAddedResource("XDownstreamResource", "new-manual-default", "default"). + WithField("spec.forProvider.configData", "v1-new-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // Crossplane selects a composition only for a composite with no compositionRef, and never + // re-selects. A composite whose input relies on a selector therefore keeps the compositionRef + // Crossplane wrote, here composition A, even though the selector now matches two compositions. + // Before, the selector was re-evaluated against the input, and the diff failed as ambiguous. + "ExistingXRSelectingItsCompositionKeepsTheClusterCompositionRef": { + reason: "An existing XR whose input selects its composition by label renders with the composition Crossplane already bound it to", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/selector-bound/setup.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/selector-bound/existing-xr.yaml", + "testdata/diff/resources/selector-bound/existing-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/modified-xr-selector-bound.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "selector-bound", "default"). + WithFieldChange("spec.forProvider.configData", "a-existing-value", "a-modified-value"). + And(). + WithModifiedResource("XNopResource", "selector-bound", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // v2 XRD with v1-style composition paths (issue #206) + "V2XRDWithV1StyleCompositionPaths": { + reason: "Validates v2 XRD using v1-style spec.compositionRef paths are correctly recognized (issue #206)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/v2-xrd-with-v1-paths.yaml", + "testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes( + "testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml", + "testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml", + ), + }, + inputFiles: []string{"testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 2, 0). + WithModifiedResource("XDownstreamResource", "test-v2xrd-v1paths", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "modified-value"). + And(). + WithModifiedResource("XNopResource", "test-v2xrd-v1paths", "default"). + WithFieldChange("spec.coolField", "existing-value", "modified-value"), + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + // A claim passed alongside its backing XR: Crossplane's claim controller writes that XR (a + // server-side apply with forced ownership, stamping claimRef and the claim labels), so the XR + // input would be a second writer. Rejected on the XR; the claim's diff still runs. The binding + // is read from the claim's cluster copy (spec.resourceRef) and from the XR's claim labels. + "ClaimPassedWithItsBackingXRIsRejected": { + reason: "A claim and the XR bound to it, passed together, is an input error on the XR", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/child-definition.yaml", + "testdata/diff/resources/claim-nested/parent-composition.yaml", + "testdata/diff/resources/claim-nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/claim-nested/existing-claim.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + OwnerFile: "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-child-xr.yaml": { + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + xrdAPIVersion: integration.XRDV1, // the XRDs are v1, so Crossplane writes the XRs' resourceRefs at spec.resourceRefs + inputFiles: []string{ + "testdata/diff/modified-claim-nested.yaml", + "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + }, + expectedError: true, + expectedErrorContains: "XNopParent/existing-parent-claim-82crv is the XR bound to claim " + + "ParentNopClaim/existing-parent-claim, which Crossplane's claim controller writes", + expectedExitCode: dp.ExitCodeToolError, + }, + "ModifiedClaimWithNestedXRsShowsDiff": { + reason: "Validates that modified Claims with nested XRs show proper diff (3 modified resources)", + outputFormat: "json", + setupFiles: []string{ + "testdata/diff/resources/existing-namespace.yaml", + // XRDs for parent and child Claims (the harness generates their XR and Claim CRDs) + "testdata/diff/resources/claim-nested/parent-definition.yaml", + "testdata/diff/resources/claim-nested/child-definition.yaml", + // Compositions for parent and child + "testdata/diff/resources/claim-nested/parent-composition.yaml", + "testdata/diff/resources/claim-nested/child-composition.yaml", + "testdata/diff/resources/functions.yaml", + // Claim is set up separately (not via owner refs - it uses spec.resourceRef to link to backing XR) + "testdata/diff/resources/claim-nested/existing-claim.yaml", + }, + // Owner ref hierarchy matches Crossplane's actual ownership model: + // - Backing XR is the root of the owner ref tree (no owner refs) + // - Nested XR has owner ref to backing XR + // - Managed resource has owner ref to nested XR + // Note: Claim is NOT in this hierarchy - it links to backing XR via spec.resourceRef, not owner refs + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + { + // Backing XR is the root of owner ref tree + OwnerFile: "testdata/diff/resources/claim-nested/existing-parent-xr.yaml", + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + // Nested XR owned by backing XR + "testdata/diff/resources/claim-nested/existing-child-xr.yaml": { + // Managed resource owned by nested XR + OwnedFiles: map[string]*integration.HierarchicalOwnershipRelation{ + "testdata/diff/resources/claim-nested/existing-managed-resource.yaml": nil, + }, + }, + }, + }, + }, + inputFiles: []string{"testdata/diff/modified-claim-nested.yaml"}, + expectedStructured: simtest.Expect(). + WithSummary(0, 3, 0). + WithModifiedResource("ClusterNopResource", "existing-parent-claim-82crv-nop", ""). + And(). + WithModifiedResource("ParentNopClaim", "existing-parent-claim", "default"). + WithFieldChange("spec.parentField", "existing-parent-value", "modified-parent-value"). + And(). + WithModifiedResource("XNopChild", "existing-parent-claim-82crv-child", ""). + WithFieldChange("spec.childField", "existing-parent-value", "modified-parent-value"). + // The nested XR's own reconciler wrote its composition refs, not the parent's composed + // apply, so the parent's render, which omits them, leaves them in place. + WithFieldChange("spec.compositionRef.name", "child-nop-claim-composition", "child-nop-claim-composition"). + WithFieldChange("spec.compositionRevisionRef.name", "child-nop-claim-composition-db22ad5", "child-nop-claim-composition-db22ad5"), + xrdAPIVersion: integration.XRDV1, // Use V1 style resourceRefs since XRDs have claims + expectedError: false, + expectedExitCode: dp.ExitCodeDiffDetected, + }, + "FunctionCredentialsAutoFetch": { + reason: "Successfully renders XR when composition references credentials that exist in cluster", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr-with-creds.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/credentials/composition-with-creds.yaml", + "testdata/diff/resources/credentials/function-credentials-secret.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). + WithField("spec.forProvider.configData", "test-value-creds"). + And(). + WithAddedResource("XNopResource", "test-resource-with-creds", "default"). + WithField("spec.coolField", "test-value-creds"), + expectedError: false, + }, + "FunctionCredentialsFromCLI": { + reason: "Successfully renders XR when credentials provided via --function-credentials flag", + outputFormat: "json", + inputFiles: []string{"testdata/diff/new-xr-with-creds.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/credentials/composition-with-creds.yaml", + // Note: NOT setting up the secret in cluster - testing CLI override + "testdata/diff/resources/functions.yaml", + }, + // Credentials loaded from CLI flag file + functionCredentials: "testdata/diff/resources/credentials/cli-credentials.yaml", + expectedExitCode: dp.ExitCodeDiffDetected, + // WithNoWarnings is the point of this case for issue #481: the secret the composition + // references is deliberately absent from the cluster and supplied by --function-credentials + // instead, so the shortfall advisory must NOT fire. It used to, because the advisory was + // raised inside the credential client, which runs before the CLI-supplied secrets are merged + // in — telling a user who had just solved the problem to go and solve it. + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithNoWarnings(). + WithAddedResource("XDownstreamResource", "test-resource-with-creds", "default"). + WithField("spec.forProvider.configData", "test-value-creds"). + And(). + WithAddedResource("XNopResource", "test-resource-with-creds", "default"). + WithField("spec.coolField", "test-value-creds"), + expectedError: false, + }, + // Paired sequencer gating tests — the fixture sequencer-gating-composition.yaml + // is deliberately correct (rules match composition-resource-names stage0-/stage1- + // AND auto-ready runs BEFORE sequencer so DesiredComposed.Ready is populated + // before sequencer's gating check). Together these two cases discriminate + // --eventual-state from default behavior: only eventual-state surfaces stage1. + "SequencerGatesLaterStageWithoutEventualState": { + reason: "Without --eventual-state, function-sequencer hides stage1 until stage0 is ready", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated). + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "sequencer-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + "SequencerGatedStageAppearsWithEventualState": { + reason: "With --eventual-state, sequencer-gated stage1 surfaces via synthesize-Ready iteration", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added (stage1 unlocked via eventual-state). + expectedStructured: simtest.Expect(). + WithSummary(3, 0, 0). + WithAddedResource("XNopResource", "sequencer-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + // Issue #564: an existing XR whose stage0 exists but is not Ready yet, the normal state of an + // XR whose first stage is still provisioning. Nothing renders that is not already observed, so + // eventual state used to stop at once and never synthesize stage0's Ready; but Crossplane + // composes stage1 as soon as stage0 becomes Ready. + "SequencerGatedStageAppearsForExistingXRWithNotReadyStage0WithEventualState": { + reason: "With --eventual-state, stage1 surfaces when stage0 already exists but is not Ready", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("testdata/diff/resources/existing-sequencer-gating-xr.yaml", "testdata/diff/resources/existing-sequencer-gating-stage0.yaml"), + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR and stage0 modified, stage1 added (unlocked by stage0's synthesized Ready). + expectedStructured: simtest.Expect(). + WithSummary(1, 2, 0). + WithModifiedResource("XNopResource", "sequencer-test", "default"). + WithFieldChange("spec.coolField", "existing-value", "test-value"). + And(). + WithModifiedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + // The converged counterpart: both stages exist and are Ready, so synthesizing Ready changes + // nothing and eventual state is the first render. Only the spec changes the input makes show. + "EventualStateAddsNothingWhenEveryStageIsReady": { + reason: "With --eventual-state, an XR whose composed resources are all Ready shows only its real changes", + timeout: longTimeout, + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + crossplaneManagedResources: []integration.HierarchicalOwnershipRelation{ + integration.Composes("testdata/diff/resources/existing-sequencer-gating-xr.yaml", + "testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml", + "testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml"), + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithSummary(0, 3, 0). + WithModifiedResource("XNopResource", "sequencer-test", "default"). + WithFieldChange("spec.coolField", "existing-value", "test-value"). + And(). + WithModifiedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "test-value"). + And(). + WithModifiedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithFieldChange("spec.forProvider.configData", "existing-value", "test-value"), + expectedError: false, + }, + // Paired composition-driven gating tests — the fixture conditional-gating-composition.yaml + // encodes gating in go-templating itself (no function-sequencer): stage1 is only rendered + // when observed stage0 has Ready=True. This isolates eventual-state simulation from + // function-sequencer and exercises the synthesize-Ready path directly. + "ConditionalGatingHidesLaterStageWithoutEventualState": { + reason: "Without --eventual-state, template conditionally gates stage1 because observed stage0 is not Ready", + timeout: longTimeout, + outputFormat: "json", + // eventualState deliberately false + inputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 added; stage1 NOT present (gated by the template's Ready check). + expectedStructured: simtest.Expect(). + WithSummary(2, 0, 0). + WithAddedResource("XNopResource", "conditional-gating-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + "ConditionalGatedStageAppearsWithEventualState": { + reason: "With --eventual-state, synthesize-Ready flips stage0 Ready=True in observed so the template renders stage1", + timeout: longTimeout, // Multiple simulation iterations need more time + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/conditional-gating-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/conditional-gating-composition.yaml", + "testdata/diff/resources/functions.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + // Expect: XR + stage0 + stage1 all added. + expectedStructured: simtest.Expect(). + WithSummary(3, 0, 0). + WithAddedResource("XNopResource", "conditional-gating-test", "default"). + WithField("spec.coolField", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"), + expectedError: false, + }, + "EventualStateWithSequencerAndEnvironmentConfigs": { + reason: "Shows eventual state with function-sequencer AND function-environment-configs to verify requirements resolution during simulation", + timeout: longTimeout, // Multiple simulation iterations with requirements resolution need more time + // This test verifies that the eventual state simulation correctly resolves requirements + // (like environment configs) during its iterative rendering, not just in the final render. + // The composition uses: + // 1. function-environment-configs to fetch env config (requires requirements iteration) + // 2. function-go-templating to generate resources using env config data + // 3. function-sequencer to stage resources + // 4. function-auto-ready + outputFormat: "json", + eventualState: true, + inputFiles: []string{"testdata/diff/resources/sequencer-with-envconfig-xr.yaml"}, + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/sequencer-with-envconfig-composition.yaml", + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/environment-config-v1beta1.yaml", + }, + expectedExitCode: dp.ExitCodeDiffDetected, + expectedStructured: simtest.Expect(). + WithSummary(3, 0, 0). // 2 downstream resources + 1 XR + WithAddedResource("XDownstreamResource", "0-stage0-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "staging"). // From environment config + And(). + WithAddedResource("XDownstreamResource", "1-stage1-resource", "default"). + WithField("spec.forProvider.configData", "test-value"). + WithField("spec.forProvider.resourceTier", "staging"). // From environment config + And(). + WithAddedResource("XNopResource", "sequencer-envconfig-test", "default"). + WithField("spec.coolField", "test-value"), + expectedError: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + runIntegrationTest(t, XRDiffTest, tt) + }) + } +} + // TestCompDiffIntegration runs an integration test for the composition diff command. func TestCompDiffIntegration(t *testing.T) { t.Parallel() @@ -568,7 +3705,7 @@ Summary: 2 modified`, WithDownstreamSummary(1, 0, 0). WithDownstreamResource("added", "XDownstreamResource", "test-resource", "default"), }, - // Regression test for #552: + // The comp side of TestDiffIntegration/ResourceRemovalSkipsResourcesTheXRDoesNotControl (#552): // the updated composition stops rendering resource2, which the XR controls, so it is removed; // the XR's resourceRefs also name a resource another XR controls and one with no controller, // which the composition doesn't render either, and neither of those is removed. @@ -1416,7 +4553,7 @@ Summary: 1 modified`, // +++ addition and a --- removal. The composition diff itself WILL show the template change // from v1beta1 to v1beta2. // - // The XR composes the + // As in TestDiffIntegration/CompositionRevisionUpgradesResourceAPIVersion, the XR composes the // resource as Crossplane leaves it, named in its resourceRefs at v1beta1, so this also checks // that it is not reported as removed (#552). reason: "Validates composition upgrade that changes resource API version shows as update not remove/add", diff --git a/cmd/crossplane/project/testdata/README.md b/cmd/crossplane/project/testdata/README.md index 11bc26cd..4386420b 100644 --- a/cmd/crossplane/project/testdata/README.md +++ b/cmd/crossplane/project/testdata/README.md @@ -1,34 +1,40 @@ -# Simulate integration tests +# Simulate integration tests (crossplane-diff port) -The `integration*_test.go` files in `cmd/crossplane/project` run -`crossplane project simulate -f ` against fixture projects -under `testdata/project`, each against its own envtest cluster seeded from -`testdata/comp/resources`. `TestCompDiffIntegration` runs -`crossplane composition simulate -f ` with the -crossplane-diff cases and fixtures under `testdata/comp`, plus a few shared -ones under `testdata/diff`. +The `integration_*_test.go` files in `cmd/crossplane/project` port +crossplane-diff's integration tests (origin/main 6a140b6: +`cmd/diff/diff_integration_test.go`, `diff_it_utils_test.go`, `main_test.go` +and the `testdata` tree) case for case. The case tables are verbatim; only +the harness wiring differs. ## How a case runs -A case copies its project fixture to a temp dir and runs the real command -through kong with `runSimulateArgvWith` in -`integration_simulate_harness_test.go`. `simulate.Hooks` points the -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. +Every case is crossplane-diff CLI-style arguments plus an expected exit +code and expected output, funneled through one function: `runSimulate` in +`integration_simulate_harness_test.go`. It translates the arguments into +`crossplane project simulate` arguments (input files as `-f`, a single +`--resource` as `--name`, `--include-manual` as `--include-pinned`, +`--analyze-unchanged` as `--analyze-on=always`) and runs the real command +through kong. `simulateHooks` points the command at the case's +envtest cluster in place of a kubeconfig, so the cases run in parallel. Composition cases run simulate's Composition-file +mode, XR cases its composite resource mode. ## Skips -A case the environment cannot run skips with an `env:`: the -render engine runs functions as Docker containers, so no Docker daemon -skips the case. Each case starts its cluster with -`internal/testutils/integration`'s `Start`, which fails the case, rather -than skipping it, when `KUBEBUILDER_ASSETS` or `CROSSPLANE_CRDS_DIR` is -unset. +Cases are never dropped. A case the current simulate surface cannot +express is ported in full and skipped with a greppable reason: + +- `gap:` — the simulate command lacks the feature, or behaves + differently from crossplane-diff. Enable the cases by implementing the + feature and removing the gap from `simulateGap`. +- `env:` — the environment lacks something. The render engine + runs functions as Docker containers, so no Docker daemon skips the + case. Each case starts its cluster with + `internal/testutils/integration`'s `Start`, which fails the case, + rather than skipping it, when `KUBEBUILDER_ASSETS` or + `CROSSPLANE_CRDS_DIR` is unset. + +A small number of skips come from crossplane-diff itself (`skip: true` +in the source tables, with the upstream reason carried verbatim). ## Running @@ -39,7 +45,7 @@ KUBEBUILDER_ASSETS=$(setup-envtest use -p path) \ go test -tags integration -v ./cmd/crossplane/project/ # One case: -go test -tags integration -run 'TestProjectSimulateIntegration/PlainProject' ./cmd/crossplane/project/ +go test -tags integration -run 'TestCompDiffIntegration/CompositionChangeImpactsXRs' ./cmd/crossplane/project/ ``` The suite is behind the `integration` build tag, so `go test ./...` and diff --git a/cmd/crossplane/project/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml b/cmd/crossplane/project/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml new file mode 100644 index 00000000..1d9370bb --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/clusternopresources.nop.crossplane.io.yaml @@ -0,0 +1,61 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusternopresources.nop.crossplane.io +spec: + group: nop.crossplane.io + names: + kind: ClusterNopResource + listKind: ClusterNopResourceList + plural: clusternopresources + singular: clusternopresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + conditionAfter: + type: array + items: + type: object + properties: + conditionType: + type: string + conditionStatus: + type: string + time: + type: string + status: + type: object + properties: + atProvider: + type: object + conditions: + type: array + items: + type: object + properties: + lastTransitionTime: + type: string + format: date-time + observedGeneration: + type: integer + format: int64 + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/crds/xapimigrateresource-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xapimigrateresource-crd.yaml new file mode 100644 index 00000000..3005cf3c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xapimigrateresource-crd.yaml @@ -0,0 +1,84 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xapimigrateresources.diff.example.org +spec: + group: diff.example.org + names: + kind: XApiMigrateResource + listKind: XApiMigrateResourceList + plural: xapimigrateresources + singular: xapimigrateresource + scope: Namespaced + versions: + - name: v1beta1 + served: true + storage: false + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + description: "Configuration data for the resource" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + lastTransitionTime: + type: string + reason: + type: string + message: + type: string + - name: v1beta2 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + description: "Configuration data for the resource" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + lastTransitionTime: + type: string + reason: + type: string + message: + type: string diff --git a/cmd/crossplane/project/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..78cbb0ed --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xdefaulteddownstreamresource-ns-crd.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdefaulteddownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XDefaultedDownstreamResource + listKind: XDefaultedDownstreamResourceList + plural: xdefaulteddownstreamresources + singular: xdefaulteddownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + size: + type: string + default: "small" + description: "Defaulted by the CRD; the composition never sets it" diff --git a/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml new file mode 100644 index 00000000..d26f917d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-cluster-crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml new file mode 100644 index 00000000..49c1db14 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-legacycluster-crd.yaml @@ -0,0 +1,56 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.legacycluster.nop.example.org +spec: + group: legacycluster.nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Cluster + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-ns-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..1d5e07ff --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xdownstreamresource-ns-crd.yaml @@ -0,0 +1,59 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xdownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XDownstreamResource + listKind: XDownstreamResourceList + plural: xdownstreamresources + singular: xdownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + roleName: + type: string + description: "Name of an external cluster role referenced by the resource" + resourceTier: + type: string + description: "Tier of the resource (e.g., default, production, staging)" + removableField: + type: string + description: "A field that can be removed for testing field removal detection" + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/cmd/crossplane/project/testdata/diff/crds/xstatusresource-ns-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xstatusresource-ns-crd.yaml new file mode 100644 index 00000000..b8ddfe4f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xstatusresource-ns-crd.yaml @@ -0,0 +1,66 @@ +# A composed-resource kind that declares the status SUBRESOURCE. +# +# That declaration is the entire reason this CRD exists, and it is load-bearing rather than +# incidental: with `subresources: {status: {}}` the apiserver strips `status` from a CREATE request +# and hands back an object with no status at all. Every other CRD under testdata/diff/crds omits the +# subresource, so status is simply persisted on create there — which would make a status-retention +# assertion pass whether or not the diff calculator re-attached the rendered status, i.e. prove +# nothing. +# +# Used by resources/composition-with-status.yaml to check that a status authored by a composition +# pipeline survives the apiserver round-trip for an addition (crossplane-diff#334). +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xstatusresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XStatusResource + listKind: XStatusResourceList + plural: xstatusresources + singular: xstatusresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + status: + type: object + properties: + phase: + type: string + description: "A status value written by the composition pipeline, not by the apiserver" + conditions: + type: array + items: + type: object + properties: + observedGeneration: + type: integer + format: int64 + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time diff --git a/cmd/crossplane/project/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml b/cmd/crossplane/project/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml new file mode 100644 index 00000000..16873804 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/crds/xversioneddownstreamresource-ns-crd.yaml @@ -0,0 +1,52 @@ +# A CRD that serves two versions and stores the newer one, which defaults a field differently. A +# resource requested at v1alpha1 and predicted locally is defaulted against v1alpha1 only, so its +# unverified-addition warning must name the storage version (#528). +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: xversioneddownstreamresources.ns.nop.example.org +spec: + group: ns.nop.example.org + names: + kind: XVersionedDownstreamResource + listKind: XVersionedDownstreamResourceList + plural: xversioneddownstreamresources + singular: xversioneddownstreamresource + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: false + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + size: + type: string + default: "small" + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + size: + type: string + default: "medium" diff --git a/cmd/crossplane/project/testdata/diff/existing-xr-revision-ref-omitted.yaml b/cmd/crossplane/project/testdata/diff/existing-xr-revision-ref-omitted.yaml new file mode 100644 index 00000000..fa56bf15 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/existing-xr-revision-ref-omitted.yaml @@ -0,0 +1,16 @@ +# testdata/comp/resources/existing-xr-revision-ref.yaml exactly as a user would write it: unchanged, +# but without the compositionRef and compositionRevisionRef that Crossplane's composite reconciler +# wrote into the cluster copy. Applying this leaves those refs where they are, so nothing would change — +# and the composition this composite uses (testdata/comp/resources/revision-templating-composition.yaml) +# reads the revision name into a composed resource. Only the inherited compositionRef resolves the +# revision whose name render must see, so a render that drops the cluster's refs shows a spurious +# change. See issue #499. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: revision-ref-resource + namespace: default +spec: + coolField: revision-ref-existing-value + crossplane: + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/first-xr-with-composition-ref.yaml b/cmd/crossplane/project/testdata/diff/first-xr-with-composition-ref.yaml new file mode 100644 index 00000000..9c567aa2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/first-xr-with-composition-ref.yaml @@ -0,0 +1,11 @@ +# first-xr.yaml naming its composition, so finding the composition needs no list of every Composition. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: first-resource + namespace: default +spec: + coolField: "first-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/first-xr.yaml b/cmd/crossplane/project/testdata/diff/first-xr.yaml new file mode 100644 index 00000000..cc6c2fe4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/first-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: first-resource + namespace: default +spec: + coolField: "first-value" diff --git a/cmd/crossplane/project/testdata/diff/generated-name-xr.yaml b/cmd/crossplane/project/testdata/diff/generated-name-xr.yaml new file mode 100644 index 00000000..0be857b8 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/generated-name-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + generateName: generated-xr- + namespace: default +spec: + coolField: "new-value" diff --git a/cmd/crossplane/project/testdata/diff/invalid-schema-xr.yaml b/cmd/crossplane/project/testdata/diff/invalid-schema-xr.yaml new file mode 100644 index 00000000..785a6207 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/invalid-schema-xr.yaml @@ -0,0 +1,8 @@ +# XR with invalid schema - coolField should be a string, not an integer +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: invalid-schema-xr + namespace: default +spec: + coolField: 12345 diff --git a/cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml b/cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml new file mode 100644 index 00000000..49be07d7 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2-changed-field.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version-xr + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionRevisionRef: + name: xapimigrateresources.example.org-311ee4f + compositionUpdatePolicy: Manual + coolField: changed-value diff --git a/cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2.yaml b/cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2.yaml new file mode 100644 index 00000000..0baf92f0 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-api-version-xr-rev2.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version-xr + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionRevisionRef: + name: xapimigrateresources.example.org-311ee4f + compositionUpdatePolicy: Manual + coolField: test-value diff --git a/cmd/crossplane/project/testdata/diff/modified-claim-nested.yaml b/cmd/crossplane/project/testdata/diff/modified-claim-nested.yaml new file mode 100644 index 00000000..67844ef1 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-claim-nested.yaml @@ -0,0 +1,12 @@ +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: ParentNopClaim +metadata: + name: existing-parent-claim + namespace: default + labels: + new-label: added-value +spec: + parentField: modified-parent-value + compositeDeletePolicy: Background + compositionRef: + name: parent-nop-claim-composition diff --git a/cmd/crossplane/project/testdata/diff/modified-claim-omitting-defaulted-fields.yaml b/cmd/crossplane/project/testdata/diff/modified-claim-omitting-defaulted-fields.yaml new file mode 100644 index 00000000..3c6645d8 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-claim-omitting-defaulted-fields.yaml @@ -0,0 +1,11 @@ +# modified-claim.yaml without spec.compositeDeletePolicy, which the claim's CRD defaults (as it does +# spec.compositionUpdatePolicy, also omitted). +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + coolField: modified-value + compositionRef: + name: claim-composition diff --git a/cmd/crossplane/project/testdata/diff/modified-claim.yaml b/cmd/crossplane/project/testdata/diff/modified-claim.yaml new file mode 100644 index 00000000..3116b173 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-claim.yaml @@ -0,0 +1,10 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Background + coolField: modified-value + compositionRef: + name: claim-composition diff --git a/cmd/crossplane/project/testdata/diff/modified-cluster-xr.yaml b/cmd/crossplane/project/testdata/diff/modified-cluster-xr.yaml new file mode 100644 index 00000000..dcbaf051 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-cluster-xr.yaml @@ -0,0 +1,6 @@ +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + coolField: "modified-value" diff --git a/cmd/crossplane/project/testdata/diff/modified-cross-ns-xr.yaml b/cmd/crossplane/project/testdata/diff/modified-cross-ns-xr.yaml new file mode 100644 index 00000000..78068e4d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-cross-ns-xr.yaml @@ -0,0 +1,8 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-cross-ns-resource + namespace: default +spec: + coolField: modified-cross-ns-value + environment: production \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/modified-env-xr.yaml b/cmd/crossplane/project/testdata/diff/modified-env-xr.yaml new file mode 100644 index 00000000..3664be56 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-env-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XEnvResource +metadata: + name: test-env-resource +spec: + compositionUpdatePolicy: Automatic + configKey: "modified-config-value" diff --git a/cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml b/cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 00000000..92f64376 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml @@ -0,0 +1,11 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-legacy-manual-v1 +spec: + compositionRef: + name: xlegacynopresources.diff.example.org + compositionRevisionRef: + name: xlegacynopresources.diff.example.org-97a39c0 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml b/cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml new file mode 100644 index 00000000..12f18008 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-legacy-xr-manual-v1-omits-revision-ref.yaml @@ -0,0 +1,12 @@ +# The v1-path (spec.compositionRevisionRef) twin of modified-xr-manual-v1-omits-revision-ref.yaml: +# resources/existing-legacy-xr-manual-v1.yaml with coolField modified and compositionRevisionRef left +# out, so it must stay pinned to the -2f488b6 revision (the v1 template). See issue #499. +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-legacy-manual-v1 +spec: + compositionRef: + name: xlegacynopresources.diff.example.org + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-legacy-xr.yaml b/cmd/crossplane/project/testdata/diff/modified-legacy-xr.yaml new file mode 100644 index 00000000..47df1975 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-legacy-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + compositionUpdatePolicy: Automatic + coolField: "modified-value" diff --git a/cmd/crossplane/project/testdata/diff/modified-manual-pinned-claim.yaml b/cmd/crossplane/project/testdata/diff/modified-manual-pinned-claim.yaml new file mode 100644 index 00000000..be846bd7 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-manual-pinned-claim.yaml @@ -0,0 +1,12 @@ +# resources/pinned-claim/existing-manual.yaml's claim as its author keeps it, with coolField changed. +# Like the cluster copy it names no revision: the pin is the backing XR's. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: manual-claim + namespace: existing-namespace +spec: + coolField: modified-value + compositionUpdatePolicy: Manual + compositionRef: + name: xpinneds.pinned.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/modified-nested-xr.yaml b/cmd/crossplane/project/testdata/diff/modified-nested-xr.yaml new file mode 100644 index 00000000..842e568b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-nested-xr.yaml @@ -0,0 +1,8 @@ +# A modified parent XR - value changed from "existing-value" to "modified-value" +apiVersion: ns.nested.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: "modified-value" diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-automatic.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-automatic.yaml new file mode 100644 index 00000000..b0e59196 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-automatic.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-automatic + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 00000000..bb285ab2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-430f3f3 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml new file mode 100644 index 00000000..7216d4f3 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-manual-v1-omits-revision-ref.yaml @@ -0,0 +1,16 @@ +# resources/existing-xr-manual-v1.yaml with coolField modified, and with compositionRevisionRef left +# out — as a hand-written manifest would, since Crossplane is what writes that field. An apply that +# omits a field does not remove it, so the composite stays pinned to the revision the cluster copy +# names (-911318d, the v1 template). Rendering against the latest revision instead would show the v2 +# template's output for a change that cannot cause it. See issue #499. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-manual-v1.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-manual-v1.yaml new file mode 100644 index 00000000..9ed3f2cd --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-manual-v1.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-911318d + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-selector-bound.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-selector-bound.yaml new file mode 100644 index 00000000..e2e6c3a2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-selector-bound.yaml @@ -0,0 +1,13 @@ +# resources/selector-bound/setup.yaml's composite as its author keeps it, with coolField changed. It +# selects its composition by label and leaves compositionRef to Crossplane, which already set it. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-bound + namespace: default +spec: + coolField: modified-value + crossplane: + compositionSelector: + matchLabels: + binding: shared diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-selector-nomatch.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-selector-nomatch.yaml new file mode 100644 index 00000000..0f45934e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-selector-nomatch.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-selector-nomatch + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + channel: nonexistent + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-selector-stable.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-selector-stable.yaml new file mode 100644 index 00000000..b4dfec12 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-selector-stable.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-selector + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + channel: stable + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-switch-to-automatic.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-switch-to-automatic.yaml new file mode 100644 index 00000000..8d721546 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-switch-to-automatic.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/modified-xr-with-external-dep.yaml b/cmd/crossplane/project/testdata/diff/modified-xr-with-external-dep.yaml new file mode 100644 index 00000000..eba2a6e3 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr-with-external-dep.yaml @@ -0,0 +1,8 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: modified-with-external-dep + environment: testing diff --git a/cmd/crossplane/project/testdata/diff/modified-xr.yaml b/cmd/crossplane/project/testdata/diff/modified-xr.yaml new file mode 100644 index 00000000..8cef439a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/modified-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "modified-value" diff --git a/cmd/crossplane/project/testdata/diff/new-claim-with-claimref-composition.yaml b/cmd/crossplane/project/testdata/diff/new-claim-with-claimref-composition.yaml new file mode 100644 index 00000000..bd63e462 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-claim-with-claimref-composition.yaml @@ -0,0 +1,10 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Background + compositionRef: + name: claim-composition-with-claimref + coolField: new-value diff --git a/cmd/crossplane/project/testdata/diff/new-claim.yaml b/cmd/crossplane/project/testdata/diff/new-claim.yaml new file mode 100644 index 00000000..6d153fee --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-claim.yaml @@ -0,0 +1,11 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Background + compositionRef: + name: claim-composition + coolField: new-value + diff --git a/cmd/crossplane/project/testdata/diff/new-cycle-xr.yaml b/cmd/crossplane/project/testdata/diff/new-cycle-xr.yaml new file mode 100644 index 00000000..45f9b375 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-cycle-xr.yaml @@ -0,0 +1,8 @@ +# A new XR whose composition starts the XCycleA -> XCycleB -> XCycleA cycle. +apiVersion: ns.cycle.example.org/v1alpha1 +kind: XCycleA +metadata: + name: test-cycle + namespace: default +spec: + cycleField: "cycle-value" diff --git a/cmd/crossplane/project/testdata/diff/new-manual-default-xr.yaml b/cmd/crossplane/project/testdata/diff/new-manual-default-xr.yaml new file mode 100644 index 00000000..89fbe026 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-manual-default-xr.yaml @@ -0,0 +1,15 @@ +# A new composite pinned to revision 1, the older of its composition's two revisions, that leaves its +# update policy to its XRD's default (Manual). Under Manual, Crossplane renders a composite against the +# revision it names, so this one renders revision 1; under Automatic it would render the latest. +apiVersion: manualdefault.diff.example.org/v1alpha1 +kind: XManualDefault +metadata: + name: new-manual-default + namespace: default +spec: + coolField: new-value + crossplane: + compositionRef: + name: xmanualdefaults.manualdefault.diff.example.org + compositionRevisionRef: + name: xmanualdefaults.manualdefault.diff.example.org-373e832 diff --git a/cmd/crossplane/project/testdata/diff/new-nested-child-xr.yaml b/cmd/crossplane/project/testdata/diff/new-nested-child-xr.yaml new file mode 100644 index 00000000..37a82217 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-nested-child-xr.yaml @@ -0,0 +1,9 @@ +# The child XR that new-nested-xr.yaml's composition renders (named -child). Passed alongside +# that parent it is an input error: the parent's composition writes it, so this would be a second writer. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + namespace: default +spec: + childField: "parent-value" diff --git a/cmd/crossplane/project/testdata/diff/new-nested-xr.yaml b/cmd/crossplane/project/testdata/diff/new-nested-xr.yaml new file mode 100644 index 00000000..07ea96d7 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-nested-xr.yaml @@ -0,0 +1,8 @@ +# A new parent XR that will create a child XR, which will then create managed resources +apiVersion: ns.nested.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: "parent-value" diff --git a/cmd/crossplane/project/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml b/cmd/crossplane/project/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml new file mode 100644 index 00000000..143783ac --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-parent-claim-omitting-defaulted-fields.yaml @@ -0,0 +1,11 @@ +# A new claim that omits every field a CRD defaults: spec.tier (defaulted by both the claim's and the +# XR's CRD), spec.compositeDeletePolicy (claim CRD only) and spec.compositionUpdatePolicy (XR CRD only). +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: ParentNopClaim +metadata: + name: new-parent-claim + namespace: default +spec: + parentField: new-value + compositionRef: + name: parent-nop-claim-defaults-composition diff --git a/cmd/crossplane/project/testdata/diff/new-rendered-defaults-xrs.yaml b/cmd/crossplane/project/testdata/diff/new-rendered-defaults-xrs.yaml new file mode 100644 index 00000000..b11d2e92 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-rendered-defaults-xrs.yaml @@ -0,0 +1,16 @@ +# New composites that set only size, leaving region and compositionUpdatePolicy to their CRDs' +# defaults; see resources/rendered-defaults/definitions.yaml. +apiVersion: rendered-defaults.diff.example.org/v1alpha1 +kind: XRegionalDatabase +metadata: + name: modern-database + namespace: default +spec: + size: large +--- +apiVersion: rendered-defaults.diff.example.org/v1alpha1 +kind: XLegacyRegionalDatabase +metadata: + name: legacy-database +spec: + size: small diff --git a/cmd/crossplane/project/testdata/diff/new-xr-manual-no-ref.yaml b/cmd/crossplane/project/testdata/diff/new-xr-manual-no-ref.yaml new file mode 100644 index 00000000..21557827 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr-manual-no-ref.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-no-ref + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Manual + coolField: new-value diff --git a/cmd/crossplane/project/testdata/diff/new-xr-missing-namespace-second.yaml b/cmd/crossplane/project/testdata/diff/new-xr-missing-namespace-second.yaml new file mode 100644 index 00000000..e5abd110 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr-missing-namespace-second.yaml @@ -0,0 +1,10 @@ +# A second XR targeting the same namespace as new-xr-missing-namespace.yaml, which does not exist in +# the cluster. Diffed together, the two give the unverified-addition warning more than one resource +# to count. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: second-resource + namespace: nonexistent-namespace +spec: + coolField: "second-value" diff --git a/cmd/crossplane/project/testdata/diff/new-xr-missing-namespace.yaml b/cmd/crossplane/project/testdata/diff/new-xr-missing-namespace.yaml new file mode 100644 index 00000000..03399959 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr-missing-namespace.yaml @@ -0,0 +1,13 @@ +# An XR targeting a namespace that does not exist in the cluster. +# +# This is a supported workflow, not a mistake: a Namespace and the resources inside it are routinely +# applied together (one `kubectl apply -f ./manifests/`), so a user previewing that apply legitimately +# has resources whose namespace is not there yet. The dry-run create cannot be performed, so the +# resources degrade to rendered output and say so — they must NOT fail the run. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: nonexistent-namespace +spec: + coolField: "new-value" diff --git a/cmd/crossplane/project/testdata/diff/new-xr-second.yaml b/cmd/crossplane/project/testdata/diff/new-xr-second.yaml new file mode 100644 index 00000000..b31482df --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr-second.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: second-resource + namespace: default +spec: + coolField: "second-value" diff --git a/cmd/crossplane/project/testdata/diff/new-xr-with-creds.yaml b/cmd/crossplane/project/testdata/diff/new-xr-with-creds.yaml new file mode 100644 index 00000000..07c0fa53 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr-with-creds.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource-with-creds + namespace: default +spec: + coolField: test-value-creds + crossplane: + compositionRef: + name: xnopresources-with-creds.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/new-xr-with-cross-ns-external-dep.yaml b/cmd/crossplane/project/testdata/diff/new-xr-with-cross-ns-external-dep.yaml new file mode 100644 index 00000000..d1997d5c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr-with-cross-ns-external-dep.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "new-value" + # Matches the ConfigMap in "other-namespace" (labelled environment: crossns). + # The go-templating matchLabels ExtraResources selector omits the namespace, + # so resolving it requires an all-namespaces lookup (issue #376). + environment: crossns diff --git a/cmd/crossplane/project/testdata/diff/new-xr.yaml b/cmd/crossplane/project/testdata/diff/new-xr.yaml new file mode 100644 index 00000000..57dd706b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/new-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "new-value" \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/admission-policy-canary.yaml b/cmd/crossplane/project/testdata/diff/resources/admission-policy-canary.yaml new file mode 100644 index 00000000..2130c04e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/admission-policy-canary.yaml @@ -0,0 +1,15 @@ +# A resource that rejecting-admission-policy.yaml must refuse. +# +# The harness dry-run creates this (never persisting it) until the apiserver rejects it, which is how +# a test learns that the policy is actually enforcing. Admission policies reach the apiserver's +# admission plugin through an informer, so for a short window after creation the cluster still +# accepts what the policy will later refuse; a diff issued inside that window would observe no +# rejection at all. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: admission-policy-canary + namespace: default +spec: + forProvider: + configData: new-value diff --git a/cmd/crossplane/project/testdata/diff/resources/api-version-composition-revision-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/api-version-composition-revision-v1.yaml new file mode 100644 index 00000000..887eb013 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/api-version-composition-revision-v1.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xapimigrateresources.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: diff.example.org/v1beta1 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/api-version-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/api-version-composition.yaml new file mode 100644 index 00000000..defbc52a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/api-version-composition.yaml @@ -0,0 +1,34 @@ +# The in-cluster xapimigrateresources.example.org, which renders the resource at v1beta2. The harness +# creates its latest revision from it; api-version-composition-revision-v1.yaml is the earlier one. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xapimigrateresources.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-api-versioned-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: diff.example.org/v1beta2 + kind: XApiMigrateResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-api-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: api-migrate-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-composition-with-claimref.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-composition-with-claimref.yaml new file mode 100644 index 00000000..c468c446 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-composition-with-claimref.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: claim-composition-with-claimref +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + {{- if .observed.composite.resource.spec.claimRef }} + configData: {{ .observed.composite.resource.spec.claimRef.namespace }}/{{ .observed.composite.resource.spec.claimRef.name }} + {{- else }} + configData: {{ .observed.composite.resource.spec.coolField }} + {{- end }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-composition.yaml new file mode 100644 index 00000000..f07fb97e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: claim-composition +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + name: {{ .observed.composite.resource.metadata.name }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/child-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/child-composition.yaml new file mode 100644 index 00000000..824162c4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/child-composition.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: child-nop-claim-composition +spec: + compositeTypeRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopChild + mode: Pipeline + pipeline: + - step: create-nop-resource + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.crossplane.io/v1alpha1 + kind: ClusterNopResource + metadata: + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + child-field: {{ .observed.composite.resource.spec.childField }} + spec: + forProvider: + conditionAfter: + - conditionType: Ready + conditionStatus: "True" + time: 0s + - step: auto-ready + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/child-definition.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/child-definition.yaml new file mode 100644 index 00000000..cc63922d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/child-definition.yaml @@ -0,0 +1,29 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopchildren.claimnested.diff.example.org +spec: + group: claimnested.diff.example.org + names: + kind: XNopChild + plural: xnopchildren + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + childField: + type: string + required: + - childField + status: + type: object + properties: + message: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-child-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-child-xr.yaml new file mode 100644 index 00000000..4b39acb4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-child-xr.yaml @@ -0,0 +1,11 @@ +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: XNopChild +metadata: + name: existing-parent-claim-82crv-child + generateName: existing-parent-claim-82crv- + annotations: + crossplane.io/composition-resource-name: child-xr +spec: + childField: existing-parent-value + compositionRef: + name: child-nop-claim-composition diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-claim.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-claim.yaml new file mode 100644 index 00000000..ba4e28ca --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-claim.yaml @@ -0,0 +1,18 @@ +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: ParentNopClaim +metadata: + name: existing-parent-claim + namespace: default +spec: + parentField: existing-parent-value + compositeDeletePolicy: Background + compositionRef: + name: parent-nop-claim-composition + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: parent-nop-claim-composition-cfac93e + # Bound to its backing XR, existing-parent-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopParent + name: existing-parent-claim-82crv diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-managed-resource.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-managed-resource.yaml new file mode 100644 index 00000000..21a97b6d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-managed-resource.yaml @@ -0,0 +1,14 @@ +apiVersion: nop.crossplane.io/v1alpha1 +kind: ClusterNopResource +metadata: + name: existing-parent-claim-82crv-nop + generateName: existing-parent-claim-82crv- + annotations: + child-field: existing-parent-value + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + conditionAfter: + - conditionType: Ready + conditionStatus: "True" + time: 0s diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-parent-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-parent-xr.yaml new file mode 100644 index 00000000..20399443 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/existing-parent-xr.yaml @@ -0,0 +1,24 @@ +# The backing XR Crossplane's claim syncer created for existing-claim.yaml, tracking its composition's +# current revision. A case also diffs this file as an input, so it spells out what the harness would +# otherwise derive. +apiVersion: claimnested.diff.example.org/v1alpha1 +kind: XNopParent +metadata: + name: existing-parent-claim-82crv + generateName: existing-parent-claim- + labels: + crossplane.io/claim-name: existing-parent-claim + crossplane.io/claim-namespace: default + crossplane.io/composite: existing-parent-claim-82crv +spec: + claimRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: ParentNopClaim + name: existing-parent-claim + namespace: default + parentField: existing-parent-value + compositionUpdatePolicy: Automatic + compositionRef: + name: parent-nop-claim-composition + compositionRevisionRef: + name: parent-nop-claim-composition-cfac93e diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-composition.yaml new file mode 100644 index 00000000..f2475f2d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: parent-nop-claim-composition +spec: + compositeTypeRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopParent + mode: Pipeline + pipeline: + - step: create-child-xr + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopChild + metadata: + generateName: {{ .observed.composite.resource.metadata.name }}- + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: child-xr + spec: + childField: {{ .observed.composite.resource.spec.parentField }} + - step: auto-ready + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml new file mode 100644 index 00000000..d3e331ca --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-defaults-composition.yaml @@ -0,0 +1,35 @@ +# Renders, into a composed resource, the two fields of its XR that only the XR's CRD defaults: +# spec.tier (a user default, which the claim's CRD shares) and spec.compositionUpdatePolicy (machinery, +# which the claim's CRD does not default). A claim whose manifest omits both therefore shows, in this +# resource, whether the XR that render consumed was defaulted with the XR's CRD. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: parent-nop-claim-defaults-composition +spec: + compositeTypeRef: + apiVersion: claimnested.diff.example.org/v1alpha1 + kind: XNopParent + mode: Pipeline + pipeline: + - step: render-defaults + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-defaults + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: defaults + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.tier }}/{{ .observed.composite.resource.spec.compositionUpdatePolicy }} + - step: auto-ready + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-definition.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-definition.yaml new file mode 100644 index 00000000..d3ef4162 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-nested/parent-definition.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopparents.claimnested.diff.example.org +spec: + group: claimnested.diff.example.org + names: + kind: XNopParent + plural: xnopparents + claimNames: + kind: ParentNopClaim + plural: parentnopclaims + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parentField: + type: string + tier: + type: string + default: standard + required: + - parentField + status: + type: object + properties: + message: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/claim-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/claim-xrd.yaml new file mode 100644 index 00000000..af86a8af --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/claim-xrd.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.diff.example.org +spec: + group: diff.example.org + names: + kind: XNopResource + plural: xnopresources + claimNames: + kind: NopClaim + plural: nopclaims + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + tier: + type: string + default: standard + writeConnectionSecretsToNamespace: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml new file mode 100644 index 00000000..ddda8d62 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml @@ -0,0 +1,80 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xconcurrenttests.diff.example.org +spec: + compositeTypeRef: + apiVersion: concurrent.diff.example.org/v1alpha1 + kind: XConcurrentTest + mode: Pipeline + pipeline: + # Step 1: Generate base resources + - step: generate-base-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- range $i := until 3 }} + --- + apiVersion: concurrent.nop.example.org/v1alpha1 + kind: XBaseResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }}-base-{{ $i }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: base-resource-{{ $i }} + spec: + forProvider: + index: {{ $i }} + config: {{ $.observed.composite.resource.spec.config }} + {{- end }} + + # Step 2: Apply environment configs + - step: apply-environment-configs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: EnvironmentConfigs + + # Step 3: Generate additional resources + - step: generate-additional-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- range $i := until 2 }} + --- + apiVersion: concurrent.nop.example.org/v1alpha1 + kind: XAdditionalResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }}-additional-{{ $i }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: additional-resource-{{ $i }} + spec: + forProvider: + index: {{ $i }} + config: {{ $.observed.composite.resource.spec.config }} + {{- end }} + + # Step 4: Fetch extra resources if needed + - step: fetch-extra-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extraresources.fn.crossplane.io/v1beta1 + kind: ExtraResources + + # Step 5: Auto-ready + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-no-namespace-patch.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-no-namespace-patch.yaml new file mode 100644 index 00000000..a4cb8739 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-no-namespace-patch.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # NOTE: No namespace template - testing automatic namespace propagation + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-revision-preview.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-revision-preview.yaml new file mode 100644 index 00000000..18d67a21 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-revision-preview.yaml @@ -0,0 +1,38 @@ +# A later revision of xnopresources.diff.example.org, from when the composition carried the +# channel=preview label. The harness names and labels it from the hash of the composition it was made +# from, as Crossplane does. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + channel: preview +spec: + revision: 2 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: preview-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-revision-stable.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-revision-stable.yaml new file mode 100644 index 00000000..5dff855b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-revision-stable.yaml @@ -0,0 +1,38 @@ +# An earlier revision of xnopresources.diff.example.org, from when the composition carried the +# channel=stable label. The harness names and labels it from the hash of the composition it was made +# from, as Crossplane does. The XR in existing-xr-selector-stable.yaml tracks it. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + channel: stable +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: stable-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-revision-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-revision-v1.yaml new file mode 100644 index 00000000..109940aa --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-revision-v1.yaml @@ -0,0 +1,34 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xnopresources.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-v2.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-v2.yaml new file mode 100644 index 00000000..17c012c6 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-v2.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-builtin-defaults.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-builtin-defaults.yaml new file mode 100644 index 00000000..c032a4cd --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-builtin-defaults.yaml @@ -0,0 +1,61 @@ +# Renders a built-in Kubernetes resource whose apiserver defaults are NOT applied locally. +# +# This fixture exists to exercise the apiserver round-trip for additions (issue #334), and the +# choice of a built-in type is load-bearing rather than incidental. +# +# The lenient Defaulter predicts CRD-derived `default:` values locally for an addition that gets no +# apiserver result, so a CRD-backed composed resource can show its defaults in a `+++` diff with no +# apiserver involvement. Deployment has no CRD, so IsCRDRequired is false and the Defaulter passes it +# through unchanged — which makes it the smallest resource whose defaulted fields can only come from +# the apiserver. +# +# The Deployment deliberately sets only the fields a caller must set (selector, template, one +# container). Everything asserted in the corresponding test — spec.strategy.type, +# spec.template.spec.restartPolicy, spec.template.spec.dnsPolicy — is left for the apiserver to +# populate. Deployment is preferred over Service here because Service creation allocates a +# clusterIP even under dry-run, which would put a non-deterministic value in the diff; Deployment's +# defaults are pure schema defaulting with no allocator involved. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: apps/v1 + kind: Deployment + metadata: + name: {{ .observed.composite.resource.metadata.name }}-deploy + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: deployment + spec: + selector: + matchLabels: + app: {{ .observed.composite.resource.metadata.name }} + template: + metadata: + labels: + app: {{ .observed.composite.resource.metadata.name }} + spec: + containers: + - name: main + image: nginx:1.27 + env: + - name: CONFIG_DATA + value: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-configmap.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-configmap.yaml new file mode 100644 index 00000000..d0ca422f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-configmap.yaml @@ -0,0 +1,48 @@ +# Renders a built-in Kubernetes resource (ConfigMap) alongside a composed XR. +# +# ConfigMap has no CRD, so every code path that determines a resource's scope +# or validates it against a schema has to cope with its absence. Scope comes +# from the discovery API; schema validation comes from the embedded Kubernetes +# scheme in crossplane/cli's validate package. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: v1 + kind: ConfigMap + metadata: + name: {{ .observed.composite.resource.metadata.name }}-config + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: config-map + data: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml new file mode 100644 index 00000000..48f1a0af --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-crd-defaulted-downstream.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-defaulted-downstream.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # spec.forProvider.size is deliberately not rendered: its CRD defaults it. + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDefaultedDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-defaulted + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: defaulted-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-defaults.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-defaults.yaml new file mode 100644 index 00000000..0eedc809 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-defaults.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xdefaultresources.ns.diff.example.org + labels: + provider: nop + service: nop +spec: + mode: Pipeline + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XTestDefaultResource + pipeline: + - step: auto-ready + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-missing-function.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-missing-function.yaml new file mode 100644 index 00000000..cb17b40f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-missing-function.yaml @@ -0,0 +1,17 @@ +# Names a function, function-not-installed, that no Function on the cluster provides. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-not-installed + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml new file mode 100644 index 00000000..c0edae36 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-non-storage-version-downstream.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-non-storage-version-downstream.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # Rendered at v1alpha1, which its CRD serves but does not store. + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XVersionedDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-versioned + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: versioned-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-status.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-status.yaml new file mode 100644 index 00000000..be9332b7 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-status.yaml @@ -0,0 +1,41 @@ +# A composition whose pipeline authors a `status` on the composed resource it renders. +# +# Composition pipelines are explicitly allowed to write their own status, so a rendered status is +# user-authored output rather than a stale read of the cluster. The composed kind here +# (XStatusResource) declares the status subresource, so the apiserver strips status from the dry-run +# create and returns an object with none — which means the status below can only reach the `+++` +# diff if the calculator re-attaches it (crossplane-diff#334). +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XStatusResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: status-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + status: + phase: authored-by-composition + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-with-unserved-k8s-group.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-with-unserved-k8s-group.yaml new file mode 100644 index 00000000..17ecbf25 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/composition-with-unserved-k8s-group.yaml @@ -0,0 +1,42 @@ +# Renders a composed resource in a *.k8s.io group that the cluster does not serve. +# +# This shape slips past every earlier guard. IsCRDRequired (schema_client.go) assumes any group +# ending in .k8s.io other than apiextensions.k8s.io is built-in and returns false WITHOUT consulting +# discovery, so no CRD is demanded. FetchCurrentObject then propagates only non-NotFound errors, and +# a discovery 404 for an unserved group/version is NotFound-shaped, so it is swallowed as "this +# resource is new". The resource therefore reaches the addition path with a GVK that cannot be +# resolved at all. +# +# It stands in for a typo in a group name, an apiVersion skew, a stripped-down cluster, or an +# aggregated API that was never deployed. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: totallynotserved.k8s.io/v1 + kind: Widget + metadata: + name: {{ .observed.composite.resource.metadata.name }}-widget + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: widget + spec: + size: large + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/conditional-gating-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/conditional-gating-xr.yaml new file mode 100644 index 00000000..b871f6bd --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/conditional-gating-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: conditional-gating-test + namespace: default +spec: + coolField: "test-value" diff --git a/cmd/crossplane/project/testdata/diff/resources/credentials/cli-credentials.yaml b/cmd/crossplane/project/testdata/diff/resources/credentials/cli-credentials.yaml new file mode 100644 index 00000000..73d57dbd --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/credentials/cli-credentials.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: function-credentials + namespace: crossplane-system +type: Opaque +data: + # base64 encoded "cli-override-key" + credentials: Y2xpLW92ZXJyaWRlLWtleQ== diff --git a/cmd/crossplane/project/testdata/diff/resources/credentials/composition-with-creds.yaml b/cmd/crossplane/project/testdata/diff/resources/credentials/composition-with-creds.yaml new file mode 100644 index 00000000..2820c988 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/credentials/composition-with-creds.yaml @@ -0,0 +1,39 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources-with-creds.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + # Credentials reference - this tests auto-fetch from cluster + credentials: + - name: test-creds + source: Secret + secretRef: + namespace: crossplane-system + name: function-credentials + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/credentials/function-credentials-secret.yaml b/cmd/crossplane/project/testdata/diff/resources/credentials/function-credentials-secret.yaml new file mode 100644 index 00000000..7c070b9f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/credentials/function-credentials-secret.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: crossplane-system +--- +apiVersion: v1 +kind: Secret +metadata: + name: function-credentials + namespace: crossplane-system +type: Opaque +data: + # base64 encoded "test-api-key-12345" + credentials: dGVzdC1hcGkta2V5LTEyMzQ1 diff --git a/cmd/crossplane/project/testdata/diff/resources/cross-namespace-configmap.yaml b/cmd/crossplane/project/testdata/diff/resources/cross-namespace-configmap.yaml new file mode 100644 index 00000000..4fa3771b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/cross-namespace-configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: cross-namespace-config + namespace: kube-system + labels: + app: cross-namespace-app + environment: production +data: + config-data: "cross-namespace-data" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: another-cross-namespace-config + namespace: kube-public + labels: + app: another-cross-namespace-app + environment: staging +data: + config-data: "another-cross-namespace-data" \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/cross-namespace-fn-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/cross-namespace-fn-composition.yaml new file mode 100644 index 00000000..a1bcb0e0 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/cross-namespace-fn-composition.yaml @@ -0,0 +1,72 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.cross-namespace.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-cross-namespace-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + # Cross-namespace ConfigMap by label selector + - apiVersion: v1 + kind: ConfigMap + into: crossNamespaceConfig + type: Selector + namespace: kube-system + selector: + matchLabels: + - key: app + type: Value + value: cross-namespace-app + - key: environment + type: Value + value: production + # Another cross-namespace ConfigMap by name reference + - apiVersion: v1 + kind: ConfigMap + into: namedCrossNamespaceConfig + type: Reference + namespace: kube-public + ref: + name: another-cross-namespace-config + # Cluster-scoped resource for comparison + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + into: namedClusterRole + type: Reference + ref: + name: external-named-clusterrole + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- $crossNsConfig := index .context "apiextensions.crossplane.io/extra-resources" "crossNamespaceConfig" 0 -}} + {{- $namedCrossNsConfig := index .context "apiextensions.crossplane.io/extra-resources" "namedCrossNamespaceConfig" 0 -}} + {{- $clusterRole := index .context "apiextensions.crossplane.io/extra-resources" "namedClusterRole" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: cross-ns-resource + spec: + forProvider: + configData: {{ index $crossNsConfig "data" "config-data" }}-{{ index $namedCrossNsConfig "data" "config-data" }}-{{ $clusterRole.metadata.name }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/default-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/default-composition.yaml new file mode 100644 index 00000000..b7ccde46 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/default-composition.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: default-composition + labels: + provider: default + environment: any +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-default-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: default-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: "default" + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/env-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/env-composition.yaml new file mode 100644 index 00000000..6583c4de --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/env-composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xenvresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XEnvResource + mode: Pipeline + pipeline: + - step: environmentConfigs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: test-env-config + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ $envConfig := index .context "apiextensions.crossplane.io/environment" }} + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamEnvResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: env-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.configKey }} + region: {{ $envConfig.region }} + environment: {{ $envConfig.environment }} + serviceLevel: {{ $envConfig.serviceLevel }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/env-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/env-xrd.yaml new file mode 100644 index 00000000..9d137cac --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/env-xrd.yaml @@ -0,0 +1,22 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xenvresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XEnvResource + plural: xenvresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + configKey: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/environment-config-v1beta1.yaml b/cmd/crossplane/project/testdata/diff/resources/environment-config-v1beta1.yaml new file mode 100644 index 00000000..ddb284c0 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/environment-config-v1beta1.yaml @@ -0,0 +1,8 @@ +apiVersion: apiextensions.crossplane.io/v1beta1 +kind: EnvironmentConfig +metadata: + name: test-env-config +data: + region: us-west-2 + environment: staging + serviceLevel: premium diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml new file mode 100644 index 00000000..7caf33a2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-api-version-downstream-v1beta1.yaml @@ -0,0 +1,10 @@ +apiVersion: diff.example.org/v1beta1 +kind: XApiMigrateResource +metadata: + name: test-api-version-xr-api-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: api-migrate-resource +spec: + forProvider: + configData: test-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-api-version-xr-rev1.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-api-version-xr-rev1.yaml new file mode 100644 index 00000000..558b7168 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-api-version-xr-rev1.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-api-version-xr + namespace: default +spec: + crossplane: + compositionRef: + name: xapimigrateresources.example.org + compositionRevisionRef: + name: xapimigrateresources.example.org-2067855 + compositionUpdatePolicy: Manual + coolField: test-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml new file mode 100644 index 00000000..ab5d01ab --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-claim-defaulted-fields-other-manager.yaml @@ -0,0 +1,10 @@ +# A different field manager sets two fields the claim's CRD defaults to non-default values on the +# claim, whose manifest (modified-claim-omitting-defaulted-fields.yaml) omits both. +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + compositeDeletePolicy: Foreground + compositionUpdatePolicy: Manual diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-claim-downstream-resource.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-claim-downstream-resource.yaml new file mode 100644 index 00000000..bdea322d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-claim-downstream-resource.yaml @@ -0,0 +1,9 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-claim-82crv +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-claim-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-claim-xr.yaml new file mode 100644 index 00000000..e5ed847a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-claim-xr.yaml @@ -0,0 +1,21 @@ +# The backing XR Crossplane's claim syncer created for existing-claim.yaml, and whose name it wrote to +# that claim's spec.resourceRef. Its composed resource is existing-claim-downstream-resource.yaml. +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-claim-82crv + generateName: test-claim- +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: claim-composition + claimRef: + apiVersion: diff.example.org/v1alpha1 + kind: NopClaim + name: test-claim + namespace: existing-namespace + resourceRefs: + - apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + name: test-claim-82crv diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-claim.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-claim.yaml new file mode 100644 index 00000000..0de70f11 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-claim.yaml @@ -0,0 +1,18 @@ +apiVersion: diff.example.org/v1alpha1 +kind: NopClaim +metadata: + name: test-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositeDeletePolicy: Background + compositionRef: + name: claim-composition + # Copied back from the backing XR, as the claim syncer does under the XR's Automatic update policy. + compositionRevisionRef: + name: claim-composition-caff3a7 + # Bound to its backing XR, existing-claim-xr.yaml, as Crossplane's claim syncer binds it. + resourceRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + name: test-claim-82crv diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-cluster-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-cluster-xr.yaml new file mode 100644 index 00000000..03e34f05 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-cluster-xr.yaml @@ -0,0 +1,10 @@ +# TODO: use opposite pattern of prefixing namespaced api group instead of prefixing cluster api group +apiVersion: diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-downstream.yaml new file mode 100644 index 00000000..45fdbc88 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-cross-ns-resource + namespace: default + annotations: + crossplane.io/composition-resource-name: cross-ns-resource +spec: + forProvider: + configData: existing-cross-ns-data-existing-named-data-old-cross-ns-role \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-xr.yaml new file mode 100644 index 00000000..5830cd0e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-cross-ns-xr.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-cross-ns-resource + namespace: default +spec: + coolField: existing-cross-ns-value + environment: staging + crossplane: + compositionRef: + name: xnopresources.cross-namespace.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-composed.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-composed.yaml new file mode 100644 index 00000000..cb92c60d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-composed.yaml @@ -0,0 +1,14 @@ +# The fields Crossplane's composed-resource field manager applies. spec.forProvider.size is absent, +# exactly as the composition renders it. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDefaultedDownstreamResource +metadata: + name: test-resource-defaulted + namespace: default + annotations: + crossplane.io/composition-resource-name: defaulted-resource + labels: + crossplane.io/composite: test-resource +spec: + forProvider: + configData: new-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml new file mode 100644 index 00000000..04a6509a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-defaulted-downstream-other-manager.yaml @@ -0,0 +1,10 @@ +# A different field manager (a user or another controller) sets the CRD-defaulted field to a +# non-default value on the composed resource Crossplane already manages. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDefaultedDownstreamResource +metadata: + name: test-resource-defaulted + namespace: default +spec: + forProvider: + size: large diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-automatic.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-automatic.yaml new file mode 100644 index 00000000..f8abdd1d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-automatic.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-automatic + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1-existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-manual-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-manual-v1.yaml new file mode 100644 index 00000000..8909fb23 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-manual-v1.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-manual-v1 + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1-existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml new file mode 100644 index 00000000..650c6e68 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource-with-argocd.yaml @@ -0,0 +1,15 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + argocd.argoproj.io/tracking-id: "test-app:/XDownstreamResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.nop.example.org/v1alpha1","kind":"XDownstreamResource"} + labels: + argocd.argoproj.io/instance: test-app + name: test-resource + namespace: default +spec: + forProvider: + configData: new-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource.yaml new file mode 100644 index 00000000..3dc89919 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-resource.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-selector-stable.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-selector-stable.yaml new file mode 100644 index 00000000..5f53076d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-selector-stable.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-selector + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: stable-existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-external-dep.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-external-dep.yaml new file mode 100644 index 00000000..f25c13dc --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-external-dep.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + name: test-resource + namespace: default +spec: + forProvider: + configData: existing-value + roleName: old-role-name diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-generated-name.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-generated-name.yaml new file mode 100644 index 00000000..e8cc8c56 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-downstream-with-generated-name.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: nop-resource + generateName: test-resource- + labels: + crossplane.io/composite: test-resource + name: test-resource-abc123 + namespace: default +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-env-downstream-resource.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-env-downstream-resource.yaml new file mode 100644 index 00000000..19da0e39 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-env-downstream-resource.yaml @@ -0,0 +1,16 @@ +# Deliberately no compositionRef or compositionRevisionRef. XDownstreamEnvResource is an XR kind (its +# XRD is xdownstreamenvresource-xrd.yaml), but no Composition targets it, so Crossplane can select +# none and never writes either ref. The diff likewise skips rendering it as a nested XR. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamEnvResource +metadata: + annotations: + crossplane.io/composition-resource-name: env-resource + name: test-env-resource +spec: + compositionUpdatePolicy: Automatic + forProvider: + configData: existing-config-value + environment: staging + region: us-west-2 + serviceLevel: premium \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-env-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-env-xr.yaml new file mode 100644 index 00000000..1768c99a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-env-xr.yaml @@ -0,0 +1,9 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XEnvResource +metadata: + name: test-env-resource +spec: + compositionRef: + name: xenvresources.diff.example.org + compositionUpdatePolicy: Automatic + configKey: "existing-config-value" diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-generate-name-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-generate-name-xr.yaml new file mode 100644 index 00000000..72d7f866 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-generate-name-xr.yaml @@ -0,0 +1,13 @@ +# existing-xr.yaml as Crossplane leaves it once generated-name-composition.yaml has composed it, so it +# tracks that composition's revision rather than composition.yaml's. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml new file mode 100644 index 00000000..0134d82c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml @@ -0,0 +1,9 @@ +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-legacy-manual-v1 + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1-existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml new file mode 100644 index 00000000..4999cd65 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml @@ -0,0 +1,11 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-legacy-manual-v1 +spec: + compositionRef: + name: xlegacynopresources.diff.example.org + compositionRevisionRef: + name: xlegacynopresources.diff.example.org-2f488b6 + compositionUpdatePolicy: Manual + coolField: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr.yaml new file mode 100644 index 00000000..5c375919 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-legacy-xr.yaml @@ -0,0 +1,9 @@ +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource +spec: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic + coolField: "existing-value" diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-namespace.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-namespace.yaml new file mode 100644 index 00000000..3fc6cd48 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: existing-namespace \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-removal-test-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-removal-test-xr.yaml new file mode 100644 index 00000000..bc841ee6 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-removal-test-xr.yaml @@ -0,0 +1,13 @@ +# existing-xr.yaml as Crossplane leaves it once removal-test-composition.yaml has composed it, so it +# tracks that composition and its revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml new file mode 100644 index 00000000..8ce45267 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0-ready.yaml @@ -0,0 +1,19 @@ +# The stage0 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml, +# once it has become Ready. Crossplane then composed stage1 too +# (existing-sequencer-gating-stage1-ready.yaml). +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value +status: + conditions: + - type: Ready + status: "True" + reason: Available + lastTransitionTime: "2024-01-01T00:00:00Z" diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0.yaml new file mode 100644 index 00000000..e84cc250 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage0.yaml @@ -0,0 +1,19 @@ +# The stage0 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml, +# still provisioning: it is not Ready, so function-sequencer keeps stage1 gated until +# --eventual-state synthesizes its Ready. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: existing-value +status: + conditions: + - type: Ready + status: "False" + reason: Creating + lastTransitionTime: "2024-01-01T00:00:00Z" diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml new file mode 100644 index 00000000..a209807c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-stage1-ready.yaml @@ -0,0 +1,18 @@ +# The stage1 resource sequencer-gating-composition.yaml composed for existing-sequencer-gating-xr.yaml +# once its stage0 (existing-sequencer-gating-stage0-ready.yaml) became Ready. It is Ready too. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage1-resource + name: 1-stage1-resource + namespace: default +spec: + forProvider: + configData: existing-value +status: + conditions: + - type: Ready + status: "True" + reason: Available + lastTransitionTime: "2024-01-01T00:00:00Z" diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-xr.yaml new file mode 100644 index 00000000..b259f22a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-gating-xr.yaml @@ -0,0 +1,13 @@ +# sequencer-xr.yaml as it stood in the cluster before its coolField was changed, once Crossplane had +# reconciled it against sequencer-gating-composition.yaml, recording the composition it selected at +# spec.crossplane.* as the v2 XRD has them. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-test + namespace: default +spec: + coolField: existing-value + crossplane: + compositionRef: + name: sequencer-gating-composition diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-xr.yaml new file mode 100644 index 00000000..6c50f601 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-sequencer-xr.yaml @@ -0,0 +1,12 @@ +# sequencer-xr.yaml as it stands in the cluster once Crossplane has reconciled it against +# sequencer-composition.yaml. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-test + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionRef: + name: sequencer-composition diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml new file mode 100644 index 00000000..e1c4dcb2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-v2xrd-v1paths + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml new file mode 100644 index 00000000..2f1b1b25 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-v2xrd-v1paths-xr.yaml @@ -0,0 +1,14 @@ +# v2 XRD with v1-style composition paths test case (issue #206) +# This XR uses v1-style spec.compositionRef instead of spec.crossplane.compositionRef +apiVersion: v2withv1paths.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-v2xrd-v1paths + namespace: default +spec: + # v1-style composition reference (at spec.compositionRef instead of spec.crossplane.compositionRef), + # which is what this case is about. The harness derives the revision ref at the same v1-style path. + compositionRef: + name: xnopresources.v2withv1paths.diff.example.org + compositionUpdatePolicy: Automatic + coolField: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-automatic.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-automatic.yaml new file mode 100644 index 00000000..52060d5d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-automatic.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-automatic + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-911318d # Currently on v1, but should auto-upgrade + compositionUpdatePolicy: Automatic + coolField: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-deleting.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-deleting.yaml new file mode 100644 index 00000000..b84ed1af --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-deleting.yaml @@ -0,0 +1,14 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + # The finalizer keeps the object around after a delete is issued, so envtest (which runs no + # controllers) leaves it with metadata.deletionTimestamp set — a resource mid-deletion. + finalizers: + - composite.apiextensions.crossplane.io +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml new file mode 100644 index 00000000..a68ebbd7 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-listing-uncontrolled-resources.yaml @@ -0,0 +1,23 @@ +# existing-removal-test-xr.yaml whose resourceRefs still name two composed resources it no longer +# controls: one another XR (existing-other-xr.yaml) now controls, and one whose controller reference +# was removed. Crossplane's garbage collector deletes only a resource the composite controls, so +# neither is removed although the composition no longer renders them. The harness appends the +# resources this XR does control. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: removal-test-composition + compositionUpdatePolicy: Automatic + resourceRefs: + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: other-resource + - apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + name: resource-without-controller diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-manual-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-manual-v1.yaml new file mode 100644 index 00000000..61c2c65c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-manual-v1.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-manual-v1 + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-911318d + compositionUpdatePolicy: Manual + coolField: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-selector-stable.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-selector-stable.yaml new file mode 100644 index 00000000..bb1edeb2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-selector-stable.yaml @@ -0,0 +1,16 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-selector + namespace: default +spec: + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-1623e01 + compositionUpdatePolicy: Automatic + compositionRevisionSelector: + matchLabels: + channel: stable + coolField: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-argocd.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-argocd.yaml new file mode 100644 index 00000000..af4bc154 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-argocd.yaml @@ -0,0 +1,19 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app-OLD:/XNopResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource","namespace":"default"},"spec":{"coolField":"old-value"}} + labels: + argocd.argoproj.io/instance: test-app-OLD +spec: + coolField: "new-value" + # What any XR Crossplane has reconciled carries: the reconciler writes spec.crossplane, and the + # XR's generated CRD then defaults the policy. + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml new file mode 100644 index 00000000..678d6a0e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-other-manager.yaml @@ -0,0 +1,9 @@ +# A different field manager (a user or another controller) sets the XRD-defaulted spec.region to a +# non-default value on an XR whose own manifest (xr-with-missing-defaults.yaml) omits it. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default +spec: + region: eu-west-1 diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml new file mode 100644 index 00000000..6a855f8d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-defaults-reconciled.yaml @@ -0,0 +1,16 @@ +# What Crossplane's composite reconciler writes to xr-with-missing-defaults.yaml once it has reconciled +# it: its own composite label, and the composition and revision it selected (the revision the harness +# creates for composition-with-defaults.yaml, as the revision controller does). +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default + labels: + crossplane.io/composite: test-resource-with-defaults +spec: + crossplane: + compositionRef: + name: xdefaultresources.ns.diff.example.org + compositionRevisionRef: + name: xdefaultresources.ns.diff.example.org-d0e5887 diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml new file mode 100644 index 00000000..8a000a9b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-multistep-fatal.yaml @@ -0,0 +1,14 @@ +# existing-xr-with-external-dep.yaml as Crossplane leaves it once +# multistep-fatal-after-extra-res-composition.yaml has composed it, so it tracks that composition and +# its revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: multistep-fatal-after-extra-res.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml new file mode 100644 index 00000000..7c16d606 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-templated.yaml @@ -0,0 +1,13 @@ +# existing-xr-with-external-dep.yaml as Crossplane leaves it once external-res-gotpl-composition.yaml +# has composed it, so it tracks that composition and its revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: templated-extra-resources.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml new file mode 100644 index 00000000..2f09685b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep-unguarded.yaml @@ -0,0 +1,14 @@ +# existing-xr-with-external-dep.yaml as Crossplane leaves it once +# unguarded-external-res-gotpl-composition.yaml has composed it, so it tracks that composition and its +# revision. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: unguarded-templated-extra-resources.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep.yaml new file mode 100644 index 00000000..852cd656 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr-with-external-dep.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: existing-value + environment: staging + crossplane: + compositionRef: + name: xnopresources.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/existing-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/existing-xr.yaml new file mode 100644 index 00000000..38e8c85c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/existing-xr.yaml @@ -0,0 +1,13 @@ +# spec.crossplane.compositionUpdatePolicy is what any XR Crossplane has reconciled carries: the +# reconciler writes spec.crossplane, and the XR's generated CRD then defaults the policy. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/external-named-clusterrole.yaml b/cmd/crossplane/project/testdata/diff/resources/external-named-clusterrole.yaml new file mode 100644 index 00000000..3f6b02ef --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/external-named-clusterrole.yaml @@ -0,0 +1,8 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: external-named-clusterrole +rules: + - apiGroups: [""] + resources: ["pods"] + verbs: ["get", "list", "watch"] diff --git a/cmd/crossplane/project/testdata/diff/resources/external-res-fn-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/external-res-fn-composition.yaml new file mode 100644 index 00000000..dacd3b43 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/external-res-fn-composition.yaml @@ -0,0 +1,64 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: fetch-external-resources + functionRef: + name: function-extra-resources + input: + apiVersion: extra-resources.fn.crossplane.io/v1beta1 + kind: Input + spec: + extraResources: + - apiVersion: v1 + kind: ConfigMap + into: cfgMap + type: Selector + selector: + matchLabels: + - key: app + type: Value + value: test-app + - key: environment + type: FromCompositeFieldPath + valueFromFieldPath: spec.environment + - apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + # function-extra-resources only supports Cluster-scoped resources as name refs + into: namedClusterRole + type: Reference + ref: + name: external-named-clusterrole + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{- /* we can't use the same call to 'index' on these because it goes map -> array -> map */ -}} + {{- $extras := index .context "apiextensions.crossplane.io/extra-resources" -}} + {{- $cfgMap := index $extras "cfgMap" 0 -}} + {{- $clusterRole := index $extras "namedClusterRole" 0 -}} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ index $cfgMap "data" "resource-data" }} + roleName: {{ $clusterRole.metadata.name }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/external-res-gotpl-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/external-res-gotpl-composition.yaml new file mode 100644 index 00000000..eb3c88aa --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/external-res-gotpl-composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: templated-extra-resources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- with .extraResources }} + {{ $configMaps := index . "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} + {{- end }} diff --git a/cmd/crossplane/project/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml new file mode 100644 index 00000000..a886976c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/external-res-matchname-gotpl-composition.yaml @@ -0,0 +1,46 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: templated-extra-resources-matchname.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + # Regression scenario for crossplane-contrib/crossplane-diff#355: + # ExtraResources block uses matchName to reference a Secret that + # does not exist in the cluster. Pre-fix this aborted the diff with + # "cannot get referenced resource"; post-fix the unmet requirement + # is silently skipped (mirroring upstream xfn behavior) and the + # composition continues to render its downstream resource. + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + optional-secret: + apiVersion: v1 + kind: Secret + matchName: nonexistent-secret + namespace: default + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + roleName: static-role diff --git a/cmd/crossplane/project/testdata/diff/resources/external-resource-configmap-other-namespace.yaml b/cmd/crossplane/project/testdata/diff/resources/external-resource-configmap-other-namespace.yaml new file mode 100644 index 00000000..4a02772e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/external-resource-configmap-other-namespace.yaml @@ -0,0 +1,22 @@ +# Reproduction fixture for crossplane-contrib/crossplane-diff#376. +# +# A ConfigMap matched by a matchLabels ExtraResources selector (no namespace) +# that lives in a DIFFERENT namespace than the XR being diffed. The XR is in +# "default"; this resource is in "other-namespace". A correct all-namespaces +# lookup (matching upstream Crossplane) finds it; a lookup wrongly scoped to the +# XR namespace does not. +apiVersion: v1 +kind: Namespace +metadata: + name: other-namespace +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: external-resource-crossns + namespace: other-namespace + labels: + app: test-app + environment: crossns +data: + resource-data: "cross-namespace-external-resource-data" diff --git a/cmd/crossplane/project/testdata/diff/resources/external-resource-configmap.yaml b/cmd/crossplane/project/testdata/diff/resources/external-resource-configmap.yaml new file mode 100644 index 00000000..53200b9e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/external-resource-configmap.yaml @@ -0,0 +1,21 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: external-resource-staging + namespace: default + labels: + app: test-app + environment: staging +data: + resource-data: "staging-external-resource-data" +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: external-resource-testing + namespace: default + labels: + app: test-app + environment: testing +data: + resource-data: "testing-external-resource-data" \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/generated-name-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/generated-name-composition.yaml new file mode 100644 index 00000000..f16e10c1 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/generated-name-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + generateName: test-resource- + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/legacy-composition-revision-v1.yaml b/cmd/crossplane/project/testdata/diff/resources/legacy-composition-revision-v1.yaml new file mode 100644 index 00000000..8f7eec5d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/legacy-composition-revision-v1.yaml @@ -0,0 +1,33 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xlegacynopresources.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/legacy-composition-v2.yaml b/cmd/crossplane/project/testdata/diff/resources/legacy-composition-v2.yaml new file mode 100644 index 00000000..fc492bb9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/legacy-composition-v2.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xlegacynopresources.diff.example.org +spec: + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/legacy-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/legacy-xrd.yaml new file mode 100644 index 00000000..c8648a5a --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/legacy-xrd.yaml @@ -0,0 +1,49 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.legacycluster.diff.example.org +spec: + group: legacycluster.diff.example.org + names: + kind: XNopResource + plural: xnopresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/manual-default/definitions.yaml b/cmd/crossplane/project/testdata/diff/resources/manual-default/definitions.yaml new file mode 100644 index 00000000..11c29f88 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/manual-default/definitions.yaml @@ -0,0 +1,96 @@ +# An XRD whose composites default to the Manual update policy, and a composition with two revisions. +# Each revision's template writes its own version into the composed resource, so the value shows which +# revision rendered. The composition itself is the newest revision (v2), as it is in a cluster, and the +# harness creates that revision from it; revision 1 is declared here. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xmanualdefaults.manualdefault.diff.example.org +spec: + group: manualdefault.diff.example.org + names: + kind: XManualDefault + plural: xmanualdefaults + scope: Namespaced + defaultCompositionUpdatePolicy: Manual + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xmanualdefaults.manualdefault.diff.example.org +spec: + compositeTypeRef: + apiVersion: manualdefault.diff.example.org/v1alpha1 + kind: XManualDefault + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xmanualdefaults.manualdefault.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: manualdefault.diff.example.org/v1alpha1 + kind: XManualDefault + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml new file mode 100644 index 00000000..a974f9f5 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/modified-v2xrd-v1paths-xr.yaml @@ -0,0 +1,13 @@ +# v2 XRD with v1-style composition paths test case (issue #206) +# This XR uses v1-style spec.compositionRef instead of spec.crossplane.compositionRef +apiVersion: v2withv1paths.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-v2xrd-v1paths + namespace: default +spec: + # v1-style composition reference (at spec.compositionRef instead of spec.crossplane.compositionRef) + compositionRef: + name: xnopresources.v2withv1paths.diff.example.org + compositionUpdatePolicy: Automatic + coolField: modified-value diff --git a/cmd/crossplane/project/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml new file mode 100644 index 00000000..55419e07 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/multistep-fatal-after-extra-res-composition.yaml @@ -0,0 +1,65 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: multistep-fatal-after-extra-res.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + # Step 1: guarded gotpl. Emits an ExtraResources requirement AND (once + # .extraResources is populated on the inner fetcher iteration) a composed + # resource. Stabilizes cleanly within the single render call. + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- with .extraResources }} + {{ $configMaps := index . "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} + {{- end }} + # Step 2: always fatals. `index .missing.path 0` — .missing is , + # indexing returns "index of untyped nil". This step does NOT emit + # requirements — so rsp.Requirements is nil for this step, but the render + # library has already accumulated step 1's requirements into its outputs. + # This produces the combination the PR's render-loop fix addresses: + # renderErr != nil AND len(output.Requirements) > 0. + - step: always-fatal + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ index .missing.path 0 }} diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/child-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/child-composition.yaml new file mode 100644 index 00000000..17ae8f7d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/child-composition.yaml @@ -0,0 +1,33 @@ +# Child composition - creates managed resources downstream +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xchildresources.nested.example.org +spec: + compositeTypeRef: + apiVersion: ns.nested.example.org/v1alpha1 + kind: XChildResource + mode: Pipeline + pipeline: + - step: generate-managed-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-managed + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: managed-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.childField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/child-revision-templating-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/child-revision-templating-composition.yaml new file mode 100644 index 00000000..54b1faa3 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/child-revision-templating-composition.yaml @@ -0,0 +1,38 @@ +# A child composition whose template reads the CompositionRevision's own name off the nested composite +# and propagates it into the composed resource — the nested counterpart of +# testdata/comp/resources/revision-templating-composition.yaml. A nested XR is rendered from its +# parent's output, which never carries compositionRevisionRef (Crossplane's composite reconciler writes +# that), so unless the render inherits the cluster copy's ref this template has nothing to read. +# Used in place of child-composition.yaml, never alongside it: both target XChildResource. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xchildresources.nested.example.org +spec: + compositeTypeRef: + apiVersion: ns.nested.example.org/v1alpha1 + kind: XChildResource + mode: Pipeline + pipeline: + - step: generate-managed-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-managed + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: managed-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.crossplane.compositionRevisionRef.name }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/child-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/child-xrd.yaml new file mode 100644 index 00000000..c8da4876 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/child-xrd.yaml @@ -0,0 +1,55 @@ +# Child XRD - this will be created as a composed resource of the parent +# and then will itself create downstream managed resources +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xchildresources.ns.nested.example.org +spec: + group: ns.nested.example.org + names: + kind: XChildResource + plural: xchildresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + childField: + type: string + description: A field for the child resource + tier: + type: string + default: standard + description: Defaulted by the XRD; the parent composition never sets it + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml new file mode 100644 index 00000000..7004e4f1 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-revision-ref.yaml @@ -0,0 +1,18 @@ +# existing-child-xr.yaml as Crossplane's composite reconciler leaves it once it has selected +# child-revision-templating-composition.yaml and its revision: carrying spec.crossplane.compositionRef +# and, from the harness, compositionRevisionRef. That composition reads the revision name into the +# managed resource, so a nested render that did not inherit the refs would report the managed resource +# as changing. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + annotations: + crossplane.io/composition-resource-name: child-xr + namespace: default +spec: + childField: "existing-value" + crossplane: + compositionRef: + name: xchildresources.nested.example.org + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml new file mode 100644 index 00000000..3cef855c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr-tier-other-manager.yaml @@ -0,0 +1,9 @@ +# A different field manager sets the XRD-defaulted spec.tier to a non-default value on the child XR, +# which the parent composition renders without it. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + namespace: default +spec: + tier: premium diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr.yaml new file mode 100644 index 00000000..fa857d35 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/existing-child-xr.yaml @@ -0,0 +1,13 @@ +# Existing child XR in the cluster (created by parent) +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: test-parent-child + annotations: + crossplane.io/composition-resource-name: child-xr + namespace: default +spec: + childField: "existing-value" + crossplane: + compositionRef: + name: xchildresources.nested.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml new file mode 100644 index 00000000..de74ec9e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource-revision-ref.yaml @@ -0,0 +1,15 @@ +# The managed resource composed by existing-child-xr-revision-ref.yaml through +# child-revision-templating-composition.yaml, so configData holds the revision the child tracks. +# Carries crossplane.io/composition-resource-name (and, from the harness, the composite label) because +# the render adds both: a fixture missing them is reported as modified on every run, which would hide +# whether the revision name propagated. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-child-managed + namespace: default + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: xchildresources.nested.example.org-96afc06 diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource.yaml new file mode 100644 index 00000000..1211a7e4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/existing-managed-resource.yaml @@ -0,0 +1,11 @@ +# Existing managed resource in the cluster (created by child XR) +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-parent-child-managed + namespace: default + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: "existing-value" diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/existing-parent-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/existing-parent-xr.yaml new file mode 100644 index 00000000..9ce1afe0 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/existing-parent-xr.yaml @@ -0,0 +1,11 @@ +# Existing parent XR in the cluster +apiVersion: ns.nested.example.org/v1alpha1 +kind: XParentResource +metadata: + name: test-parent + namespace: default +spec: + parentField: "existing-value" + crossplane: + compositionRef: + name: xparentresources.nested.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/parent-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/parent-composition.yaml new file mode 100644 index 00000000..5a4ce994 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/parent-composition.yaml @@ -0,0 +1,32 @@ +# Parent composition - creates a child XR as its composed resource +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xparentresources.nested.example.org +spec: + compositeTypeRef: + apiVersion: ns.nested.example.org/v1alpha1 + kind: XParentResource + mode: Pipeline + pipeline: + - step: generate-child-xr + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nested.example.org/v1alpha1 + kind: XChildResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-child + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: child-xr + spec: + childField: {{ .observed.composite.resource.spec.parentField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/nested/parent-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/nested/parent-xrd.yaml new file mode 100644 index 00000000..04de284e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/nested/parent-xrd.yaml @@ -0,0 +1,50 @@ +# Parent XRD - this will create nested XRs as its composed resources +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xparentresources.ns.nested.example.org +spec: + group: ns.nested.example.org + names: + kind: XParentResource + plural: xparentresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parentField: + type: string + description: A field that will be passed to child XR + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/definitions.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/definitions.yaml new file mode 100644 index 00000000..34bff81d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/definitions.yaml @@ -0,0 +1,96 @@ +# A claim-capable XRD and a composition with two revisions, for the revision tests. Each revision's +# template writes "::" +# into the composed resource, so one value shows both which revision rendered and which ref the render +# saw. The composition itself is the newest revision (v2), as it is in a cluster, and the harness creates +# that revision from it; revision 1 is declared here. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xpinneds.pinned.diff.example.org +spec: + group: pinned.diff.example.org + names: + kind: XPinned + plural: xpinneds + claimNames: + kind: Pinned + plural: pinneds + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xpinneds.pinned.diff.example.org +spec: + compositeTypeRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v2:{{ .observed.composite.resource.spec.compositionRevisionRef.name }}:{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + labels: + crossplane.io/composition-name: xpinneds.pinned.diff.example.org +spec: + revision: 1 + compositeTypeRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: v1:{{ .observed.composite.resource.spec.compositionRevisionRef.name }}:{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml new file mode 100644 index 00000000..d0672a40 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-downstream.yaml @@ -0,0 +1,10 @@ +# The composed resource of existing-automatic-xr.yaml, as revision 2 rendered it. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: automatic-claim-a1b2c + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v2:xpinneds.pinned.diff.example.org-806289b:existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml new file mode 100644 index 00000000..ecd3a8a9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic-xr.yaml @@ -0,0 +1,15 @@ +# The backing XR of existing-automatic.yaml, tracking revision 2, the latest. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: automatic-claim-a1b2c +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org + claimRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: Pinned + name: automatic-claim + namespace: existing-namespace diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic.yaml new file mode 100644 index 00000000..7d3eda85 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-automatic.yaml @@ -0,0 +1,19 @@ +# An Automatic claim bound to a backing XR tracking revision 2, the latest: existing-automatic-xr.yaml, +# whose composed resource, as revision 2 rendered it, is existing-automatic-downstream.yaml. The claim +# carries the ref the claim syncer copies back from its XR under Automatic. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: automatic-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org + compositionRevisionRef: + name: xpinneds.pinned.diff.example.org-806289b + resourceRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + name: automatic-claim-a1b2c diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml new file mode 100644 index 00000000..8fca08a8 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-downstream.yaml @@ -0,0 +1,10 @@ +# The composed resource of existing-lagging-automatic-xr.yaml, as revision 1 rendered it. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: lagging-xr + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1:xpinneds.pinned.diff.example.org-3d33597:existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml new file mode 100644 index 00000000..553bd228 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-lagging-automatic-xr.yaml @@ -0,0 +1,16 @@ +# An Automatic XR, bound to no claim, whose compositionRevisionRef still names revision 1 although revision +# 2 is the latest. Its composed resource, as revision 1 rendered it, is +# existing-lagging-automatic-downstream.yaml. Crossplane re-points an Automatic composite at the latest +# revision on its next reconcile, so the ref lags until then: say, when the composite is diffed straight +# after its composition changed. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: lagging-xr +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org + compositionRevisionRef: + name: xpinneds.pinned.diff.example.org-3d33597 diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml new file mode 100644 index 00000000..f6d3a87d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-downstream.yaml @@ -0,0 +1,10 @@ +# The composed resource of existing-manual-xr.yaml, as revision 1 rendered it. +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: manual-claim-x1y2z + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: v1:xpinneds.pinned.diff.example.org-3d33597:existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml new file mode 100644 index 00000000..a5d186fa --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual-xr.yaml @@ -0,0 +1,17 @@ +# The backing XR of existing-manual.yaml, pinned to revision 1. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: manual-claim-x1y2z +spec: + coolField: existing-value + compositionUpdatePolicy: Manual + compositionRef: + name: xpinneds.pinned.diff.example.org + compositionRevisionRef: + name: xpinneds.pinned.diff.example.org-3d33597 + claimRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: Pinned + name: manual-claim + namespace: existing-namespace diff --git a/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual.yaml b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual.yaml new file mode 100644 index 00000000..8ece1692 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/pinned-claim/existing-manual.yaml @@ -0,0 +1,19 @@ +# A Manual claim bound to a backing XR that is pinned to revision 1, the older of the composition's two +# revisions: existing-manual-xr.yaml, whose composed resource, as revision 1 rendered it, is +# existing-manual-downstream.yaml. The claim carries no compositionRevisionRef: the claim syncer copies +# the ref back to a claim only under Automatic, so a Manual claim has one only if its author set it. The +# pin therefore lives on the backing XR alone. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: manual-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositionUpdatePolicy: Manual + compositionRef: + name: xpinneds.pinned.diff.example.org + resourceRef: + apiVersion: pinned.diff.example.org/v1alpha1 + kind: XPinned + name: manual-claim-x1y2z diff --git a/cmd/crossplane/project/testdata/diff/resources/production-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/production-composition.yaml new file mode 100644 index 00000000..1e0b06ba --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/production-composition.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: production-composition + labels: + provider: aws + environment: production +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-production-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: production-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: "production" + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/rejecting-admission-policy.yaml b/cmd/crossplane/project/testdata/diff/resources/rejecting-admission-policy.yaml new file mode 100644 index 00000000..ff10e3eb --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/rejecting-admission-policy.yaml @@ -0,0 +1,38 @@ +# A ValidatingAdmissionPolicy that refuses XDownstreamResources carrying the configData values the +# diff fixtures render, so a dry-run against the apiserver is rejected (crossplane-diff#334). +# +# Why a VAP and not a CEL x-kubernetes-validations rule on the CRD: crossplane-diff's own schema +# validator evaluates CEL rules locally, before the diff calculator runs, so a CEL rule would reject +# the resource without the apiserver ever being consulted. The test would pass on code that never +# dry-ran anything — the exact false positive this instrument has to avoid. A VAP is invisible to the +# local validator, needs no webhook server, and has been GA since Kubernetes 1.30. +# +# CREATE covers the addition path (dry-run create); UPDATE covers the existing-resource path, where +# a server-side-apply dry-run reaches admission as an UPDATE. The same policy therefore drives the +# rejection tests for both, including the deliberate reclassification of an existing resource's +# rejection from a tool error to a validation failure. +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicy +metadata: + name: reject-marked-downstream-resources +spec: + failurePolicy: Fail + matchConstraints: + resourceRules: + - apiGroups: ["ns.nop.example.org"] + apiVersions: ["v1alpha1"] + operations: ["CREATE", "UPDATE"] + resources: ["xdownstreamresources"] + validations: + - expression: >- + !has(object.spec) || !has(object.spec.forProvider) || !has(object.spec.forProvider.configData) + || !(object.spec.forProvider.configData in ['new-value', 'modified-value']) + message: "this cluster refuses that configData value" +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingAdmissionPolicyBinding +metadata: + name: reject-marked-downstream-resources +spec: + policyName: reject-marked-downstream-resources + validationActions: ["Deny"] diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-composition.yaml new file mode 100644 index 00000000..766a06f8 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml new file mode 100644 index 00000000..4d6bc8d1 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-cluster-downstream-resource1.yaml @@ -0,0 +1,9 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + name: resource-to-be-kept +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-composition.yaml new file mode 100644 index 00000000..67995f9f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xlegacychildresources.legacycluster.diff.example.org +spec: + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XLegacyChildResource + mode: Pipeline + pipeline: + - step: generate-managed-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }}-managed + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: managed-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.childField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-xrd.yaml new file mode 100644 index 00000000..ce48cad4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-child-xrd.yaml @@ -0,0 +1,24 @@ +# A legacy (v1) XR that a legacy XR composes, so a removal hierarchy at the v1 spec.resourceRefs path is +# two real XRs deep, as in a cluster: removal-test-legacy-nested-xr.yaml is one. +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xlegacychildresources.legacycluster.diff.example.org +spec: + group: legacycluster.diff.example.org + names: + kind: XLegacyChildResource + plural: xlegacychildresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + childField: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-composition.yaml new file mode 100644 index 00000000..e8fa8909 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-composition.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: removal-test-composition +spec: + compositeTypeRef: + apiVersion: legacycluster.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml new file mode 100644 index 00000000..10d846bf --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr-managed.yaml @@ -0,0 +1,11 @@ +# The managed resource removal-test-legacy-child-composition.yaml composed for +# removal-test-legacy-nested-xr.yaml. +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: resource-to-be-removed-managed + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: child-value diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr.yaml new file mode 100644 index 00000000..e421b6f2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacy-nested-xr.yaml @@ -0,0 +1,13 @@ +# removal-test-nested-xr.yaml at the legacy (v1) paths: the nested XR removal-test-legacy-composition +# once composed as resource2. It composes removal-test-legacy-nested-xr-managed.yaml in turn, through +# the revision the harness creates for removal-test-legacy-child-composition.yaml. +apiVersion: legacycluster.diff.example.org/v1alpha1 +kind: XLegacyChildResource +metadata: + name: resource-to-be-removed + annotations: + crossplane.io/composition-resource-name: resource2 +spec: + childField: child-value + compositionRef: + name: xlegacychildresources.legacycluster.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml new file mode 100644 index 00000000..e03c4398 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-legacycluster-downstream-resource1.yaml @@ -0,0 +1,9 @@ +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: resource1 + name: resource-to-be-kept +spec: + forProvider: + configData: existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr-managed.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr-managed.yaml new file mode 100644 index 00000000..0bde272b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr-managed.yaml @@ -0,0 +1,11 @@ +# The managed resource nested/child-composition.yaml composed for removal-test-nested-xr.yaml. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: resource-to-be-removed-managed + namespace: default + annotations: + crossplane.io/composition-resource-name: managed-resource +spec: + forProvider: + configData: child-value diff --git a/cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr.yaml new file mode 100644 index 00000000..6ff9ea7b --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/removal-test-nested-xr.yaml @@ -0,0 +1,15 @@ +# The nested XR the removal-test compositions once composed as resource2, as Crossplane left it, with +# the composition of its own kind it was composed with. It composes removal-test-nested-xr-managed.yaml in +# turn, so removing it removes that too. +apiVersion: ns.nested.example.org/v1alpha1 +kind: XChildResource +metadata: + name: resource-to-be-removed + namespace: default + annotations: + crossplane.io/composition-resource-name: resource2 +spec: + childField: child-value + crossplane: + compositionRef: + name: xchildresources.nested.example.org diff --git a/cmd/crossplane/project/testdata/diff/resources/rendered-defaults/definitions.yaml b/cmd/crossplane/project/testdata/diff/resources/rendered-defaults/definitions.yaml new file mode 100644 index 00000000..5976be20 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/rendered-defaults/definitions.yaml @@ -0,0 +1,129 @@ +# XRDs whose defaults a composition reads, one modern (v2, machinery under spec.crossplane) and one +# legacy (v1, machinery directly under spec), each with a composition that writes the composite's +# defaulted fields into a composed resource. The CRDs are generated from these XRDs (upstream xcrd), so +# the XR CRDs default region from the XRD's schema and, for the legacy XR, spec.compositionUpdatePolicy +# from defaultCompositionUpdatePolicy, as in a real cluster. A render input that was not defaulted would +# write "" instead. +# +# The modern template does not read spec.crossplane.compositionUpdatePolicy: its CRD defaults the +# policy only inside an existing spec.crossplane, which a new XR's manifest does not have, so the tool +# renders a new modern XR without one. (In a cluster Crossplane writes spec.crossplane before it first +# composes, so the apiserver defaults the policy by then.) +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xregionaldatabases.rendered-defaults.diff.example.org +spec: + group: rendered-defaults.diff.example.org + names: + kind: XRegionalDatabase + plural: xregionaldatabases + scope: Namespaced + defaultCompositionUpdatePolicy: Automatic + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + size: + type: string + region: + type: string + default: us-east-1 +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xregionaldatabases.rendered-defaults.diff.example.org +spec: + compositeTypeRef: + apiVersion: rendered-defaults.diff.example.org/v1alpha1 + kind: XRegionalDatabase + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.region }}/{{ .observed.composite.resource.spec.size }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xlegacyregionaldatabases.rendered-defaults.diff.example.org +spec: + group: rendered-defaults.diff.example.org + names: + kind: XLegacyRegionalDatabase + plural: xlegacyregionaldatabases + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + size: + type: string + region: + type: string + default: eu-west-1 +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xlegacyregionaldatabases.rendered-defaults.diff.example.org +spec: + compositeTypeRef: + apiVersion: rendered-defaults.diff.example.org/v1alpha1 + kind: XLegacyRegionalDatabase + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: database + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.region }}/{{ .observed.composite.resource.spec.size }}/{{ .observed.composite.resource.spec.compositionUpdatePolicy }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-downstream.yaml new file mode 100644 index 00000000..8e54b1b0 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-downstream.yaml @@ -0,0 +1,11 @@ +# The composed resource of existing-xr.yaml, as composition selector-bound-a rendered it. +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: selector-bound + namespace: default + annotations: + crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: a-existing-value diff --git a/cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-xr.yaml new file mode 100644 index 00000000..9711b44c --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/selector-bound/existing-xr.yaml @@ -0,0 +1,16 @@ +# A composite whose compositionSelector matches both compositions in setup.yaml, and which Crossplane +# bound to the first, selector-bound-a. +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: selector-bound + namespace: default +spec: + coolField: existing-value + crossplane: + compositionSelector: + matchLabels: + binding: shared + compositionRef: + name: selector-bound-a + compositionUpdatePolicy: Automatic diff --git a/cmd/crossplane/project/testdata/diff/resources/selector-bound/setup.yaml b/cmd/crossplane/project/testdata/diff/resources/selector-bound/setup.yaml new file mode 100644 index 00000000..e33e1e16 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/selector-bound/setup.yaml @@ -0,0 +1,74 @@ +# Two compositions that both match one compositionSelector. existing-xr.yaml is a composite Crossplane +# has already bound to the first, and existing-downstream.yaml its composed resource. Crossplane selects +# a composition once, when the composite has no compositionRef, and never re-selects +# (composite.APILabelSelectorResolver), so the composite stays on composition A however many +# compositions its selector matches. +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: selector-bound-a + labels: + binding: shared +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: a-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: selector-bound-b + labels: + binding: shared +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: b-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-composition.yaml new file mode 100644 index 00000000..3a56fdf9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-composition.yaml @@ -0,0 +1,53 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^stage0- # Stage 0 (composition-resource-name starts with stage0-) + - ^stage1- # Stage 1 (hidden until stage 0 is ready, unless it already exists) diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-stage0-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-stage0-downstream.yaml new file mode 100644 index 00000000..fa1608b9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-stage0-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage0-resource + name: 0-stage0-resource + namespace: default +spec: + forProvider: + configData: test-value diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-stage1-downstream.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-stage1-downstream.yaml new file mode 100644 index 00000000..23e1180e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-stage1-downstream.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + annotations: + crossplane.io/composition-resource-name: stage1-resource + name: 1-stage1-resource + namespace: default +spec: + forProvider: + configData: test-value diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-composition.yaml new file mode 100644 index 00000000..706f8194 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-composition.yaml @@ -0,0 +1,67 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: sequencer-with-envconfig-composition +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: get-environment-configs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + environmentConfigs: + - type: Reference + ref: + name: test-env-config + - step: generate-all-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + {{ $envConfig := index .context "apiextensions.crossplane.io/environment" }} + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 0-stage0-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage0-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: {{ $envConfig.environment }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: 1-stage1-resource + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: stage1-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: {{ $envConfig.environment }} + - step: sequence-resources + functionRef: + name: function-sequencer + input: + apiVersion: sequencer.fn.crossplane.io/v1beta1 + kind: Input + rules: + - sequence: + - ^0- # Stage 0 resources (rendered first) + - ^1- # Stage 1 resources (hidden until stage 0 is ready) + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-xr.yaml new file mode 100644 index 00000000..52c724cd --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-with-envconfig-xr.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-envconfig-test + namespace: default +spec: + crossplane: + compositionRef: + name: sequencer-with-envconfig-composition + coolField: "test-value" diff --git a/cmd/crossplane/project/testdata/diff/resources/sequencer-xr.yaml b/cmd/crossplane/project/testdata/diff/resources/sequencer-xr.yaml new file mode 100644 index 00000000..de0f52ec --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/sequencer-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: sequencer-test + namespace: default +spec: + coolField: "test-value" diff --git a/cmd/crossplane/project/testdata/diff/resources/staging-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/staging-composition.yaml new file mode 100644 index 00000000..5cf736f9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/staging-composition.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: staging-composition + labels: + provider: aws + environment: staging +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-staging-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: staging-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: "staging" + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/unestablished-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/unestablished-xrd.yaml new file mode 100644 index 00000000..286e15ee --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/unestablished-xrd.yaml @@ -0,0 +1,25 @@ +# An XRD unrelated to any input, applied with no generated CRD: one Crossplane has not established yet, +# so the apiserver serves neither its XR type nor its claim type. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xunestablishedresources.unrelated.example.org +spec: + group: unrelated.example.org + names: + kind: XUnestablishedResource + plural: xunestablishedresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml new file mode 100644 index 00000000..26561167 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/unguarded-external-res-gotpl-composition.yaml @@ -0,0 +1,44 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: unguarded-templated-extra-resources.diff.example.org +spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: render-templates + functionRef: + name: function-go-templating + input: + apiVersion: gotemplating.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + --- + apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 + kind: ExtraResources + requirements: + configmaps: + apiVersion: v1 + kind: ConfigMap + matchLabels: + app: test-app + environment: {{ .observed.composite.resource.spec.environment }} + {{- $configMaps := index .extraResources "configmaps" }} + {{- range $i, $configMap := $configMaps.items }} + --- + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ $.observed.composite.resource.metadata.name }} + namespace: {{ $.observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ $.observed.composite.resource.spec.coolField }} + roleName: templated-{{ $configMap.resource.metadata.name }} + {{- end }} diff --git a/cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml b/cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml new file mode 100644 index 00000000..380d66c5 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths-composition.yaml @@ -0,0 +1,32 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.v2withv1paths.diff.example.org +spec: + compositeTypeRef: + apiVersion: v2withv1paths.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths.yaml b/cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths.yaml new file mode 100644 index 00000000..e150c8a9 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/v2-xrd-with-v1-paths.yaml @@ -0,0 +1,101 @@ +# This is a v2 XRD that explicitly supports both v1-style and v2-style composition paths. +# Used to test issue #206: v2 XRs using v1-style composition paths are not recognized. +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.v2withv1paths.diff.example.org +spec: + group: v2withv1paths.diff.example.org + names: + kind: XNopResource + plural: xnopresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + coolField: + type: string + environment: + type: string + # v1-style composition fields (for backwards compatibility) + compositionRef: + type: object + properties: + name: + type: string + compositionRevisionRef: + type: object + properties: + name: + type: string + compositionUpdatePolicy: + type: string + enum: + - Automatic + - Manual + compositionSelector: + type: object + properties: + matchLabels: + type: object + additionalProperties: + type: string + # v2-style composition fields (nested under crossplane) + crossplane: + type: object + properties: + compositionRef: + type: object + properties: + name: + type: string + compositionRevisionRef: + type: object + properties: + name: + type: string + compositionUpdatePolicy: + type: string + enum: + - Automatic + - Manual + compositionSelector: + type: object + properties: + matchLabels: + type: object + additionalProperties: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/xdownstreamenvresource-xrd.yaml b/cmd/crossplane/project/testdata/diff/resources/xdownstreamenvresource-xrd.yaml new file mode 100644 index 00000000..5e2954ba --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/xdownstreamenvresource-xrd.yaml @@ -0,0 +1,31 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xdownstreamenvresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XDownstreamEnvResource + plural: xdownstreamenvresources + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + forProvider: + type: object + properties: + configData: + type: string + region: + type: string + environment: + type: string + serviceLevel: + type: string diff --git a/cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml b/cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml new file mode 100644 index 00000000..350d2c84 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml @@ -0,0 +1,30 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xconcurrenttests.concurrent.diff.example.org +spec: + group: concurrent.diff.example.org + names: + kind: XConcurrentTest + plural: xconcurrenttests + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + config: + type: string + description: Configuration data for the test + default: "test-config" + dataField: + type: string + description: Additional data field + default: "default-data" + required: + - config \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/xrd-with-defaults.yaml b/cmd/crossplane/project/testdata/diff/resources/xrd-with-defaults.yaml new file mode 100644 index 00000000..bbd6eb8e --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/resources/xrd-with-defaults.yaml @@ -0,0 +1,79 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xtestdefaultresources.ns.diff.example.org +spec: + group: ns.diff.example.org + names: + kind: XTestDefaultResource + plural: xtestdefaultresources + scope: Namespaced + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + region: + type: string + default: "us-east-1" + description: "AWS region with default value" + size: + type: string + default: "small" + description: "Instance size with default" + settings: + type: object + default: + enabled: true + timeout: 30 + properties: + enabled: + type: boolean + default: true + timeout: + type: integer + default: 30 + retries: + type: integer + default: 3 + tags: + type: object + default: + environment: "development" + properties: + environment: + type: string + default: "development" + team: + type: string + status: + type: object + properties: + conditions: + description: Conditions of the resource. + type: array + items: + type: object + required: + - lastTransitionTime + - reason + - status + - type + properties: + lastTransitionTime: + type: string + format: date-time + message: + type: string + reason: + type: string + status: + type: string + type: + type: string \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/unchanged-automatic-pinned-claim.yaml b/cmd/crossplane/project/testdata/diff/unchanged-automatic-pinned-claim.yaml new file mode 100644 index 00000000..c22ad2e2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/unchanged-automatic-pinned-claim.yaml @@ -0,0 +1,12 @@ +# resources/pinned-claim/existing-automatic.yaml's claim as its author keeps it: unchanged, but without +# the compositionRevisionRef the claim syncer copied onto the cluster copy (issue #498). +apiVersion: pinned.diff.example.org/v1alpha1 +kind: Pinned +metadata: + name: automatic-claim + namespace: existing-namespace +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/unchanged-lagging-automatic-xr.yaml b/cmd/crossplane/project/testdata/diff/unchanged-lagging-automatic-xr.yaml new file mode 100644 index 00000000..10905951 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/unchanged-lagging-automatic-xr.yaml @@ -0,0 +1,11 @@ +# resources/pinned-claim/existing-lagging-automatic-xr.yaml's XR as its author keeps it: unchanged, and +# without the compositionRevisionRef Crossplane's composite reconciler writes. +apiVersion: pinned.diff.example.org/v1alpha1 +kind: XPinned +metadata: + name: lagging-xr +spec: + coolField: existing-value + compositionUpdatePolicy: Automatic + compositionRef: + name: xpinneds.pinned.diff.example.org diff --git a/cmd/crossplane/project/testdata/diff/unmodified-xr.yaml b/cmd/crossplane/project/testdata/diff/unmodified-xr.yaml new file mode 100644 index 00000000..9d00b3d4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/unmodified-xr.yaml @@ -0,0 +1,7 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "existing-value" diff --git a/cmd/crossplane/project/testdata/diff/xr-with-ambiguous-selector.yaml b/cmd/crossplane/project/testdata/diff/xr-with-ambiguous-selector.yaml new file mode 100644 index 00000000..86c1d6c4 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-ambiguous-selector.yaml @@ -0,0 +1,11 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionSelector: + matchLabels: + provider: aws diff --git a/cmd/crossplane/project/testdata/diff/xr-with-argocd-annotations.yaml b/cmd/crossplane/project/testdata/diff/xr-with-argocd-annotations.yaml new file mode 100644 index 00000000..f9dcd869 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-argocd-annotations.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app:/XNopResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource","namespace":"default"}} + labels: + argocd.argoproj.io/instance: test-app +spec: + coolField: "new-value" diff --git a/cmd/crossplane/project/testdata/diff/xr-with-argocd-mixed-changes.yaml b/cmd/crossplane/project/testdata/diff/xr-with-argocd-mixed-changes.yaml new file mode 100644 index 00000000..3a399e9f --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-argocd-mixed-changes.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default + annotations: + argocd.argoproj.io/tracking-id: "test-app:/XNopResource:default/test-resource" + kubectl.kubernetes.io/last-applied-configuration: | + {"apiVersion":"ns.diff.example.org/v1alpha1","kind":"XNopResource","metadata":{"name":"test-resource","namespace":"default"}} + labels: + argocd.argoproj.io/instance: test-app +spec: + coolField: "modified-value" diff --git a/cmd/crossplane/project/testdata/diff/xr-with-composition-ref.yaml b/cmd/crossplane/project/testdata/diff/xr-with-composition-ref.yaml new file mode 100644 index 00000000..e417e325 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-composition-ref.yaml @@ -0,0 +1,10 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionRef: + name: production-composition diff --git a/cmd/crossplane/project/testdata/diff/xr-with-composition-selector.yaml b/cmd/crossplane/project/testdata/diff/xr-with-composition-selector.yaml new file mode 100644 index 00000000..bf5bdc13 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-composition-selector.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-resource + namespace: default +spec: + coolField: "test-value" + crossplane: + compositionSelector: + matchLabels: + environment: staging + provider: aws diff --git a/cmd/crossplane/project/testdata/diff/xr-with-missing-defaults.yaml b/cmd/crossplane/project/testdata/diff/xr-with-missing-defaults.yaml new file mode 100644 index 00000000..d1d9007d --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-missing-defaults.yaml @@ -0,0 +1,9 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-defaults + namespace: default +spec: + # Only specify one field, leaving others to use defaults from XRD + size: "large" + # Note: region, settings, and tags are not specified and should get defaults \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/xr-with-overridden-defaults.yaml b/cmd/crossplane/project/testdata/diff/xr-with-overridden-defaults.yaml new file mode 100644 index 00000000..53f5dfc2 --- /dev/null +++ b/cmd/crossplane/project/testdata/diff/xr-with-overridden-defaults.yaml @@ -0,0 +1,19 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XTestDefaultResource +metadata: + name: test-resource-with-overrides + namespace: default +spec: + # Override the default region (default: us-east-1) + region: "us-west-2" + # Override the default size (default: small) + size: "xlarge" + # Partially override settings (default: {enabled: true, timeout: 30}) + settings: + enabled: false # Override default true + timeout: 60 # Override default 30 + # retries should get default value of 3 + # Override default tags (default: {environment: "development"}) + tags: + environment: "production" # Override default "development" + team: "platform" # New field, no default to override \ No newline at end of file diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index 48fe549e..d1ec8b39 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -18,16 +18,15 @@ import ( "github.com/crossplane/cli/v2/internal/simulate" ) -// The assertions below read composition simulate's structured output, simulate.ProjectResult, behind the -// builder API in structured_assertions.go. The builders speak crossplane-diff's vocabulary -// (added/modified/removed changes, impactAnalysis[] entries); this file maps each onto its -// simulate.ProjectResult home: +// The assertions below read project simulate's structured output, simulate.ProjectResult, behind the builder API +// in structured_assertions.go. The builders speak crossplane-diff's vocabulary (added/modified/removed +// changes, xrs[] entries, impactAnalysis[] entries); this file maps each onto its simulate.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; +// - an xrs[] or impactAnalysis[] entry is a Composites entry, its changes the plans grouped under it; // - a composition is a Compositions entry plus its own (ungrouped) plan; // - an error's resourceID ("Kind/name") is the Kind and Name of its Errors[].resource. @@ -49,9 +48,8 @@ func changeAction(changeType string) plan.Action { // 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(). +// AssertStructured checks a simulate.ProjectResult 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() @@ -62,8 +60,38 @@ func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { t.Fatalf("failed to parse plan result JSON output: %v\nOutput was:\n%s", err, jsonOutput) } + if s := expected.summary; s != nil { + assertSummary(t, "Summary", s, res.Summary.Create, res.Summary.Update, res.Summary.Destroy, res.Summary.Replace) + } + + changes := changePlans(res.Plans, func(*plan.ResourcePlan) bool { return true }) + assertResourceExpectations(t, "", changes, expected.resources) + + if len(expected.resources) > 0 && len(changes) != len(expected.resources) { + t.Errorf("Expected %d changes, got %d: %v", len(expected.resources), len(changes), planIDs(changes)) + } + + for _, want := range expected.errors { + got := findMatchingError(res.Errors, want.resourceID) + if got == nil { + t.Errorf("Expected error for resourceID %q not found in output. Actual error IDs: %v", + want.resourceID, errorIDs(res.Errors)) + + continue + } + + assertErrorMatch(t, *got, want) + } + + // Pinning any error expectations pins the count too. Zero expectations = no opinion on errors. + if len(expected.errors) > 0 && len(res.Errors) != len(expected.errors) { + t.Errorf("Expected %d errors in structured output, got %d", len(expected.errors), len(res.Errors)) + } + assertWarnings(t, res.Warnings, warningMatches(expected.warnings, func(w *WarningExpectation) warningMatch { return w.warningMatch }), expected.noWarnings) + assertXRExpectations(t, res, expected.xrs) + 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)) @@ -112,6 +140,8 @@ func assertComposition(t *testing.T, res simulate.ProjectResult, want *Compositi t.Errorf("%s: expected composition changes but found no plan for it", label) case compPlan.Action != changeAction(want.compositionChangeType): t.Errorf("%s: expected action %s, got %q (error %q)", label, changeAction(want.compositionChangeType), compPlan.Action, compPlan.Error) + default: + assertFields(t, label, compPlan, want.compositionChangeType, nil, nil) } } @@ -206,7 +236,7 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp t.Helper() i := slices.IndexFunc(composites, func(c simulate.CompositeResult) bool { - return refMatches(c.Ref, want.kind, want.name, want.namespace) + return refMatches(c.Ref, want.kind, want.name, want.namespace, nil, false) }) if i < 0 { t.Errorf("%s: Expected XR impact %s/%s (ns=%s) not found. Actual XRs: %v", @@ -253,7 +283,7 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp } for _, wantRes := range want.downstreamResources { - p := findMatchingPlan(downstream, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace) + p := findMatchingPlan(downstream, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace, wantRes.namePattern) if p == nil { t.Errorf("%s: expected downstream %s resource %s/%s not found. Actual: %v", label, wantRes.changeType, wantRes.kind, wantRes.name, planIDs(downstream)) @@ -278,23 +308,111 @@ func statusMatches(got simulate.CompositeStatus, want string) bool { return string(got) == want } -// assertFields checks a plan's field expectations. values read the planned side, or the actual side -// of a delete, which carries what is removed. changes compare both sides. +// assertXRExpectations checks the xrs[] expectations against Composites. +// +// Each expectation consumes the entry it matches, so two expectations can never both resolve to the +// same entry, and the entry count is pinned once the test has an opinion on xrs[] at all. Without those +// guards a duplicated identity satisfied every expectation that matched it (issues #477, #488). +func assertXRExpectations(t *testing.T, res simulate.ProjectResult, expected []*XRExpectation) { + t.Helper() + + if len(expected) == 0 { + return + } + + if len(res.Composites) != len(expected) { + t.Errorf("Expected %d xrs[] entries, got %d: %v", len(expected), len(res.Composites), compositeIDs(res.Composites)) + } + + used := make([]bool, len(res.Composites)) + + for _, want := range expected { + i := -1 + for j, c := range res.Composites { + if !used[j] && refMatches(c.Ref, want.kind, want.name, want.namespace, nil, true) { + i = j + break + } + } + + if i < 0 { + t.Errorf("Expected xrs[] entry %s/%s (ns=%s) not found. Actual: %v", + want.kind, want.name, want.namespace, compositeIDs(res.Composites)) + + continue + } + + used[i] = true + got := res.Composites[i] + label := fmt.Sprintf("%s/%s", got.Ref.Kind, got.Ref.Name) + + if want.status != "" && !statusMatches(got.Status, want.status) { + t.Errorf("xr %s: status: expected %q, got %q", label, want.status, got.Status) + } + + changes := changePlans(res.Plans, groupedUnder(got.Ref)) + + if s := want.summary; s != nil { + create, update, destroy, replace := countActions(changes) + assertSummary(t, "xr "+label+": summary", s, create, update, destroy, replace) + } + + assertResourceExpectations(t, label, changes, want.changes) + + // Pin the entry's change count too, so a change the test did not ask for cannot hide inside a + // matched entry. + if len(want.changes) > 0 && len(changes) != len(want.changes) { + t.Errorf("xr %s: expected %d changes, got %d: %v", label, len(want.changes), len(changes), planIDs(changes)) + } + + for _, id := range want.errorIDs { + e := findMatchingError(res.Errors, id) + if e == nil || e.Ref == nil || *e.Ref != got.Ref { + t.Errorf("xr %s: expected error with resourceID %q not found against it. Actual error IDs: %v", label, id, errorIDs(res.Errors)) + } + } + } +} + +// assertResourceExpectations checks each expectation against a set of change plans. scope is "" for +// the flat view, or an XR's Kind/name, and is used only in failure messages. +func assertResourceExpectations(t *testing.T, scope string, changes []*plan.ResourcePlan, resources []*ResourceExpectation) { + t.Helper() + + prefix := "" + if scope != "" { + prefix = "xr " + scope + ": " + } + + for _, want := range resources { + p := findMatchingPlan(changes, want.changeType, want.kind, want.name, want.namespace, want.namePattern) + if p == nil { + t.Errorf("%sExpected %s resource %s/%s (ns=%s) not found. Actual resources: %v", + prefix, want.changeType, want.kind, want.name, want.namespace, planIDs(changes)) + + continue + } + + id := fmt.Sprintf("%s%s %s/%s", prefix, want.changeType, want.kind, want.name) + assertDryRun(t, id, p.DryRun, want.dryRun) + assertFields(t, id, p, want.changeType, want.fieldValues, want.fieldChanges) + } +} + +// assertFields checks a plan's field expectations. values read the planned side, or the +// actual side of a delete, which carries what is removed. changes compare both sides. func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType string, values map[string]any, changes map[string][2]any) { t.Helper() planned := changeType != dt.DiffTypeWordRemoved for path, want := range values { - // A field that is absent is not an explicit null, so WithField(path, nil) needs it present. - if got, found := valueAt(t, p.Changes, planned, path); !found || !valuesEqual(got, want) { - t.Errorf("%s: field %s: expected %v, got %v (present: %t)", id, path, want, got, found) - } + assertFieldValue(t, id, p, planned, path, want) } for path, want := range changes { if want[0] == nil && want[1] == nil { - t.Fatalf("%s: field %s: WithFieldChange(path, nil, nil) asserts nothing", id, path) + t.Fatalf("%s: field %s: WithFieldChange(path, nil, nil) asserts nothing; use WithFieldAbsent", id, path) } // FieldChanges are leaf changes only, so a field rendered identically on both sides is read from @@ -323,6 +441,49 @@ func assertFields(t *testing.T, id string, p *plan.ResourcePlan, changeType stri } } +// assertFieldValue checks one WithField or WithFieldAbsent expectation against the planned side, or +// the actual side of a delete. +func assertFieldValue(t *testing.T, id string, p *plan.ResourcePlan, planned bool, path string, want any) { + t.Helper() + + got, found := valueAt(t, p.Changes, planned, path) + + if _, ok := want.(absentField); ok { + if found { + t.Errorf("%s: field %s: expected absent, got %v", id, path, got) + } + + if planned { + assertAbsentInAfter(t, id, p, path) + } + + return + } + + // A field that is absent is not an explicit null, so WithField(path, nil) needs it present. + if !found || !valuesEqual(got, want) { + t.Errorf("%s: field %s: expected %v, got %v (present: %t)", id, path, want, got, found) + } + + if want == nil && planned { + assertAbsentInAfter(t, id, p, path) + } +} + +// assertAbsentInAfter requires path to be absent from the plan's After object, not only from its leaf +// changes, when After is set. +func assertAbsentInAfter(t *testing.T, id string, p *plan.ResourcePlan, path string) { + t.Helper() + + if p.After == nil { + return + } + + if got, ok := objectValueAt(t, p.After, path); ok { + t.Errorf("%s: field %s: expected absent, but after carries %v", id, path, got) + } +} + // 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. @@ -362,10 +523,14 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx t.Errorf("%s: dryRun.detail %q does not contain %q — the cluster's own explanation must be propagated, not swallowed", id, got.Detail, want.detailSubstring) } + + if want.storageVersion != "" && got.StorageVersion != want.storageVersion { + t.Errorf("%s: dryRun.storageVersion: expected %q, got %q", id, want.storageVersion, got.StorageVersion) + } } // assertWarnings checks each expectation against got, then — if the test pinned any expectations, or -// asserted there are none — verifies no extras were emitted. Without the +// asserted there are none — verifies no extras were emitted, mirroring the errors[] guard. Without the // count check, warning duplication is untestable: a regression that emits the same advisory once per // XR still satisfies every find-first-match expectation. No expectations and no noWarnings = no opinion. func assertWarnings(t *testing.T, got []simulate.Warning, want []warningMatch, noWarnings bool) { @@ -416,6 +581,82 @@ func assertWarningExpectation(t *testing.T, got []simulate.Warning, used []bool, t.Errorf("expected a warning containing %q in structured output; got %v", want.messageContains, warningMessages(got)) } +// assertErrorMatch checks a single error against its expectation. +func assertErrorMatch(t *testing.T, got simulate.Error, want *ErrorExpectation) { + t.Helper() + + if want.messageContains != "" && !strings.Contains(got.Message, want.messageContains) { + t.Errorf("error %q: message %q does not contain %q", want.resourceID, got.Message, want.messageContains) + } + + for _, wantVF := range want.validationFailures { + i := slices.IndexFunc(got.ValidationFailures, func(v simulate.ValidationFailure) bool { + return v.APIVersion == wantVF.apiVersion && v.Kind == wantVF.kind && v.Name == wantVF.name && + (wantVF.namespace == "" || v.Namespace == wantVF.namespace) + }) + if i < 0 { + actual := make([]string, 0, len(got.ValidationFailures)) + for _, vf := range got.ValidationFailures { + actual = append(actual, fmt.Sprintf("%s/%s %s/%s", vf.APIVersion, vf.Kind, vf.Namespace, vf.Name)) + } + + t.Errorf("error %q: expected validationFailure %s/%s %s/%s not found. Actual: %v", + want.resourceID, wantVF.apiVersion, wantVF.kind, wantVF.namespace, wantVF.name, actual) + + continue + } + + assertValidationFailureMatch(t, got.ValidationFailures[i], wantVF) + } + + if len(want.validationFailures) > 0 && len(got.ValidationFailures) != len(want.validationFailures) { + t.Errorf("error %q: expected %d validationFailures, got %d", + want.resourceID, len(want.validationFailures), len(got.ValidationFailures)) + } +} + +func assertValidationFailureMatch(t *testing.T, got simulate.ValidationFailure, want *ValidationFailureExpectation) { + t.Helper() + + label := fmt.Sprintf("validationFailure %s/%s %s", want.apiVersion, want.kind, want.name) + + if want.status != "" && got.Status != want.status { + t.Errorf("%s: status: expected %q, got %q", label, want.status, got.Status) + } + + for _, wantFE := range want.errors { + i := slices.IndexFunc(got.Errors, func(fe simulate.FieldValidationError) bool { + return fe.Type == wantFE.errType && fe.Field == wantFE.field + }) + if i < 0 { + actual := make([]string, 0, len(got.Errors)) + for _, fe := range got.Errors { + actual = append(actual, fmt.Sprintf("[%s] %s", fe.Type, fe.Field)) + } + + t.Errorf("%s: expected fieldError [%s] %s not found. Actual: %v", label, wantFE.errType, wantFE.field, actual) + + continue + } + + gotFE := got.Errors[i] + + if wantFE.messageContains != "" && !strings.Contains(gotFE.Message, wantFE.messageContains) { + t.Errorf("%s: fieldError [%s] %s: message %q does not contain %q", + label, wantFE.errType, wantFE.field, gotFE.Message, wantFE.messageContains) + } + + if wantFE.hasValue && !reflect.DeepEqual(gotFE.Value, wantFE.value) { + t.Errorf("%s: fieldError [%s] %s: value: expected %v (%T), got %v (%T)", + label, wantFE.errType, wantFE.field, wantFE.value, wantFE.value, gotFE.Value, gotFE.Value) + } + } + + if len(want.errors) > 0 && len(got.Errors) != len(want.errors) { + t.Errorf("%s: expected %d field errors, got %d", label, len(want.errors), len(got.Errors)) + } +} + func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy, replace int) { t.Helper() @@ -477,16 +718,16 @@ func findPlan(plans []*plan.ResourcePlan, ref plan.ResourceRef, composite *plan. return nil } -// findMatchingPlan returns the first plan with changeType's action, kind, name and (when given) -// namespace. -func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namespace string) *plan.ResourcePlan { +// findMatchingPlan returns the first plan with changeType's action, kind and (when given) namespace, +// whose name is any, matches namePattern, or equals name. +func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namespace string, namePattern *regexp.Regexp) *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) { + if refMatches(ref, kind, name, namespace, namePattern, false) { return p } } @@ -494,9 +735,19 @@ func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namesp return nil } -// refMatches reports whether ref has kind, name and, unless namespace is empty, namespace. -func refMatches(ref plan.ResourceRef, kind, name, namespace string) bool { - return ref.Kind == kind && ref.Name == name && (namespace == "" || ref.Namespace == namespace) +// refMatches reports whether ref has kind and the expected name (any, matching namePattern, or equal). +// An empty namespace matches any namespace unless exactNamespace is set. +func refMatches(ref plan.ResourceRef, kind, name, namespace string, namePattern *regexp.Regexp, exactNamespace bool) bool { + if ref.Kind != kind || ((exactNamespace || namespace != "") && ref.Namespace != namespace) { + return false + } + + switch { + case namePattern != nil: + return namePattern.MatchString(ref.Name) + default: + return ref.Name == name + } } // findMatchingError returns the error whose resource is resourceID ("Kind/name"). diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index c4350b49..48dbf75b 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -1,8 +1,10 @@ -// Package simtest asserts on composition simulate's structured output, simulate.ProjectResult, through -// crossplane-diff's composition expectation builders. +// Package simtest asserts on project simulate's structured output, simulate.ProjectResult, through +// crossplane-diff's expectation builders. package simtest import ( + "regexp" + dt "github.com/crossplane/cli/v2/internal/diff/result" ) @@ -12,20 +14,25 @@ type Expectation interface { expectation() *Expected } -// Expected is a fluent builder for test expectations on a simulate.ProjectResult: its compositions, -// errors and warnings. Each section is asserted only if it was expected. +// Expected is a fluent builder for test expectations on a simulate.ProjectResult, covering both xr-style sections +// (summary, flat resources, errors, XR groups) and composition-style sections. Each section is asserted +// only if it was expected. type Expected struct { // compositions are asserted by composition ref, with no count pinned. compositions []*CompositionExpectation // errorIDs are resourceIDs the top-level errors[] must contain (no count pinned); see WithErrorID. errorIDs []string - warnings []*WarningExpectation + summary *expectedSummary + resources []*ResourceExpectation + errors []*ErrorExpectation + warnings []*WarningExpectation // noWarnings asserts warnings[] is empty. Separate from len(warnings)==0, which means "no opinion". noWarnings bool + xrs []*XRExpectation } -// warningMatch describes one expected entry in warnings[]. Matched +// warningMatch describes one expected entry in warnings[], shared by the xr and comp builders. Matched // by message substring rather than exact equality, since warning prose is not a stable contract; // context keys are asserted exactly, because those ARE what a machine consumer reads. type warningMatch struct { @@ -33,7 +40,7 @@ type warningMatch struct { context map[string]string } -// WarningExpectation describes one expected entry in warnings[]. +// WarningExpectation describes one expected entry in an xr diff's warnings[]. type WarningExpectation struct { warningMatch @@ -44,12 +51,21 @@ func (w *WarningExpectation) expectation() *Expected { return w.parent } func (e *Expected) expectation() *Expected { return e } -// ResourceExpectation defines expectations for a single downstream resource change under an XR impact. +type expectedSummary struct { + added int + modified int + removed int +} + +// ResourceExpectation defines expectations for a single resource change. type ResourceExpectation struct { - parent *XRImpactExpectation - changeType string // "added", "modified", "removed" + parent *Expected + xrParent *XRExpectation // set when this change is scoped to an xrs[] entry + impactParent *XRImpactExpectation // set when this is a downstream resource under an XR impact + changeType string // "added", "modified", "removed" kind string name string + namePattern *regexp.Regexp namespace string fieldValues map[string]any // For added/removed: field path -> expected value fieldChanges map[string][2]any // For modified: field path -> [old, new] @@ -57,7 +73,10 @@ type ResourceExpectation struct { dryRun dryRunExpectation } -// dryRunExpectation holds a downstream change's dryRun assertions. +// dryRunExpectation holds the dryRun assertions. It is shared by the flat (xr) +// and downstream (comp) change builders because dryRun lives on ChangeDetail, +// which is the same per-resource wire shape for both commands — so asserting it +// two different ways would let the two drift apart. // // skipReason, when non-empty, asserts the change carries a dryRun object with // that skipReason. mustBePerformed asserts the opposite — that no dryRun object @@ -67,16 +86,115 @@ type ResourceExpectation struct { type dryRunExpectation struct { skipReason string detailSubstring string + storageVersion string mustBePerformed bool } -func (r *ResourceExpectation) expectation() *Expected { return r.parent.expectation() } +// expectation returns the root builder. Only meaningful for a flat top-level +// change (r.parent set) or a downstream resource (r.impactParent set); an XR-scoped +// change (r.xrParent set) is built as a standalone sub-expression passed to WithXRs +// and is never walked back to a root, so it returns nil there and that is fine. +func (r *ResourceExpectation) expectation() *Expected { + if r.impactParent != nil { + return r.impactParent.expectation() + } + + return r.parent +} // Expect creates a new Expected builder. func Expect() *Expected { - return &Expected{compositions: make([]*CompositionExpectation, 0)} + return &Expected{ + resources: make([]*ResourceExpectation, 0), + compositions: make([]*CompositionExpectation, 0), + } } +// WithSummary sets the expected summary counts. +func (e *Expected) WithSummary(added, modified, removed int) *Expected { + e.summary = &expectedSummary{ + added: added, + modified: modified, + removed: removed, + } + + return e +} + +// WithAddedResource adds an expectation for an added resource. +func (e *Expected) WithAddedResource(kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: e, + changeType: dt.DiffTypeWordAdded, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + } + e.resources = append(e.resources, r) + + return r +} + +// WithModifiedResource adds an expectation for a modified resource. +func (e *Expected) WithModifiedResource(kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: e, + changeType: dt.DiffTypeWordModified, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + } + e.resources = append(e.resources, r) + + return r +} + +// ErrorExpectation describes one expected entry in the structured +// errors[] payload. Match is by ResourceID; ValidationFailures, when +// non-nil, asserts the per-resource typed validation breakdown. +type ErrorExpectation struct { + parent *Expected + resourceID string + messageContains string + validationFailures []*ValidationFailureExpectation +} + +func (e *ErrorExpectation) expectation() *Expected { return e.parent } + +// ValidationFailureExpectation describes one expected entry in +// errors[].validationFailures[]. Match is by APIVersion/Kind/Name; +// Errors asserts on the per-field validation entries within. +type ValidationFailureExpectation struct { + parent *ErrorExpectation + apiVersion string + kind string + name string + namespace string + status string // optional; if "", no assertion + errors []*FieldErrorExpectation // optional; nil = no assertion on per-field errors +} + +func (v *ValidationFailureExpectation) expectation() *Expected { return v.parent.parent } + +// FieldErrorExpectation describes one expected entry in +// errors[].validationFailures[].errors[]. Match is by Type+Field +// (Field may be empty for non-localized errors); messageContains and +// value are optional refinements. +type FieldErrorExpectation struct { + parent *ValidationFailureExpectation + errType string + field string + messageContains string + hasValue bool + value any +} + +func (f *FieldErrorExpectation) expectation() *Expected { return f.parent.parent.parent } + // WithWarning asserts that structured output carries a warning whose message contains the supplied // substring. Chain WithWarningContext to pin the machine-readable context pairs. func (e *Expected) WithWarning(messageContains string) *WarningExpectation { @@ -106,12 +224,138 @@ func (w *WarningExpectation) WithWarningContext(context map[string]string) *Warn // And returns to the parent builder. func (w *WarningExpectation) And() *Expected { return w.parent } +// WithError attaches an expectation for an entry in the structured +// errors[] payload, matched by resourceID. Use the returned +// ErrorExpectation to add validationFailures expectations. +func (e *Expected) WithError(resourceID string) *ErrorExpectation { + er := &ErrorExpectation{ + parent: e, + resourceID: resourceID, + } + e.errors = append(e.errors, er) + + return er +} + +// WithMessageContaining adds a substring assertion on the error's +// Message field. Optional — useful when machine consumers care about +// the human-readable message too. +func (e *ErrorExpectation) WithMessageContaining(s string) *ErrorExpectation { + e.messageContains = s + return e +} + +// WithValidationFailure adds an expectation for one entry in the +// error's validationFailures[] slice, matched by APIVersion/Kind/Name. +// namespace is optional (empty string skips the namespace assertion); +// pass the actual namespace to lock down namespaced resources. +func (e *ErrorExpectation) WithValidationFailure(apiVersion, kind, name, namespace string) *ValidationFailureExpectation { + v := &ValidationFailureExpectation{ + parent: e, + apiVersion: apiVersion, + kind: kind, + name: name, + namespace: namespace, + } + e.validationFailures = append(e.validationFailures, v) + + return v +} + +// AndError returns to the parent Expected to chain another +// WithError call, mirroring the AndXR / AndComp helpers elsewhere in +// this file. +func (e *ErrorExpectation) AndError() *Expected { + return e.parent +} + +// WithStatus pins the validation status on this resource entry +// ("invalid", "missingSchema"). Optional; empty status skips the +// assertion. +func (v *ValidationFailureExpectation) WithStatus(status string) *ValidationFailureExpectation { + v.status = status + return v +} + +// WithFieldError adds an expectation for one entry in the failure's +// errors[] slice, matched by errType ("schema", "cel", +// "unknownField", "defaulting") and field (empty matches +// non-localized errors like defaulting). +func (v *ValidationFailureExpectation) WithFieldError(errType, field string) *FieldErrorExpectation { + f := &FieldErrorExpectation{ + parent: v, + errType: errType, + field: field, + } + v.errors = append(v.errors, f) + + return f +} + +// AndValidationFailure returns to the parent ErrorExpectation to chain +// another WithValidationFailure. +func (v *ValidationFailureExpectation) AndValidationFailure() *ErrorExpectation { + return v.parent +} + +// WithMessageContaining adds a substring assertion on this field +// error's Message. Optional. +func (f *FieldErrorExpectation) WithMessageContaining(s string) *FieldErrorExpectation { + f.messageContains = s + return f +} + +// WithValue pins the bad value on this field error. Pass the value as +// it would arrive after JSON decoding (e.g. float64 for numbers, +// string for strings); for "any number is fine" omit this method. +func (f *FieldErrorExpectation) WithValue(v any) *FieldErrorExpectation { + f.hasValue = true + f.value = v + + return f +} + +// AndFieldError returns to the parent ValidationFailureExpectation to +// chain another WithFieldError. +func (f *FieldErrorExpectation) AndFieldError() *ValidationFailureExpectation { + return f.parent +} + +// WithRemovedResource adds an expectation for a removed resource. +func (e *Expected) WithRemovedResource(kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + parent: e, + changeType: dt.DiffTypeWordRemoved, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + } + e.resources = append(e.resources, r) + + return r +} + // WithField asserts a specific field exists with exact value (for added/removed resources). func (r *ResourceExpectation) WithField(path string, value any) *ResourceExpectation { r.fieldValues[path] = value return r } +// absentField is the fieldValues entry WithFieldAbsent stores, which assertFields tells from the +// explicit null WithField(path, nil) stores. +type absentField struct{} + +// WithFieldAbsent asserts the path carries no value at all (for added/removed resources). +// +// Named rather than spelled WithField(path, nil) at call sites, because "this field must not be +// here" is a different claim from "this field equals nothing" and reads as a typo otherwise. +func (r *ResourceExpectation) WithFieldAbsent(path string) *ResourceExpectation { + r.fieldValues[path] = absentField{} + return r +} + // WithFieldChange asserts a field changed from old to new value (for modified resources). func (r *ResourceExpectation) WithFieldChange(path string, oldValue, newValue any) *ResourceExpectation { r.fieldChanges[path] = [2]any{oldValue, newValue} @@ -131,6 +375,15 @@ func (r *ResourceExpectation) WithDryRunSkipped(reason, detailSubstring string) return r } +// WithDryRunStorageVersion asserts this change, reported as not verified (see +// WithDryRunSkipped), records version as the version its CRD stores it at: +// one other than the version it was requested at, whose defaults and +// conversion its locally predicted diff does not model. +func (r *ResourceExpectation) WithDryRunStorageVersion(version string) *ResourceExpectation { + r.dryRun.storageVersion = version + return r +} + // WithDryRunPerformed asserts this change carries NO dryRun object, i.e. its // desired state did go through the apiserver. Worth asserting explicitly on the // happy path: without it, a regression that silently stopped dry-running @@ -141,8 +394,123 @@ func (r *ResourceExpectation) WithDryRunPerformed() *ResourceExpectation { return r } -// WithErrorID asserts the top-level errors[] contains an error with the given resourceID. It pins -// neither the error's details nor the error count. +// WithNamePattern matches resource name against a regex pattern instead of exact name. +func (r *ResourceExpectation) WithNamePattern(pattern string) *ResourceExpectation { + r.namePattern = regexp.MustCompile(pattern) + return r +} + +// And returns to the root Expected to chain more expectations. +// Not needed at the end of a chain - AssertStructured accepts a +// ResourceExpectation directly. +func (r *ResourceExpectation) And() *Expected { + e := r.expectation() + if e == nil { + panic("And chained on an XR-scoped change; it has no root builder") + } + + return e +} + +// Change opens a sibling change on the same enclosing XR entry (only valid on a +// change opened via XRExpectation.Change). Lets an XR's changes chain without a +// scope-closing call: xr.Change(...).FieldChange(...).Change(...). Panics if +// called on a flat top-level change (which has no enclosing XR). +func (r *ResourceExpectation) Change(changeType, kind, name, namespace string) *ResourceExpectation { + if r.xrParent == nil { + panic("Change chained on a flat (non-XR) change; open the change via XR(...).Change(...)") + } + + return r.xrParent.Change(changeType, kind, name, namespace) +} + +// XRExpectation defines expectations for a single entry in the xrs[] grouped +// view: the input XR's identity, its status, its per-XR summary, and its +// per-XR changes. Build one with XR(...), configure it with the methods below, +// then pass it (with any siblings) to Expected.WithXRs. +type XRExpectation struct { + kind string + name string + namespace string + status string + summary *expectedSummary + changes []*ResourceExpectation + errorIDs []string +} + +// XR begins a standalone expectation for one xrs[] entry, keyed by the input +// XR's kind/name/namespace. Pass the result(s) to Expected.WithXRs. +func XR(kind, name, namespace string) *XRExpectation { + return &XRExpectation{kind: kind, name: name, namespace: namespace} +} + +// xrSpec is satisfied by the two things a WithXRs argument can end on: an +// XRExpectation itself, or a ResourceExpectation returned by an XR's Change(…) +// sub-chain (which resolves back to its enclosing XR). This lets a per-XR +// sub-expression terminate on either a status/summary call or a field call +// without an explicit scope-closing step. +type xrSpec interface { + xrExpectation() *XRExpectation +} + +func (x *XRExpectation) xrExpectation() *XRExpectation { return x } + +func (r *ResourceExpectation) xrExpectation() *XRExpectation { return r.xrParent } + +// WithXRs attaches per-input-XR expectations (built via XR(...)) to assert +// against the xrs[] grouped view, in order. +func (e *Expected) WithXRs(xrs ...xrSpec) *Expected { + for _, x := range xrs { + e.xrs = append(e.xrs, x.xrExpectation()) + } + + return e +} + +// Build terminates the chain and returns the root expectation. Optional — +// AssertStructured accepts an *Expected directly — but it reads as an +// explicit "done building" marker. +func (e *Expected) Build() *Expected { return e } + +// Status asserts the entry's status (changed/unchanged/error). +func (x *XRExpectation) Status(status string) *XRExpectation { + x.status = status + return x +} + +// Summary asserts the entry's per-XR summary counts. +func (x *XRExpectation) Summary(added, modified, removed int) *XRExpectation { + x.summary = &expectedSummary{added: added, modified: modified, removed: removed} + return x +} + +// Err asserts the entry carries an error with the given resourceID. +func (x *XRExpectation) Err(resourceID string) *XRExpectation { + x.errorIDs = append(x.errorIDs, resourceID) + return x +} + +// Change opens a change expectation scoped to this XR entry's changes[]. +// changeType is "added", "modified", or "removed". Returns a +// ResourceExpectation whose field methods (WithField/WithFieldChange/…) and +// chained Change(...) let a change and its siblings be expressed inline. +func (x *XRExpectation) Change(changeType, kind, name, namespace string) *ResourceExpectation { + r := &ResourceExpectation{ + xrParent: x, + changeType: changeType, + kind: kind, + name: name, + namespace: namespace, + fieldValues: make(map[string]any), + fieldChanges: make(map[string][2]any), + } + x.changes = append(x.changes, r) + + return r +} + +// WithErrorID asserts the top-level errors[] contains an error with the given resourceID. Unlike +// WithError it pins neither the error's details nor the error count. func (e *Expected) WithErrorID(resourceID string) *Expected { e.errorIDs = append(e.errorIDs, resourceID) return e @@ -158,9 +526,8 @@ type CompositionExpectation struct { affectedWithErrors *int affectedFilteredByDeletion *int xrImpacts []*XRImpactExpectation - // compositionChangeType, when set, is the composition's own change: "modified", the only one a - // composition makes. - compositionChangeType string + // Composition changes expectations + compositionChangeType string // "modified" - composition changes are always modifications // impactAnalysisSkipped, when set, asserts the composites were deliberately not evaluated. impactAnalysisSkipped *bool // revisionImpact, when set, asserts what applying the composition does to CompositionRevisions. @@ -207,13 +574,6 @@ type XRImpactExpectation struct { func (x *XRImpactExpectation) expectation() *Expected { return x.parent.parent } -// expectedSummary is the expected count of each change type. -type expectedSummary struct { - added int - modified int - removed int -} - // WithComposition adds an expectation for a composition. func (e *Expected) WithComposition(name string) *CompositionExpectation { c := &CompositionExpectation{ @@ -287,6 +647,7 @@ func (c *CompositionExpectation) WithoutPredictedRevisionName() *CompositionExpe // WithCompositionModified asserts that the composition itself is modified. func (c *CompositionExpectation) WithCompositionModified() *CompositionExpectation { c.compositionChangeType = dt.DiffTypeWordModified + return c } @@ -339,7 +700,7 @@ func (x *XRImpactExpectation) WithDownstreamSummary(added, modified, removed int // Use this to assert on specific field-level changes in downstream resources. func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, namespace string) *ResourceExpectation { r := &ResourceExpectation{ - parent: x, + impactParent: x, changeType: changeType, kind: kind, name: name, @@ -354,7 +715,14 @@ func (x *XRImpactExpectation) WithDownstreamResource(changeType, kind, name, nam // AndXR returns to the enclosing XR impact to chain more downstream resource expectations. // Not needed at the end of a chain - AssertStructured accepts a ResourceExpectation directly. -func (r *ResourceExpectation) AndXR() *XRImpactExpectation { return r.parent } +// Panics if called on a resource not opened via WithDownstreamResource. +func (r *ResourceExpectation) AndXR() *XRImpactExpectation { + if r.impactParent == nil { + panic("AndXR chained on a resource not opened via WithDownstreamResource") + } + + return r.impactParent +} // AndComp returns to parent to chain more XR impact expectations. // Not needed at the end of a chain - AssertStructured accepts XRImpactExpectation directly. @@ -362,6 +730,12 @@ func (x *XRImpactExpectation) AndComp() *CompositionExpectation { return x.parent } +// And returns to parent to chain more composition expectations. +// Not needed at the end of a chain - AssertStructured accepts CompositionExpectation directly. +func (c *CompositionExpectation) And() *Expected { + return c.parent +} + // AndComposition starts an expectation for another composition, from anywhere in the current // composition's chain. It exists because the only climb a caller genuinely needs is the one back to // the root to add a sibling — every other And()/AndXR()/AndComp() is avoidable, since the assert @@ -369,5 +743,22 @@ func (x *XRImpactExpectation) AndComp() *CompositionExpectation { // multi-composition expectation readable instead of ending each composition with a run of climbs // whose purpose is not obvious from reading them. func (r *ResourceExpectation) AndComposition(name string) *CompositionExpectation { - return r.expectation().WithComposition(name) + e := r.expectation() + if e == nil { + panic("AndComposition chained on an XR-scoped change; it has no root builder") + } + + return e.WithComposition(name) +} + +// AndComposition starts an expectation for another composition. See +// ResourceExpectation.AndComposition. +func (x *XRImpactExpectation) AndComposition(name string) *CompositionExpectation { + return x.expectation().WithComposition(name) +} + +// AndComposition starts an expectation for another composition. See +// ResourceExpectation.AndComposition. +func (c *CompositionExpectation) AndComposition(name string) *CompositionExpectation { + return c.expectation().WithComposition(name) } diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 86ca0408..66950dde 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -12,7 +12,7 @@ import ( "github.com/crossplane/cli/v2/internal/simulate" ) -// resultJSON serializes r the way composition simulate's -o json does. +// resultJSON serializes r the way project simulate's -o json does. func resultJSON(t *testing.T, r simulate.ProjectResult) string { t.Helper() @@ -38,6 +38,174 @@ func checkAssertion(t *testing.T, name string, wantFail bool, assert func(*testi } } +var ( + xrA = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "changed-xr", Namespace: "default"} + xrB = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "unchanged-xr", Namespace: "default"} + xrC = plan.ResourceRef{APIVersion: "example.org/v1", Kind: "XBucket", Name: "broken-xr", Namespace: "default"} +) + +// bucket is bucket-a's object in region, with an unchanged tier. +func bucket(region string) *unstructured.Unstructured { + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.org/v1", "kind": "Bucket", + "metadata": map[string]any{"name": "bucket-a", "namespace": "default"}, + "spec": map[string]any{"region": region, "tier": "premium"}, + }} +} + +// xrResult is an xr run: one changed XR (a modified Bucket, a created ConfigMap and a deleted Secret), +// one unchanged XR, and one errored XR. +func xrResult() simulate.ProjectResult { + plans := []*plan.ResourcePlan{ + { + APIVersion: "example.org/v1", Kind: "Bucket", Name: "bucket-a", Namespace: "default", Action: plan.ActionUpdate, Composite: &xrA, + Before: bucket("us-east-1"), After: bucket("us-west-2"), + Changes: []plan.FieldChange{ + {Path: "spec.region", Actual: val("us-east-1"), Planned: val("us-west-2")}, + {Path: "metadata.annotations[example.org/source]", Actual: val("old"), Planned: val("new")}, + }, + }, + { + APIVersion: "v1", Kind: "ConfigMap", Name: "cm-x7k2p", Namespace: "default", Action: plan.ActionCreate, Composite: &xrA, + Changes: []plan.FieldChange{ + {Path: "data.key", Planned: val("value")}, + {Path: "spec.items[0].name", Planned: val("first")}, + {Path: "spec.items[1].name", Planned: val("second")}, + }, + }, + { + APIVersion: "v1", Kind: "Secret", Name: "gone", Namespace: "default", Action: plan.ActionDelete, Composite: &xrA, + Changes: []plan.FieldChange{{Path: "type", Actual: val("Opaque")}}, + }, + {APIVersion: xrB.APIVersion, Kind: xrB.Kind, Name: xrB.Name, Namespace: xrB.Namespace, Action: plan.ActionNoOp, Composite: &xrB}, + {APIVersion: xrC.APIVersion, Kind: xrC.Kind, Name: xrC.Name, Namespace: xrC.Namespace, Error: "cannot get composition", Composite: &xrC}, + } + + return simulate.ProjectResult{ + Result: plan.Result{ + Summary: plan.Summarize(plans), + Plans: plans, + }, + Composites: []simulate.CompositeResult{ + {Ref: xrA, Status: simulate.CompositeChanged}, + {Ref: xrB, Status: simulate.CompositeUnchanged}, + {Ref: xrC, Status: simulate.CompositeError}, + }, + Errors: []simulate.Error{{Ref: &xrC, Message: "cannot get composition"}}, + Warnings: []simulate.Warning{{Message: "something advisory", Context: map[string]string{"composition": "c"}}}, + } +} + +func TestAssertStructuredXRPlan(t *testing.T) { + out := resultJSON(t, xrResult()) + + // Any resource expectation pins the change count, so a case about one change lists the others. + withoutBucket := func() *Expected { + return Expect().WithAddedResource("ConfigMap", "cm-x7k2p", "default").And().WithRemovedResource("Secret", "gone", "default").And() + } + withoutConfigMap := func() *Expected { + return Expect().WithModifiedResource("Bucket", "bucket-a", "default").And().WithRemovedResource("Secret", "gone", "default").And() + } + + cases := map[string]struct { + want Expectation + wantFail bool + }{ + "Summary": {want: Expect().WithSummary(1, 1, 1)}, + "WrongSummary": {want: Expect().WithSummary(0, 1, 1), wantFail: true}, + "AllChanges": {want: Expect(). + WithModifiedResource("Bucket", "bucket-a", "default"). + WithFieldChange("spec.region", "us-east-1", "us-west-2"). + WithFieldChange("metadata.annotations['example.org/source']", "old", "new"). + And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "default"). + WithField("data.key", "value"). + WithField("spec.items", []any{map[string]any{"name": "first"}, map[string]any{"name": "second"}}). + WithField("spec.items[1].name", "second"). + And(). + WithRemovedResource("Secret", "gone", "default"). + WithField("type", "Opaque")}, + // Any resource expectation pins the change count, so an unlisted change fails. + "UnlistedChange": {want: Expect(). + WithModifiedResource("Bucket", "bucket-a", "default").And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "default"), wantFail: true}, + "WrongAction": {want: Expect().WithAddedResource("Bucket", "bucket-a", "default"), wantFail: true}, + "WrongNewValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "us-east-1", "eu-west-1"), + wantFail: true, + }, + "WrongOldValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "eu-west-1", "us-west-2"), + wantFail: true, + }, + "NamePattern": {want: withoutConfigMap().WithAddedResource("ConfigMap", "", "default").WithNamePattern(`^cm-[a-z0-9]{5}$`)}, + "BadNamePattern": {want: withoutConfigMap().WithAddedResource("ConfigMap", "", "default").WithNamePattern(`^other-`), wantFail: true}, + // A field rendered identically on both sides has no leaf change: asserting it unchanged passes + // when the plan's before and after objects both hold the value and nothing at or under it + // changed, and fails otherwise. + "UnchangedField": {want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.tier", "premium", "premium")}, + "UnchangedWrongValue": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.tier", "basic", "basic"), + wantFail: true, + }, + "UnchangedMissing": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.size", "large", "large"), + wantFail: true, + }, + "ChangedNotSteady": { + want: withoutBucket().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldChange("spec.region", "us-east-1", "us-east-1"), + wantFail: true, + }, + "FieldAbsent": {want: withoutConfigMap().WithAddedResource("ConfigMap", "cm-x7k2p", "default").WithFieldAbsent("spec.other")}, + "FieldNotAbsent": {want: withoutConfigMap().WithAddedResource("ConfigMap", "cm-x7k2p", "default").WithFieldAbsent("data.key"), wantFail: true}, + "Error": {want: Expect().WithError("XBucket/broken-xr").WithMessageContaining("cannot get")}, + "WrongErrorMessage": {want: Expect().WithError("XBucket/broken-xr").WithMessageContaining("timeout"), wantFail: true}, + "MissingError": {want: Expect().WithError("XBucket/changed-xr"), wantFail: true}, + "Warning": {want: Expect().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "c"})}, + "WrongContext": {want: Expect().WithWarning("advisory").WithWarningContext(map[string]string{"composition": "d"}), wantFail: true}, + "NoWarnings": {want: Expect().WithNoWarnings(), wantFail: true}, + "GroupedByXR": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default"). + Status("changed"). + Summary(1, 1, 1). + Change("modified", "Bucket", "bucket-a", "default"). + WithFieldChange("spec.region", "us-east-1", "us-west-2"). + Change("added", "ConfigMap", "cm-x7k2p", "default"). + Change("removed", "Secret", "gone", "default"), + XR("XBucket", "unchanged-xr", "default").Status("unchanged"), + XR("XBucket", "broken-xr", "default").Status("error").Err("XBucket/broken-xr"), + ). + Build()}, + "GroupedWrongStatus": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default").Status("unchanged"), + XR("XBucket", "unchanged-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + // Two expectations cannot both consume the same entry. + "GroupedDuplicate": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default"), + XR("XBucket", "changed-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + "GroupedErrorOnWrongXR": {want: Expect(). + WithXRs( + XR("XBucket", "changed-xr", "default").Err("XBucket/broken-xr"), + XR("XBucket", "unchanged-xr", "default"), + XR("XBucket", "broken-xr", "default"), + ). + Build(), wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) + } +} + var ( compA = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "comp-a"} compB = plan.ResourceRef{APIVersion: "apiextensions.crossplane.io/v1", Kind: "Composition", Name: "comp-b"} @@ -255,3 +423,83 @@ func TestAssertFieldsAndWarnings(t *testing.T) { checkAssertion(t, name, tc.wantFail, tc.assert) } } + +// TestAssertStructuredCombined checks that xr-style and composition-style sections of one Expect() +// are asserted against a single simulate.ProjectResult, and that each still fails where it did on its own. +func TestAssertStructuredCombined(t *testing.T) { + res := xrResult() + res.Compositions = []simulate.CompositionResult{{Ref: compA, RevisionImpact: &simulate.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}}} + res.Composites = append(res.Composites, simulate.CompositeResult{Ref: xr1, Status: simulate.CompositeChanged, Composition: &compA}) + out := resultJSON(t, res) + + combined := func() *Expected { + e := Expect().WithSummary(1, 1, 1).WithError("XBucket/broken-xr").AndError().WithWarning("advisory").And(). + WithXRs( + XR("XBucket", "changed-xr", "default").Status("changed"), + XR("XBucket", "unchanged-xr", "default").Status("unchanged"), + XR("XBucket", "broken-xr", "default").Status("error"), + XR("XNop", "xr-1", "default").Status("changed")) + e.WithComposition("comp-a").WithRevisionImpact("spec", true, 1).WithXRImpact("XNop", "xr-1", "default", "changed") + + return e + } + + cases := map[string]struct { + want Expectation + wantFail bool + }{ + "Combined": {want: combined()}, + "WrongSummary": {want: combined().WithSummary(2, 1, 1), wantFail: true}, + "WrongRevision": {want: combined().WithComposition("comp-a").WithRevisionImpact("metadata", true, 1), wantFail: true}, + "WrongXRImpact": {want: combined().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "unchanged"), wantFail: true}, + "MissingErrorID": {want: combined().WithErrorID("Composition/comp-a"), wantFail: true}, + "AbsentFieldFails": {want: Expect().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldAbsent("spec.region"), wantFail: true}, + } + + for name, tc := range cases { + checkAssertion(t, name, tc.wantFail, func(t *testing.T) { t.Helper(); AssertStructured(t, out, tc.want) }) + } +} + +func TestResourceExpectationParents(t *testing.T) { + root := Expect() + flat := root.WithAddedResource("K", "n", "ns") + + if flat.And() != root { + t.Error("flat And() must return the root") + } + + impact := root.WithComposition("c").WithXRImpact("XR", "x", "ns", "changed") + down := impact.WithDownstreamResource("modified", "K", "n", "ns") + + if down.AndXR() != impact { + t.Error("downstream AndXR() must return the XR impact") + } + + if down.And() != root { + t.Error("downstream And() must return the root") + } + + var _ Expectation = down + + if down.expectation() != root { + t.Error("downstream expectation() must resolve to the root") + } + + for name, fn := range map[string]func(){ + "AndXR on flat": func() { flat.AndXR() }, + "And on XR-scoped": func() { XR("X", "x", "ns").Change("added", "K", "n", "ns").And() }, + "AndComposition on XR-scoped": func() { + XR("X", "x", "ns").Change("added", "K", "n", "ns").AndComposition("c") + }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: expected panic, not a nil parent", name) + } + }() + fn() + }() + } +} From cf0e5623dac7cdf2ea911adca8f537ab31e70b34 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 20:33:24 -0500 Subject: [PATCH 05/23] test(xr): restore the XR-only integration harness code Restore simArgs.FunctionCredentials with its --function-credentials argv, which the XR cases of TestDiffIntegration use. simulateArgv maps XRDiffTest to xr simulate and still fails on any other unknown test type. The admission-policy gate uses integration.AwaitAdmissionDenial. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- .../project/integration_simulate_harness_test.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index b1ee3c5a..70b040ad 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -78,6 +78,7 @@ type simArgs struct { Output string `default:"diff" enum:"diff,json,yaml" help:"Output format (diff, json, or yaml)." name:"output" short:"o"` NoColor bool `help:"Disable colorized output." name:"no-color"` IgnorePaths []string `help:"Paths to ignore in diffs." name:"ignore-paths"` + FunctionCredentials string `help:"Secret credentials to pass to Functions." name:"function-credentials" placeholder:"PATH"` EventualState bool `help:"Show eventual state after all reconciliation cycles complete." name:"eventual-state"` DryRunOn string `default:"all" enum:"existing,all" help:"Which resources to verify against the apiserver with a dry run." name:"dry-run-on"` Resource []string `help:"Composite resources to scope to." sep:","` @@ -114,12 +115,14 @@ func parseSimulateArgs(t *testing.T, testType DiffTestType, args []string) *simA func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { t.Helper() - cmd := "project" + var cmd string switch testType { case CompositionDiffTest: cmd = "composition" case XRDiffTest: cmd = "xr" + default: + t.Fatalf("simulateArgv: no simulate command for test type %q", testType) } argv := []string{ cmd, "simulate", @@ -149,6 +152,9 @@ func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { for _, p := range a.IgnorePaths { argv = append(argv, "--ignore-paths="+p) } + if a.FunctionCredentials != "" { + argv = append(argv, "--function-credentials="+a.FunctionCredentials) + } if a.EventualState { argv = append(argv, "--eventual-state") } From 10b3a25017ec5c27e99f559ca18d5d27511b94dc Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:30:41 -0500 Subject: [PATCH 06/23] test(simulate): show composite resource runs ignore the project file Move the build step of Run into Flags.build and test that both Composition and composite resource inputs take imageConfigs only from --image-configs, never from ./crossplane-project.yaml. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/run.go | 37 +++++++++++++++++++---------------- internal/simulate/run_test.go | 25 +++++++++++++++++++++++ 2 files changed, 45 insertions(+), 17 deletions(-) diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 9278a081..a70b2766 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -582,27 +582,13 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg return err } - built := &Built{Compositions: in.Compositions} msg := "cannot simulate Compositions" if project { msg = "cannot simulate project" } - if project { - if built, err = in.Build(sigCtx); err != nil { - return err - } - } else { - var ics []pkgv1beta1.ImageConfig - if ics, err = c.flagImageConfigs(); err != nil { - return err - } - built.Packages = &Packages{ICs: ics} - // Only planning fetches packages, so --skip-plan needs no cache. - if !c.SkipPlan { - if built.Packages.Client, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { - return errors.Wrap(err, "cannot create xpkg client") - } - } + built, err := c.build(sigCtx, in) + if err != nil { + return err } comps, fns, digests, pkgs := built.Compositions, built.Functions, built.Digests, built.Packages @@ -639,6 +625,23 @@ 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, msg)) } +// build builds in's project, or for file inputs (Compositions or composite +// resources) takes imageConfigs only from --image-configs. +func (c *Flags) build(ctx context.Context, in *Inputs) (*Built, error) { + if in.Build != nil { + return in.Build(ctx) + } + ics, err := c.flagImageConfigs() + if err != nil { + return nil, err + } + client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)) + if err != nil { + return nil, errors.Wrap(err, "cannot create xpkg client") + } + return &Built{Compositions: in.Compositions, Packages: &Packages{ICs: ics, Client: client}}, nil +} + // flagImageConfigs returns the ImageConfigs from --image-configs, or none. // Unlike ImageConfigs it never falls back to a project file in the current // directory: a command that takes files rather than a project uses only what diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index 56ef07c9..fc4c8e03 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -958,3 +958,28 @@ func TestFlagImageConfigsIgnoresProjectFile(t *testing.T) { t.Errorf("flagImageConfigs(): want no ImageConfigs, got %v", ics) } } + +func TestBuildFileInputsIgnoreProjectFile(t *testing.T) { + // Composition and composite resource runs inside a project directory + // must not read its project file. + 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) + + for name, in := range map[string]*Inputs{ + "Compositions": {Compositions: []*un.Unstructured{{}}}, + "Composites": {Composites: []*un.Unstructured{{}}}, + } { + t.Run(name, func(t *testing.T) { + built, err := (&Flags{}).build(context.Background(), in) + if err != nil { + t.Fatalf("build(): want no error, got %v", err) + } + if len(built.Packages.ICs) != 0 { + t.Errorf("build(): want no ImageConfigs, got %v", built.Packages.ICs) + } + }) + } +} From fd45af623286746b1559f44fcd07a46facd2dff3 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:30:41 -0500 Subject: [PATCH 07/23] docs(xr): say xr simulate -f takes a file or directory Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/xr/help/simulate.md | 6 +++--- cmd/crossplane/xr/simulate.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md index a83a1631..7fcc83d5 100644 --- a/cmd/crossplane/xr/help/simulate.md +++ b/cmd/crossplane/xr/help/simulate.md @@ -3,9 +3,9 @@ claims would change a cluster. It renders each one with the Composition the cluster selects for it and the functions installed in the cluster, and shows the difference from what's in the cluster. It doesn't change the cluster. -`-f` (`--input`) names a file of composite resources or claims to simulate, and -is required. Repeat it for more files; `-f -` reads standard input. Every -resource in the files must be a kind an XRD in the cluster defines as a +`-f` (`--input`) names a file, or a directory of YAML files, of composite +resources or claims to simulate, and is required. Repeat it for more; `-f -` +reads standard input. Every resource in them must be a kind an XRD in the cluster defines as a composite resource or claim; to simulate a Composition, use `crossplane composition simulate`, and to simulate a project, use `crossplane project simulate`. A claim is simulated as its composite resource, diff --git a/cmd/crossplane/xr/simulate.go b/cmd/crossplane/xr/simulate.go index bb373901..15c0ea8d 100644 --- a/cmd/crossplane/xr/simulate.go +++ b/cmd/crossplane/xr/simulate.go @@ -41,7 +41,7 @@ var ( type simulateCmd struct { simulate.Flags `embed:""` - Input []string `help:"Composite resource or claim 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:"Composite resource or claim 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 { From 80f48a2e4231159498dcc329024ab686cb042e7c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 11:43:15 -0500 Subject: [PATCH 08/23] docs(simulate): cross-reference all three simulate commands Point project, composition and xr simulate help at each other, and resource simulate help at the three. Mark xr simulate as added in the design doc and name the right commands in the integration testdata README. 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/testdata/README.md | 7 ++++--- cmd/crossplane/simulate/help/simulate.md | 5 +++++ cmd/crossplane/xr/help/simulate.md | 4 ++-- design/one-pager-cli-simulate.md | 4 ++-- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index a5665bbe..f2ef20ea 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -16,7 +16,8 @@ one `--context` names. `-f` (`--input`) names a file, or a directory of YAML files, of Compositions to simulate, and is required. Give a comma-separated list, or repeat it, for more; `-f -` reads standard input. Every resource in them must be a Composition; to simulate a project, use -`crossplane project simulate`. +`crossplane project simulate`, and to simulate composite resources or claims, +use `crossplane xr 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 8b523937..42b8da27 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -2,7 +2,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. To simulate Composition files against a cluster -without a project, use `crossplane composition simulate`. +without a project, use `crossplane composition simulate`; to simulate composite +resource or claim files, use `crossplane xr simulate`. > **Important:** Although it doesn't change the cluster, this command needs > `create` and `patch` permission on the resources it renders. It checks each diff --git a/cmd/crossplane/project/testdata/README.md b/cmd/crossplane/project/testdata/README.md index 4386420b..1c56adc7 100644 --- a/cmd/crossplane/project/testdata/README.md +++ b/cmd/crossplane/project/testdata/README.md @@ -11,12 +11,13 @@ the harness wiring differs. Every case is crossplane-diff CLI-style arguments plus an expected exit code and expected output, funneled through one function: `runSimulate` in `integration_simulate_harness_test.go`. It translates the arguments into -`crossplane project simulate` arguments (input files as `-f`, a single +`crossplane composition simulate` or `crossplane xr simulate` arguments (input +files as `-f`, a single `--resource` as `--name`, `--include-manual` as `--include-pinned`, `--analyze-unchanged` as `--analyze-on=always`) and runs the real command through kong. `simulateHooks` points the command at the case's -envtest cluster in place of a kubeconfig, so the cases run in parallel. Composition cases run simulate's Composition-file -mode, XR cases its composite resource mode. +envtest cluster in place of a kubeconfig, so the cases run in parallel. Composition cases run `composition simulate`, XR +cases `xr simulate`. ## Skips diff --git a/cmd/crossplane/simulate/help/simulate.md b/cmd/crossplane/simulate/help/simulate.md index 9caa16f0..b980ff43 100644 --- a/cmd/crossplane/simulate/help/simulate.md +++ b/cmd/crossplane/simulate/help/simulate.md @@ -11,6 +11,11 @@ desired and live state. The provider computes its own diff, so the preview includes replacement detection, values known only after apply, and redacted sensitive values. A resource with no live counterpart plans as a create. +This command takes managed resources. To simulate composite resources or +claims, use `crossplane xr simulate`; to simulate Compositions, use +`crossplane composition simulate`; to simulate a project, use +`crossplane project simulate`. + Which providers run as diff servers comes from the current kubeconfig context by default: the CLI follows each resource's kind to the provider that installed it on the cluster, so the preview matches the provider versions diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md index 7fcc83d5..f84cad16 100644 --- a/cmd/crossplane/xr/help/simulate.md +++ b/cmd/crossplane/xr/help/simulate.md @@ -5,8 +5,8 @@ the difference from what's in the cluster. It doesn't change the cluster. `-f` (`--input`) names a file, or a directory of YAML files, of composite resources or claims to simulate, and is required. Repeat it for more; `-f -` -reads standard input. Every resource in them must be a kind an XRD in the cluster defines as a -composite resource or claim; to simulate a Composition, use +reads standard input. Every resource in them must be a kind an XRD in the +cluster defines as a composite resource or claim; to simulate a Composition, use `crossplane composition simulate`, and to simulate a project, use `crossplane project simulate`. A claim is simulated as its composite resource, and its result is listed under the claim's name. diff --git a/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index d81f45e0..465081ac 100644 --- a/design/one-pager-cli-simulate.md +++ b/design/one-pager-cli-simulate.md @@ -83,8 +83,8 @@ many controllers (that remains the domain of control-plane-level simulation). ## Proposal Add `simulate` as a verb in the CLI's noun-first command tree, sharing one -engine: `resource simulate`, `project simulate` and `composition simulate` -first, then `xr simulate` later in the same stack. +engine: `resource simulate`, `project simulate`, `composition simulate` and +`xr simulate`, all added in the same stack. Each command takes one kind of input, so no command sniffs its inputs or defines flags that do nothing for them. From 6f39964c9933b8598c0584df136ebf431c6bad2e Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:12:30 -0500 Subject: [PATCH 09/23] fix(simulate): say why xr simulate rejects an input no XRD defines Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/inputs.go | 14 ++++++++++++-- internal/simulate/inputs_test.go | 8 ++++---- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go index 18481c71..4dfb7884 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -35,8 +35,18 @@ const XRTakes = "xr simulate takes composite resource or claim files" // take. takes describes what the command does take, e.g. "composition // simulate takes Composition files". func RejectInput(r *un.Unstructured, takes string) error { + return rejectInput(r, "", takes) +} + +// rejectInput is RejectInput with a reason, if not empty, that says why r was +// rejected and follows the input. +func rejectInput(r *un.Unstructured, reason, takes string) error { gvk := r.GroupVersionKind() - msg := fmt.Sprintf("-f inputs include %s %q (%s); %s", gvk.Kind, r.GetName(), gvk.GroupVersion(), takes) + msg := fmt.Sprintf("-f inputs include %s %q (%s)", gvk.Kind, r.GetName(), gvk.GroupVersion()) + if reason != "" { + msg += ", " + reason + } + msg += "; " + takes if r.IsList() || gvk.Kind == "List" { msg += "; pass its items as separate YAML documents instead of a List" } @@ -91,7 +101,7 @@ func checkComposites(ctx context.Context, defs xpclient.DefinitionClient, resour if xrd, err := defs.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { continue } - return RejectInput(r, XRTakes) + return rejectInput(r, "which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed)", XRTakes) } return nil } diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index dfe41820..f28645de 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -246,22 +246,22 @@ func TestCheckComposites(t *testing.T) { "ConfigurationMetadata": { reason: "A Configuration package metadata file should be rejected in the shared template, with no other command to point at.", resources: []*un.Unstructured{res("meta.pkg.crossplane.io/v1", "Configuration", "cfg")}, - err: `-f inputs include Configuration "cfg" (meta.pkg.crossplane.io/v1); xr simulate takes composite resource or claim files`, + err: `-f inputs include Configuration "cfg" (meta.pkg.crossplane.io/v1), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, }, "Provider": { reason: "A Provider should be rejected.", resources: []*un.Unstructured{res("example.org/v1", "XThing", "x"), res("pkg.crossplane.io/v1", "Provider", "provider-aws")}, - err: `-f inputs include Provider "provider-aws" (pkg.crossplane.io/v1); xr simulate takes composite resource or claim files`, + err: `-f inputs include Provider "provider-aws" (pkg.crossplane.io/v1), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, }, "ConfigMap": { reason: "A non-Crossplane kind should be rejected.", resources: []*un.Unstructured{res("v1", "ConfigMap", "cm")}, - err: `-f inputs include ConfigMap "cm" (v1); xr simulate takes composite resource or claim files`, + err: `-f inputs include ConfigMap "cm" (v1), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, }, "WrongVersion": { reason: "A composite resource at a version its XRD doesn't define should be rejected.", resources: []*un.Unstructured{res("example.org/v2", "XThing", "x")}, - err: `-f inputs include XThing "x" (example.org/v2); xr simulate takes composite resource or claim files`, + err: `-f inputs include XThing "x" (example.org/v2), which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed); xr simulate takes composite resource or claim files`, }, } for name, tc := range cases { From c0dc566ef598c610b6095618a85851956466290f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:12:59 -0500 Subject: [PATCH 10/23] fix(simulate): reject a kind: List at load in every simulate command Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/inputs_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index f28645de..f26c1a51 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -170,7 +170,7 @@ spec: want: want{err: `-f inputs include GoTemplate "g"`}, }, "KubectlList": { - reason: "A List, as kubectl get -o yaml writes, should be rejected with a hint to unwrap it.", + reason: "A List, as kubectl get -o yaml writes, should be rejected with a hint to unwrap it and no pointer at another command.", paths: []string{list}, want: want{err: `-f inputs include List "" (v1); composition simulate takes Composition files; pass its items as separate YAML documents instead of a List`}, }, From 0b71dbc90b5f1ea7bc4527c631d3b477e3934e6e Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:13:38 -0500 Subject: [PATCH 11/23] test(simulate): restore renderXRReport coverage in TestSimulateRenderReports Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/run_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index fc4c8e03..f7c0e5e4 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -29,6 +29,7 @@ import ( "github.com/alecthomas/kong" "github.com/google/go-cmp/cmp" "github.com/google/go-containerregistry/pkg/name" + "github.com/sergi/go-diff/diffmatchpatch" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -344,6 +345,13 @@ func TestSimulateDiffOptions(t *testing.T) { // human diff for diff. func TestSimulateRenderReports(t *testing.T) { xr := corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XNetwork", Name: "prod"} + cm := &un.Unstructured{Object: map[string]any{"apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]any{"name": "cm"}, "data": map[string]any{"k": "v"}}} + added := &dt.ResourceDiff{ + Gvk: cm.GroupVersionKind(), ResourceName: "cm", DiffType: dt.DiffTypeAdded, + Desired: dt.ResourceViews{Raw: cm, Clean: cm}, + LineDiffs: []diffmatchpatch.Diff{{Type: diffmatchpatch.DiffInsert, Text: "data:\n k: v\n"}}, + } + xrReport := &dt.XRReport{Groups: []dt.XRDiffGroup{{XR: xr, Diffs: map[string]*dt.ResourceDiff{added.GetDiffKey(): added}}}} compReport := &dt.CompDiffOutput{Compositions: []dt.CompositionDiff{{ Name: "net", RevisionImpact: dt.RevisionImpact{ChangeScope: "spec", CreatesRevision: true, RepointedComposites: 1}, @@ -377,26 +385,31 @@ func TestSimulateRenderReports(t *testing.T) { cases := map[string]struct { reason string cmd Flags + wantXR func(*testing.T, string) wantComp func(*testing.T, string) }{ "Plan": { reason: "-o plan, the default, should print the plan text, with the revision impact under the Composition.", cmd: Flags{Output: "plan"}, + wantXR: contains("XR: XNetwork.example.org prod\n [+] ConfigMap. cm (create)\n data:\n + k: \"v\"", "Plan: 1 to add", "Note: "), wantComp: contains("[=] Composition.apiextensions.crossplane.io net (no-op)\n Applying this creates a new CompositionRevision, which 1 composite would adopt.", "XR: XNetwork.example.org prod", "Note: "), }, "JSON": { reason: "-o json should print ProjectResult as JSON.", cmd: Flags{Output: "json"}, + wantXR: isPlanResult(json.Unmarshal), wantComp: isPlanResult(json.Unmarshal), }, "YAML": { reason: "-o yaml should print ProjectResult as YAML.", cmd: Flags{Output: "yaml"}, + wantXR: isPlanResult(func(b []byte, v any) error { return yaml.Unmarshal(b, v) }), wantComp: isPlanResult(func(b []byte, v any) error { return yaml.Unmarshal(b, v) }), }, "Diff": { reason: "-o diff should print the engine's human diff.", cmd: Flags{Output: "diff"}, + wantXR: contains("+++ ConfigMap/cm", "Summary: 1 added"), wantComp: contains("=== Composition Changes ===", "=== Affected Composite Resources ==="), }, } @@ -408,6 +421,10 @@ func TestSimulateRenderReports(t *testing.T) { render func(*kong.Context) error want func(*testing.T, string) }{ + "xr": {func(k *kong.Context) error { + _, err := tc.cmd.renderXRReport(k, logging.NewNopLogger(), xrReport, nil) + return err + }, tc.wantXR}, "comp": {func(k *kong.Context) error { _, err := tc.cmd.renderCompReport(k, logging.NewNopLogger(), compReport, nil) return err From 2be0bfb41df543093d7edf5dae9d95bd9a81f3ab Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:14:15 -0500 Subject: [PATCH 12/23] docs(simulate): point every simulate help at resource simulate Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/crossplane/composition/help/simulate.md | 5 +++-- cmd/crossplane/project/help/simulate.md | 3 ++- cmd/crossplane/xr/help/simulate.md | 5 +++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/cmd/crossplane/composition/help/simulate.md b/cmd/crossplane/composition/help/simulate.md index f2ef20ea..b33ebf22 100644 --- a/cmd/crossplane/composition/help/simulate.md +++ b/cmd/crossplane/composition/help/simulate.md @@ -16,8 +16,9 @@ one `--context` names. `-f` (`--input`) names a file, or a directory of YAML files, of Compositions to simulate, and is required. Give a comma-separated list, or repeat it, for more; `-f -` reads standard input. Every resource in them must be a Composition; to simulate a project, use -`crossplane project simulate`, and to simulate composite resources or claims, -use `crossplane xr simulate`. +`crossplane project simulate`, to simulate composite resources or claims, use +`crossplane xr simulate`, and to simulate managed resources directly, use +`crossplane resource simulate`. Functions come from the cluster. `--cache-dir` sets where provider package contents are cached. diff --git a/cmd/crossplane/project/help/simulate.md b/cmd/crossplane/project/help/simulate.md index 42b8da27..0a1844e6 100644 --- a/cmd/crossplane/project/help/simulate.md +++ b/cmd/crossplane/project/help/simulate.md @@ -3,7 +3,8 @@ would change the composite resources in a cluster. It renders each affected composite resource and shows the difference from what's in the cluster. It doesn't change the cluster. To simulate Composition files against a cluster without a project, use `crossplane composition simulate`; to simulate composite -resource or claim files, use `crossplane xr simulate`. +resource or claim files, use `crossplane xr simulate`; to simulate managed +resources directly, use `crossplane resource simulate`. > **Important:** Although it doesn't change the cluster, this command needs > `create` and `patch` permission on the resources it renders. It checks each diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md index f84cad16..f4862316 100644 --- a/cmd/crossplane/xr/help/simulate.md +++ b/cmd/crossplane/xr/help/simulate.md @@ -7,8 +7,9 @@ the difference from what's in the cluster. It doesn't change the cluster. resources or claims to simulate, and is required. Repeat it for more; `-f -` reads standard input. Every resource in them must be a kind an XRD in the cluster defines as a composite resource or claim; to simulate a Composition, use -`crossplane composition simulate`, and to simulate a project, use -`crossplane project simulate`. A claim is simulated as its composite resource, +`crossplane composition simulate`, to simulate a project, use +`crossplane project simulate`, and to simulate managed resources directly, use +`crossplane resource simulate`. A claim is simulated as its composite resource, and its result is listed under the claim's name. Functions come from the cluster. `--cache-dir` sets where provider package contents are cached. From 8d99792e613e0d0945765327d9a18cafe3e8a535 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:14:27 -0500 Subject: [PATCH 13/23] docs(simulate): make the output help and design doc true for xr simulate Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- design/one-pager-cli-simulate.md | 9 +++++---- internal/simulate/help/output.md | 5 +++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/design/one-pager-cli-simulate.md b/design/one-pager-cli-simulate.md index 465081ac..eb17ca20 100644 --- a/design/one-pager-cli-simulate.md +++ b/design/one-pager-cli-simulate.md @@ -340,8 +340,8 @@ type Flags struct { `crossplane resource simulate` shares the diff server, timeout, and output flags, and takes its resources from `-f` files instead of project -discovery. Both commands enter the tree with the CLI's `maturity:"alpha"` -tag and graduate the way every other command does. +discovery. Each simulate command enters the tree with the CLI's +`maturity:"alpha"` tag and graduates the way every other command does. ### Running Diff Servers Like Render Runs Functions @@ -517,8 +517,9 @@ standalone packaging forced them to build. files instead of being discovered from a project, and `xr` is the same preview driven by a changed XR file with the Compositions coming from the cluster. They become `composition simulate` and `xr simulate`, beside - `project simulate` (`-f` for changed Compositions or XRs, `--namespace` - and `--name` to scope the cluster side), and both gain the plan layer as + `project simulate` (`-f` for changed Compositions or XRs; + `composition simulate` adds `--namespace` and `--name` to scope the + cluster side), and both gain the plan layer as their upgrade. That upgrade is strictly additive, so today's diff functionality is subsumed rather than replaced: planning is a capability simulate detects per resource, and a resource with no diff server gets diff --git a/internal/simulate/help/output.md b/internal/simulate/help/output.md index ae061254..e6612f69 100644 --- a/internal/simulate/help/output.md +++ b/internal/simulate/help/output.md @@ -1,8 +1,9 @@ The `-o` flag picks the output format: - `plan`, the default, prints each change as a field-level plan, grouped under - the composite resource it belongs to. A changed Composition prints first, - with whether applying it creates a new Composition revision. + the composite resource it belongs to. When the run simulates a changed + Composition, the Composition prints first, with whether applying it creates + a new Composition revision. - `json` and `yaml` print the same plan as a structured result, for CI and other tools. Each plan carries the objects it compares, as `before` (the live object, absent for a create) and `after` (the rendered object, absent From 0618d886f143991a46c207672182fd2756398360 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:14:51 -0500 Subject: [PATCH 14/23] docs(simulate): fix stale integration harness and testdata README comments Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- .../integration_simulate_harness_test.go | 8 +- cmd/crossplane/project/testdata/README.md | 60 +++++++------- .../composition-multi-functions.yaml | 80 ------------------- .../diff/resources/xrd-concurrent.yaml | 30 ------- 4 files changed, 32 insertions(+), 146 deletions(-) delete mode 100644 cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml delete mode 100644 cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml diff --git a/cmd/crossplane/project/integration_simulate_harness_test.go b/cmd/crossplane/project/integration_simulate_harness_test.go index 70b040ad..9e7347de 100644 --- a/cmd/crossplane/project/integration_simulate_harness_test.go +++ b/cmd/crossplane/project/integration_simulate_harness_test.go @@ -21,7 +21,7 @@ limitations under the License. // https://github.com/crossplane-contrib/crossplane-diff/commit/6a140b65a6bdb428add78680e6caee9f08b7f04b, plus later upstream additions through // https://github.com/crossplane-contrib/crossplane-diff/commit/d93eb87c6315cb2b44e4790e369eef3590c31dff), written as its comp and xr commands' CLI args plus an expected // exit code and output. runSimulate translates those args into -// `crossplane composition simulate` args and runs the real command through +// `crossplane composition simulate` or `crossplane xr simulate` args and runs the real command through // kong against the case's envtest cluster, started by // internal/testutils/integration, so no case table changes. // @@ -173,12 +173,12 @@ func simulateArgv(t *testing.T, testType DiffTestType, a *simArgs) []string { } // runSimulate runs one case's args through the real `crossplane -// composition simulate` command against cfg, and returns the exit code the command +// composition simulate` or `crossplane xr simulate` command against cfg, and returns the exit code the command // would exit with, the error it would fail with, and everything it wrote, // including the error as main prints it. // Each call parses a fresh command, so a second run starts with cold -// client caches just as a second CLI invocation would; the restricted -// RBAC rerun relies on it. +// client caches just as a second CLI invocation would; the determinism +// re-run and the restricted RBAC rerun rely on it. func runSimulate(t *testing.T, cfg *rest.Config, testType DiffTestType, args []string, stdout, stderr io.Writer) (int, error) { t.Helper() diff --git a/cmd/crossplane/project/testdata/README.md b/cmd/crossplane/project/testdata/README.md index 1c56adc7..3c0e7e78 100644 --- a/cmd/crossplane/project/testdata/README.md +++ b/cmd/crossplane/project/testdata/README.md @@ -1,41 +1,37 @@ -# Simulate integration tests (crossplane-diff port) +# Simulate integration tests -The `integration_*_test.go` files in `cmd/crossplane/project` port -crossplane-diff's integration tests (origin/main 6a140b6: -`cmd/diff/diff_integration_test.go`, `diff_it_utils_test.go`, `main_test.go` -and the `testdata` tree) case for case. The case tables are verbatim; only -the harness wiring differs. +The `integration*_test.go` files in `cmd/crossplane/project` run +`crossplane project simulate -f ` against fixture projects +under `testdata/project`, each against its own envtest cluster seeded from +`testdata/comp/resources`. `TestCompDiffIntegration` runs +`crossplane composition simulate -f ` with the +crossplane-diff cases and fixtures under `testdata/comp`, plus a few shared +ones under `testdata/diff`. +`TestDiffIntegration` runs `crossplane xr simulate -f ` with +crossplane-diff's XR cases and the same fixtures. ## How a case runs -Every case is crossplane-diff CLI-style arguments plus an expected exit -code and expected output, funneled through one function: `runSimulate` in -`integration_simulate_harness_test.go`. It translates the arguments into -`crossplane composition simulate` or `crossplane xr simulate` arguments (input -files as `-f`, a single -`--resource` as `--name`, `--include-manual` as `--include-pinned`, -`--analyze-unchanged` as `--analyze-on=always`) and runs the real command -through kong. `simulateHooks` points the command at the case's -envtest cluster in place of a kubeconfig, so the cases run in parallel. Composition cases run `composition simulate`, XR -cases `xr simulate`. +A case copies its project fixture to a temp dir and runs the real command +through kong with `runSimulateArgvWith` in +`integration_simulate_harness_test.go`. `simulate.Hooks` points the +command at the case's envtest cluster in place of a kubeconfig, so the +cases run in parallel. -## Skips - -Cases are never dropped. A case the current simulate surface cannot -express is ported in full and skipped with a greppable reason: +A `TestCompDiffIntegration` or `TestDiffIntegration` case keeps its crossplane-diff args; +`runSimulate` translates them into `composition simulate`'s or +`xr simulate`'s flags (its +files as `-f`, `--resource` as `--name`, `--include-manual` as +`--include-pinned`) and runs the command the same way. -- `gap:` — the simulate command lacks the feature, or behaves - differently from crossplane-diff. Enable the cases by implementing the - feature and removing the gap from `simulateGap`. -- `env:` — the environment lacks something. The render engine - runs functions as Docker containers, so no Docker daemon skips the - case. Each case starts its cluster with - `internal/testutils/integration`'s `Start`, which fails the case, - rather than skipping it, when `KUBEBUILDER_ASSETS` or - `CROSSPLANE_CRDS_DIR` is unset. +## Skips -A small number of skips come from crossplane-diff itself (`skip: true` -in the source tables, with the upstream reason carried verbatim). +A case the environment cannot run skips with an `env:`: the +render engine runs functions as Docker containers, so no Docker daemon +skips the case. Each case starts its cluster with +`internal/testutils/integration`'s `Start`, which fails the case, rather +than skipping it, when `KUBEBUILDER_ASSETS` or `CROSSPLANE_CRDS_DIR` is +unset. ## Running @@ -46,7 +42,7 @@ KUBEBUILDER_ASSETS=$(setup-envtest use -p path) \ go test -tags integration -v ./cmd/crossplane/project/ # One case: -go test -tags integration -run 'TestCompDiffIntegration/CompositionChangeImpactsXRs' ./cmd/crossplane/project/ +go test -tags integration -run 'TestProjectSimulateIntegration/PlainProject' ./cmd/crossplane/project/ ``` The suite is behind the `integration` build tag, so `go test ./...` and diff --git a/cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml b/cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml deleted file mode 100644 index ddda8d62..00000000 --- a/cmd/crossplane/project/testdata/diff/resources/composition-multi-functions.yaml +++ /dev/null @@ -1,80 +0,0 @@ -apiVersion: apiextensions.crossplane.io/v1 -kind: Composition -metadata: - name: xconcurrenttests.diff.example.org -spec: - compositeTypeRef: - apiVersion: concurrent.diff.example.org/v1alpha1 - kind: XConcurrentTest - mode: Pipeline - pipeline: - # Step 1: Generate base resources - - step: generate-base-resources - functionRef: - name: function-go-templating - input: - apiVersion: template.fn.crossplane.io/v1beta1 - kind: GoTemplate - source: Inline - inline: - template: | - {{- range $i := until 3 }} - --- - apiVersion: concurrent.nop.example.org/v1alpha1 - kind: XBaseResource - metadata: - name: {{ $.observed.composite.resource.metadata.name }}-base-{{ $i }} - namespace: {{ $.observed.composite.resource.metadata.namespace }} - annotations: - gotemplating.fn.crossplane.io/composition-resource-name: base-resource-{{ $i }} - spec: - forProvider: - index: {{ $i }} - config: {{ $.observed.composite.resource.spec.config }} - {{- end }} - - # Step 2: Apply environment configs - - step: apply-environment-configs - functionRef: - name: function-environment-configs - input: - apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 - kind: EnvironmentConfigs - - # Step 3: Generate additional resources - - step: generate-additional-resources - functionRef: - name: function-go-templating - input: - apiVersion: template.fn.crossplane.io/v1beta1 - kind: GoTemplate - source: Inline - inline: - template: | - {{- range $i := until 2 }} - --- - apiVersion: concurrent.nop.example.org/v1alpha1 - kind: XAdditionalResource - metadata: - name: {{ $.observed.composite.resource.metadata.name }}-additional-{{ $i }} - namespace: {{ $.observed.composite.resource.metadata.namespace }} - annotations: - gotemplating.fn.crossplane.io/composition-resource-name: additional-resource-{{ $i }} - spec: - forProvider: - index: {{ $i }} - config: {{ $.observed.composite.resource.spec.config }} - {{- end }} - - # Step 4: Fetch extra resources if needed - - step: fetch-extra-resources - functionRef: - name: function-extra-resources - input: - apiVersion: extraresources.fn.crossplane.io/v1beta1 - kind: ExtraResources - - # Step 5: Auto-ready - - step: automatically-detect-ready-composed-resources - functionRef: - name: function-auto-ready \ No newline at end of file diff --git a/cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml b/cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml deleted file mode 100644 index 350d2c84..00000000 --- a/cmd/crossplane/project/testdata/diff/resources/xrd-concurrent.yaml +++ /dev/null @@ -1,30 +0,0 @@ -apiVersion: apiextensions.crossplane.io/v2 -kind: CompositeResourceDefinition -metadata: - name: xconcurrenttests.concurrent.diff.example.org -spec: - group: concurrent.diff.example.org - names: - kind: XConcurrentTest - plural: xconcurrenttests - versions: - - name: v1alpha1 - served: true - referenceable: true - schema: - openAPIV3Schema: - type: object - properties: - spec: - type: object - properties: - config: - type: string - description: Configuration data for the test - default: "test-config" - dataField: - type: string - description: Additional data field - default: "default-data" - required: - - config \ No newline at end of file From 4d13413fb557377734cc11f151430c151280aa7c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:16:51 -0500 Subject: [PATCH 15/23] test(simulate): make the reject symmetry check real and test diffComposites Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/run_test.go | 73 +++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index f7c0e5e4..fd9be3d1 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -33,6 +33,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/rest" "sigs.k8s.io/yaml" @@ -44,10 +45,12 @@ import ( pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + xpclient "github.com/crossplane/cli/v2/internal/diff/client/crossplane" dp "github.com/crossplane/cli/v2/internal/diff/diffprocessor" "github.com/crossplane/cli/v2/internal/diff/renderer" dt "github.com/crossplane/cli/v2/internal/diff/result" "github.com/crossplane/cli/v2/internal/diff/testutils" + difftypes "github.com/crossplane/cli/v2/internal/diff/types" "github.com/crossplane/cli/v2/internal/plan" xprender "github.com/crossplane/cli/v2/pkg/render" ) @@ -1000,3 +1003,73 @@ func TestBuildFileInputsIgnoreProjectFile(t *testing.T) { }) } } + +func TestDiffComposites(t *testing.T) { + xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XThing"} + defs := &testutils.MockDefinitionClient{ + GetXRDForXRFn: func(_ context.Context, gvk schema.GroupVersionKind) (*un.Unstructured, error) { + if gvk == xrGVK { + return &un.Unstructured{}, nil + } + return nil, errors.New("no XRD found") + }, + GetXRDForClaimFn: func(context.Context, schema.GroupVersionKind) (*un.Unstructured, error) { + return nil, errors.New("no XRD found") + }, + } + res := func(apiVersion, kind string) *un.Unstructured { + return &un.Unstructured{Object: map[string]any{"apiVersion": apiVersion, "kind": kind, "metadata": map[string]any{"name": "n"}}} + } + + cases := map[string]struct { + reason string + xrs []*un.Unstructured + diffErr error + wantDiffs bool + wantErr string + wantOut string + }{ + "RejectedBeforeDiff": { + reason: "An input no XRD defines should be rejected, unwrapped, before PerformDiff runs.", + xrs: []*un.Unstructured{res("example.org/v1", "XThing"), res("v1", "ConfigMap")}, + wantErr: `-f inputs include ConfigMap "n" (v1), which no XRD`, + }, + "DiffErrorWrapsAndRenders": { + reason: "A PerformDiff error should be wrapped, and the report rendered anyway.", + xrs: []*un.Unstructured{res("example.org/v1", "XThing")}, + diffErr: errors.New("boom"), + wantDiffs: true, + wantErr: "cannot simulate composite resources: boom", + wantOut: "XThing", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + diffed := false + proc := &testutils.MockDiffProcessor{PerformDiffFn: func(context.Context, []*un.Unstructured, difftypes.CompositionProvider) (*dt.XRReport, error) { + diffed = true + ref := corev1.ObjectReference{APIVersion: "example.org/v1", Kind: "XThing", Name: "n"} + g := dt.XRDiffGroup{XR: ref} + if tc.diffErr != nil { + g.Err = &dt.OutputError{Message: tc.diffErr.Error()} + } + return &dt.XRReport{Groups: []dt.XRDiffGroup{g}}, tc.diffErr + }} + c := &Flags{Output: "plan", NoColor: true} + stdout := &bytes.Buffer{} + k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: &bytes.Buffer{}}} + xp := &xpclient.Clients{Definition: defs, Composition: &testutils.MockCompositionClient{}} + + _, err := c.diffComposites(k, logging.NewNopLogger(), proc, xp, tc.xrs)(context.Background()) + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Errorf("\n%s\ndiffComposites(...): want error containing %q, got %v", tc.reason, tc.wantErr, err) + } + if diffed != tc.wantDiffs { + t.Errorf("\n%s\ndiffComposites(...): PerformDiff called = %t, want %t", tc.reason, diffed, tc.wantDiffs) + } + if !strings.Contains(stdout.String(), tc.wantOut) { + t.Errorf("\n%s\ndiffComposites(...): want output containing %q, got:\n%s", tc.reason, tc.wantOut, stdout.String()) + } + }) + } +} From f0a099c18b08c6e081bb533c0e92826a818daf03 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Mon, 2 Nov 2026 12:17:31 -0500 Subject: [PATCH 16/23] test(simulate): fold the project-file image config test into the build test Signed-off-by: Jonathan Ogilvie Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/simulate/run_test.go | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index fd9be3d1..e3df3665 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -961,27 +961,10 @@ func parseFlags(t *testing.T, args ...string) (*Flags, error) { return &cli.Simulate, err } -func TestFlagImageConfigsIgnoresProjectFile(t *testing.T) { - // A Composition run inside a project directory must not read its - // project file: an unparseable one would otherwise fail the run. - 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) - } -} - func TestBuildFileInputsIgnoreProjectFile(t *testing.T) { // Composition and composite resource runs inside a project directory - // must not read its project file. + // 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) From 106327e3ece41e047785dd411c339dcb5c86594f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 16:10:36 -0500 Subject: [PATCH 17/23] refactor(xr): validate -f inputs in xr simulate and take a comma-separated list xr simulate owns which inputs it accepts, and -f matches resource simulate. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/composition/simulate_test.go | 2 +- cmd/crossplane/xr/help/simulate.md | 2 +- cmd/crossplane/xr/simulate.go | 21 +++++++++++++++++++-- cmd/crossplane/xr/simulate_test.go | 4 ++-- 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/cmd/crossplane/composition/simulate_test.go b/cmd/crossplane/composition/simulate_test.go index 89e6d51a..08732e81 100644 --- a/cmd/crossplane/composition/simulate_test.go +++ b/cmd/crossplane/composition/simulate_test.go @@ -117,7 +117,7 @@ func TestSimulateRejectsNonComposition(t *testing.T) { } // 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; xr simulate takes composite resource or claim files` + want := `-f inputs include XNetwork "net" (example.org/v1); composition simulate takes Composition files` if err == nil || err.Error() != want { t.Errorf("Run(...): want error %q, got %v", want, err) } diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md index f4862316..ef12fe20 100644 --- a/cmd/crossplane/xr/help/simulate.md +++ b/cmd/crossplane/xr/help/simulate.md @@ -4,7 +4,7 @@ cluster selects for it and the functions installed in the cluster, and shows the difference from what's in the cluster. It doesn't change the cluster. `-f` (`--input`) names a file, or a directory of YAML files, of composite -resources or claims to simulate, and is required. Repeat it for more; `-f -` +resources or claims to simulate, and is required. Give a comma-separated list, or repeat it, for more; `-f -` reads standard input. Every resource in them must be a kind an XRD in the cluster defines as a composite resource or claim; to simulate a Composition, use `crossplane composition simulate`, to simulate a project, use diff --git a/cmd/crossplane/xr/simulate.go b/cmd/crossplane/xr/simulate.go index 15c0ea8d..1ffc7886 100644 --- a/cmd/crossplane/xr/simulate.go +++ b/cmd/crossplane/xr/simulate.go @@ -20,9 +20,13 @@ import ( "context" "github.com/alecthomas/kong" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/project/projectfile" "github.com/crossplane/cli/v2/internal/simulate" "github.com/crossplane/cli/v2/internal/terminal" @@ -41,7 +45,7 @@ var ( type simulateCmd struct { simulate.Flags `embed:""` - Input []string `help:"Composite resource or claim 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:"Composite resource or claim file or directory to simulate. Takes a comma-separated list of paths, and can be repeated. - reads standard input." placeholder:"PATH" required:"" short:"f"` } func (c *simulateCmd) Help() string { @@ -51,7 +55,20 @@ func (c *simulateCmd) Help() string { // Run previews the composite resources or claims -f names against the // cluster, with the cluster's Compositions and functions. func (c *simulateCmd) Run(sigCtx context.Context, k *kong.Context, logger logging.Logger, sp terminal.SpinnerPrinter) error { - xrs, err := simulate.LoadInputs(c.Input, simulate.ClassComposite) + // Anything but a Composition or project file is a candidate composite + // resource or claim; checkComposites confirms it against the cluster's XRDs. + xrs, err := simulate.LoadInputs(c.Input, func(r *un.Unstructured) error { + gvk := r.GroupVersionKind() + switch { + case gvk == apiextensionsv1.CompositionGroupVersionKind: + return simulate.RejectInput(r, simulate.XRTakes+"; composition simulate takes Composition files") + case gvk.GroupVersion().String() == projectfile.APIVersion && gvk.Kind == projectfile.Kind: + return simulate.RejectInput(r, simulate.XRTakes+"; project simulate takes a project file") + case r.IsList() || gvk.Kind == "List": + return simulate.RejectInput(r, simulate.XRTakes) + } + return nil + }) if err != nil { return err } diff --git a/cmd/crossplane/xr/simulate_test.go b/cmd/crossplane/xr/simulate_test.go index 254b576d..3cad11bd 100644 --- a/cmd/crossplane/xr/simulate_test.go +++ b/cmd/crossplane/xr/simulate_test.go @@ -60,9 +60,9 @@ func TestSimulateFlags(t *testing.T) { input: []string{"xr.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 0209b02580b83db6086245ab8ac518e7e02a973f Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 18:18:58 -0500 Subject: [PATCH 18/23] 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 | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/simulate/run.go b/internal/simulate/run.go index a70b2766..41fb46f7 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -635,11 +635,14 @@ func (c *Flags) build(ctx context.Context, in *Inputs) (*Built, error) { if err != nil { return nil, err } - client, err := clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)) - if err != nil { - return nil, errors.Wrap(err, "cannot create xpkg client") + pkgs := &Packages{ICs: ics} + // Only planning fetches packages, so --skip-plan needs no cache. + if !c.SkipPlan { + if pkgs.Client, err = clixpkg.NewClient(clixpkg.WithCacheDir(afero.NewOsFs(), cmp.Or(c.CacheDir, dependency.DefaultCacheDir())), clixpkg.WithImageConfigs(ics)); err != nil { + return nil, errors.Wrap(err, "cannot create xpkg client") + } } - return &Built{Compositions: in.Compositions, Packages: &Packages{ICs: ics, Client: client}}, nil + return &Built{Compositions: in.Compositions, Packages: pkgs}, nil } // flagImageConfigs returns the ImageConfigs from --image-configs, or none. From 840facbe7691ca3ecc8024055a7170b456db5a3c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Fri, 6 Nov 2026 20:47:03 -0500 Subject: [PATCH 19/23] docs(xr): lead xr simulate's help with the permissions it needs Match composition and project simulate: the create and patch permissions come in an Important callout straight after the summary, with the reason. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/xr/help/simulate.md | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md index ef12fe20..8fc22c87 100644 --- a/cmd/crossplane/xr/help/simulate.md +++ b/cmd/crossplane/xr/help/simulate.md @@ -3,22 +3,24 @@ claims would change a cluster. It renders each one with the Composition the cluster selects for it and the functions installed in the cluster, and shows the difference from what's in the cluster. It doesn't change the cluster. -`-f` (`--input`) names a file, or a directory of YAML files, of composite -resources or claims to simulate, and is required. Give a comma-separated list, or repeat it, for more; `-f -` -reads standard input. Every resource in them must be a kind an XRD in the -cluster defines as a composite resource or claim; to simulate a Composition, use -`crossplane composition simulate`, to simulate a project, use -`crossplane project simulate`, and to simulate managed resources directly, use -`crossplane resource simulate`. A claim is simulated as its composite resource, -and its result is listed under the claim's name. -Functions come from the cluster. `--cache-dir` sets where provider package -contents are cached. +> **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. -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. +`-f` (`--input`) names a file, or a directory of YAML files, of composite +resources or claims to simulate, and is required. Give a comma-separated list, +or repeat it, for more; `-f -` reads standard input. Every resource in them +must be a kind an XRD in the cluster defines as a composite resource or claim; +to simulate a Composition, use `crossplane composition simulate`, to simulate a +project, use `crossplane project simulate`, and to simulate managed resources +directly, use `crossplane resource simulate`. A claim is simulated as its +composite resource, and its result is listed under the claim's name. +Functions come from the cluster. `--cache-dir` sets where provider package +contents are cached. From 80be2bdb63220d9cd4a4a1d797a9169afbc974d6 Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sat, 7 Nov 2026 00:10:20 -0500 Subject: [PATCH 20/23] fix(simulate): match each assertion expectation to a distinct result Errors, resource plans, validation failures and field errors were matched first-wins, so two expectations could be satisfied by one result while the count check still passed. Track used results as the warnings matcher does, and match per-XR errorIDs by ref so same-named XRs in different namespaces do not fail spuriously. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 71 ++++++++++++++----- .../simtest/structured_assertions_test.go | 16 +++-- 2 files changed, 64 insertions(+), 23 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index d1ec8b39..f4dc3247 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -71,8 +71,10 @@ func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { t.Errorf("Expected %d changes, got %d: %v", len(expected.resources), len(changes), planIDs(changes)) } + usedErrs := make([]bool, len(res.Errors)) + for _, want := range expected.errors { - got := findMatchingError(res.Errors, want.resourceID) + got := findMatchingError(res.Errors, usedErrs, want.resourceID, nil) if got == nil { t.Errorf("Expected error for resourceID %q not found in output. Actual error IDs: %v", want.resourceID, errorIDs(res.Errors)) @@ -93,7 +95,7 @@ func AssertStructured(t *testing.T, jsonOutput string, e Expectation) { assertXRExpectations(t, res, expected.xrs) for _, id := range expected.errorIDs { - if findMatchingError(res.Errors, id) == nil { + if findMatchingError(res.Errors, usedErrs, id, nil) == nil { t.Errorf("Expected top-level error with resourceID %q not found. Actual error IDs: %v", id, errorIDs(res.Errors)) } } @@ -269,8 +271,10 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp } } + usedErrs := make([]bool, len(errs)) + for _, id := range want.errorIDs { - if findMatchingError(errs, id) == nil { + if findMatchingError(errs, usedErrs, id, nil) == nil { t.Errorf("%s: expected error with resourceID %q not found. Actual error IDs: %v", label, id, errorIDs(errs)) } } @@ -282,8 +286,10 @@ func assertXRImpact(t *testing.T, res simulate.ProjectResult, label string, comp assertSummary(t, label+": downstream summary", s, create, update, destroy, replace) } + usedPlans := make([]bool, len(downstream)) + for _, wantRes := range want.downstreamResources { - p := findMatchingPlan(downstream, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace, wantRes.namePattern) + p := findMatchingPlan(downstream, usedPlans, wantRes.changeType, wantRes.kind, wantRes.name, wantRes.namespace, wantRes.namePattern) if p == nil { t.Errorf("%s: expected downstream %s resource %s/%s not found. Actual: %v", label, wantRes.changeType, wantRes.kind, wantRes.name, planIDs(downstream)) @@ -325,6 +331,7 @@ func assertXRExpectations(t *testing.T, res simulate.ProjectResult, expected []* } used := make([]bool, len(res.Composites)) + usedErrs := make([]bool, len(res.Errors)) for _, want := range expected { i := -1 @@ -366,8 +373,7 @@ func assertXRExpectations(t *testing.T, res simulate.ProjectResult, expected []* } for _, id := range want.errorIDs { - e := findMatchingError(res.Errors, id) - if e == nil || e.Ref == nil || *e.Ref != got.Ref { + if findMatchingError(res.Errors, usedErrs, id, &got.Ref) == nil { t.Errorf("xr %s: expected error with resourceID %q not found against it. Actual error IDs: %v", label, id, errorIDs(res.Errors)) } } @@ -384,8 +390,10 @@ func assertResourceExpectations(t *testing.T, scope string, changes []*plan.Reso prefix = "xr " + scope + ": " } + used := make([]bool, len(changes)) + for _, want := range resources { - p := findMatchingPlan(changes, want.changeType, want.kind, want.name, want.namespace, want.namePattern) + p := findMatchingPlan(changes, used, want.changeType, want.kind, want.name, want.namespace, want.namePattern) if p == nil { t.Errorf("%sExpected %s resource %s/%s (ns=%s) not found. Actual resources: %v", prefix, want.changeType, want.kind, want.name, want.namespace, planIDs(changes)) @@ -589,8 +597,12 @@ func assertErrorMatch(t *testing.T, got simulate.Error, want *ErrorExpectation) t.Errorf("error %q: message %q does not contain %q", want.resourceID, got.Message, want.messageContains) } + usedVFs := make([]bool, len(got.ValidationFailures)) + for _, wantVF := range want.validationFailures { - i := slices.IndexFunc(got.ValidationFailures, func(v simulate.ValidationFailure) bool { + i := firstUnused(usedVFs, func(i int) bool { + v := got.ValidationFailures[i] + return v.APIVersion == wantVF.apiVersion && v.Kind == wantVF.kind && v.Name == wantVF.name && (wantVF.namespace == "" || v.Namespace == wantVF.namespace) }) @@ -606,6 +618,8 @@ func assertErrorMatch(t *testing.T, got simulate.Error, want *ErrorExpectation) continue } + usedVFs[i] = true + assertValidationFailureMatch(t, got.ValidationFailures[i], wantVF) } @@ -624,9 +638,11 @@ func assertValidationFailureMatch(t *testing.T, got simulate.ValidationFailure, t.Errorf("%s: status: expected %q, got %q", label, want.status, got.Status) } + usedFEs := make([]bool, len(got.Errors)) + for _, wantFE := range want.errors { - i := slices.IndexFunc(got.Errors, func(fe simulate.FieldValidationError) bool { - return fe.Type == wantFE.errType && fe.Field == wantFE.field + i := firstUnused(usedFEs, func(i int) bool { + return got.Errors[i].Type == wantFE.errType && got.Errors[i].Field == wantFE.field }) if i < 0 { actual := make([]string, 0, len(got.Errors)) @@ -639,6 +655,7 @@ func assertValidationFailureMatch(t *testing.T, got simulate.ValidationFailure, continue } + usedFEs[i] = true gotFE := got.Errors[i] if wantFE.messageContains != "" && !strings.Contains(gotFE.Message, wantFE.messageContains) { @@ -657,6 +674,17 @@ func assertValidationFailureMatch(t *testing.T, got simulate.ValidationFailure, } } +// firstUnused returns the index of the first unused element for which match is true, or -1. +func firstUnused(used []bool, match func(i int) bool) int { + for i := range used { + if !used[i] && match(i) { + return i + } + } + + return -1 +} + func assertSummary(t *testing.T, label string, want *expectedSummary, create, update, destroy, replace int) { t.Helper() @@ -718,16 +746,18 @@ func findPlan(plans []*plan.ResourcePlan, ref plan.ResourceRef, composite *plan. return nil } -// findMatchingPlan returns the first plan with changeType's action, kind and (when given) namespace, +// findMatchingPlan returns the first unused plan with changeType's action, kind and (when given) namespace, // whose name is any, matches namePattern, or equals name. -func findMatchingPlan(plans []*plan.ResourcePlan, changeType, kind, name, namespace string, namePattern *regexp.Regexp) *plan.ResourcePlan { - for _, p := range plans { - if p.Action != changeAction(changeType) { +func findMatchingPlan(plans []*plan.ResourcePlan, used []bool, changeType, kind, name, namespace string, namePattern *regexp.Regexp) *plan.ResourcePlan { + for i, p := range plans { + if used[i] || p.Action != changeAction(changeType) { continue } ref := plan.ResourceRef{Kind: p.Kind, Name: p.Name, Namespace: p.Namespace} if refMatches(ref, kind, name, namespace, namePattern, false) { + used[i] = true + return p } } @@ -750,10 +780,17 @@ func refMatches(ref plan.ResourceRef, kind, name, namespace string, namePattern } } -// findMatchingError returns the error whose resource is resourceID ("Kind/name"). -func findMatchingError(errs []simulate.Error, resourceID string) *simulate.Error { +// findMatchingError returns the first unused error whose resource is resourceID ("Kind/name") and, when +// ref is set, whose Ref equals it, and marks it used so one error satisfies one expectation. +func findMatchingError(errs []simulate.Error, used []bool, resourceID string, ref *plan.ResourceRef) *simulate.Error { for i := range errs { - if errorID(errs[i]) == resourceID { + if used[i] || errorID(errs[i]) != resourceID { + continue + } + + if ref == nil || (errs[i].Ref != nil && *errs[i].Ref == *ref) { + used[i] = true + return &errs[i] } } diff --git a/internal/simulate/simtest/structured_assertions_test.go b/internal/simulate/simtest/structured_assertions_test.go index 66950dde..e1720cf4 100644 --- a/internal/simulate/simtest/structured_assertions_test.go +++ b/internal/simulate/simtest/structured_assertions_test.go @@ -448,12 +448,16 @@ func TestAssertStructuredCombined(t *testing.T) { want Expectation wantFail bool }{ - "Combined": {want: combined()}, - "WrongSummary": {want: combined().WithSummary(2, 1, 1), wantFail: true}, - "WrongRevision": {want: combined().WithComposition("comp-a").WithRevisionImpact("metadata", true, 1), wantFail: true}, - "WrongXRImpact": {want: combined().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "unchanged"), wantFail: true}, - "MissingErrorID": {want: combined().WithErrorID("Composition/comp-a"), wantFail: true}, - "AbsentFieldFails": {want: Expect().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldAbsent("spec.region"), wantFail: true}, + "Combined": {want: combined()}, + "WrongSummary": {want: combined().WithSummary(2, 1, 1), wantFail: true}, + "WrongRevision": {want: combined().WithComposition("comp-a").WithRevisionImpact("metadata", true, 1), wantFail: true}, + "WrongXRImpact": {want: combined().WithComposition("comp-a").WithXRImpact("XNop", "xr-1", "default", "unchanged"), wantFail: true}, + "MissingErrorID": {want: combined().WithErrorID("Composition/comp-a"), wantFail: true}, + "AbsentFieldFails": { + want: Expect().WithModifiedResource("Bucket", "bucket-a", "default").WithFieldAbsent("spec.region").And(). + WithAddedResource("ConfigMap", "cm-x7k2p", "default").And().WithRemovedResource("Secret", "gone", "default").And(), + wantFail: true, + }, } for name, tc := range cases { From a7b1611f50bfdc2c3f67fcfa94ed810691e8248c Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sat, 7 Nov 2026 00:10:42 -0500 Subject: [PATCH 21/23] fix(simulate): check Before for deleted fields; verify storage version WithFieldAbsent on a delete read only leaf changes, so an empty map or list in Before passed. WithDryRunStorageVersion was silently unchecked without WithDryRunSkipped. WithXRs matches regardless of order, so say so. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/simulate/simtest/plan_assertions.go | 30 +++++++++++++++++-- .../simulate/simtest/structured_assertions.go | 2 +- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/internal/simulate/simtest/plan_assertions.go b/internal/simulate/simtest/plan_assertions.go index f4dc3247..a3c2615e 100644 --- a/internal/simulate/simtest/plan_assertions.go +++ b/internal/simulate/simtest/plan_assertions.go @@ -463,6 +463,8 @@ func assertFieldValue(t *testing.T, id string, p *plan.ResourcePlan, planned boo if planned { assertAbsentInAfter(t, id, p, path) + } else { + assertAbsentInBefore(t, id, p, path) } return @@ -492,6 +494,20 @@ func assertAbsentInAfter(t *testing.T, id string, p *plan.ResourcePlan, path str } } +// assertAbsentInBefore requires path to be absent from the plan's Before object, not only from its leaf +// changes, when Before is set: an empty map or list leaves no leaf change. +func assertAbsentInBefore(t *testing.T, id string, p *plan.ResourcePlan, path string) { + t.Helper() + + if p.Before == nil { + return + } + + if got, ok := objectValueAt(t, p.Before, path); ok { + t.Errorf("%s: field %s: expected absent, but before carries %v", id, path, got) + } +} + // assertDryRun checks a plan's dry-run verification: either that its desired state was reported as NOT // verified against the apiserver (for a reason, optionally with the cluster's own explanation), or that // no such report is present at all. @@ -503,6 +519,11 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx return } + if want.mustBePerformed && want.storageVersion != "" { + t.Errorf("%s: WithDryRunPerformed and WithDryRunStorageVersion(%q) contradict each other; a performed dry-run reports no storage version", id, want.storageVersion) + return + } + if want.mustBePerformed { if got != nil { t.Errorf("%s: expected the desired state to have been dry-run against the apiserver, but it reports skipReason %q (detail: %q)", @@ -512,7 +533,12 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx return } - if want.skipReason == "" { + if want.skipReason == "" && want.storageVersion == "" { + return + } + + if got == nil && want.skipReason == "" { + t.Errorf("%s: expected dryRun.storageVersion %q, but no dryRun was reported", id, want.storageVersion) return } @@ -523,7 +549,7 @@ func assertDryRun(t *testing.T, id string, got *plan.Verification, want dryRunEx return } - if got.SkipReason != want.skipReason { + if want.skipReason != "" && got.SkipReason != want.skipReason { t.Errorf("%s: dryRun.skipReason: expected %q, got %q", id, want.skipReason, got.SkipReason) } diff --git a/internal/simulate/simtest/structured_assertions.go b/internal/simulate/simtest/structured_assertions.go index 48dbf75b..c4999091 100644 --- a/internal/simulate/simtest/structured_assertions.go +++ b/internal/simulate/simtest/structured_assertions.go @@ -458,7 +458,7 @@ func (x *XRExpectation) xrExpectation() *XRExpectation { return x } func (r *ResourceExpectation) xrExpectation() *XRExpectation { return r.xrParent } // WithXRs attaches per-input-XR expectations (built via XR(...)) to assert -// against the xrs[] grouped view, in order. +// against the xrs[] grouped view, matched regardless of order. func (e *Expected) WithXRs(xrs ...xrSpec) *Expected { for _, x := range xrs { e.xrs = append(e.xrs, x.xrExpectation()) From 008d87f619dadeabdcbdeba3c8c3025b2be77bdd Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sat, 7 Nov 2026 00:11:18 -0500 Subject: [PATCH 22/23] fix(diff): name the namespace in XR section headers and count only XRs in the footer Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- internal/diff/renderer/diff_renderer.go | 13 +++++++++++-- internal/diff/renderer/diff_renderer_test.go | 20 +++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/internal/diff/renderer/diff_renderer.go b/internal/diff/renderer/diff_renderer.go index e7d7e70c..b6c86bee 100644 --- a/internal/diff/renderer/diff_renderer.go +++ b/internal/diff/renderer/diff_renderer.go @@ -297,6 +297,7 @@ func (r *DefaultDiffRenderer) renderGrouped(groups []dt.XRDiffGroup) error { var ( total diffCounts + xrCount int unchangedXR int errorXR int ) @@ -316,7 +317,15 @@ func (r *DefaultDiffRenderer) renderGrouped(groups []dt.XRDiffGroup) error { continue } - if _, err := fmt.Fprintf(stdout, "=== %s/%s ===\n\n", g.XR.Kind, g.XR.Name); err != nil { + xrCount++ + + // The namespace is part of the identity: same-named XRs in different namespaces are distinct. + title := g.XR.Kind + "/" + g.XR.Name + if g.XR.Namespace != "" { + title = g.XR.Kind + "/" + g.XR.Namespace + "/" + g.XR.Name + } + + if _, err := fmt.Fprintf(stdout, "=== %s ===\n\n", title); err != nil { return errors.Wrap(err, "failed to write XR section header") } @@ -354,7 +363,7 @@ func (r *DefaultDiffRenderer) renderGrouped(groups []dt.XRDiffGroup) error { } } - return r.renderAggregateFooter(len(groups), total, unchangedXR, errorXR) + return r.renderAggregateFooter(xrCount, total, unchangedXR, errorXR) } // renderAggregateFooter writes the cross-XR totals line shown when more than one diff --git a/internal/diff/renderer/diff_renderer_test.go b/internal/diff/renderer/diff_renderer_test.go index 454f92f4..46630026 100644 --- a/internal/diff/renderer/diff_renderer_test.go +++ b/internal/diff/renderer/diff_renderer_test.go @@ -278,6 +278,24 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { "Total: 1 modified across 2 XRs (1 unchanged)", }, }, + // Same kind and name in different namespaces: the header names the namespace. + "NamespacedXRHeaders": { + groups: []dt.XRDiffGroup{ + { + XR: corev1.ObjectReference{Kind: "XNopResource", Name: "same", Namespace: "ns-a"}, + Diffs: map[string]*dt.ResourceDiff{}, + }, + { + XR: corev1.ObjectReference{Kind: "XNopResource", Name: "same", Namespace: "ns-b"}, + Diffs: map[string]*dt.ResourceDiff{}, + }, + }, + options: noColor, + expectedOutputs: []string{ + "=== XNopResource/ns-a/same ===", + "=== XNopResource/ns-b/same ===", + }, + }, // A mixed batch (identity-less group alongside identity-bearing ones): // the identity-less group's diffs fold into the aggregate with no header. // Two identity-bearing groups ensure the footer fires so the fold-in is @@ -299,7 +317,7 @@ func TestDefaultDiffRenderer_RenderDiffs(t *testing.T) { "=== XNopResource/real-xr ===", "=== XNopResource/real-xr-2 ===", "~~~ Topic/loose-topic", // identity-less diff still rendered - "Total: 3 modified across 3 XRs", // all three counted + "Total: 3 modified across 2 XRs", // the identity-less group is not an XR }, notExpected: []string{ "=== /", "=== (unknown)", // no header for the identity-less group From fd3f78095fc46dfc9ac6c38d637d1bf4374948de Mon Sep 17 00:00:00 2001 From: Jonathan Ogilvie Date: Sat, 7 Nov 2026 00:12:24 -0500 Subject: [PATCH 23/23] fix(simulate): default input namespaces to the kubeconfig context's A claim or namespaced composite resource with no metadata.namespace now takes the context namespace, as kubectl apply would. Cluster-scoped composite resources are left alone. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Jonathan Ogilvie --- cmd/crossplane/xr/help/simulate.md | 4 ++- internal/simulate/inputs.go | 20 +++++++++-- internal/simulate/inputs_test.go | 58 +++++++++++++++++++++++++++++- internal/simulate/run.go | 6 ++-- internal/simulate/run_test.go | 2 +- 5 files changed, 81 insertions(+), 9 deletions(-) diff --git a/cmd/crossplane/xr/help/simulate.md b/cmd/crossplane/xr/help/simulate.md index 8fc22c87..95e9efe1 100644 --- a/cmd/crossplane/xr/help/simulate.md +++ b/cmd/crossplane/xr/help/simulate.md @@ -20,7 +20,9 @@ must be a kind an XRD in the cluster defines as a composite resource or claim; to simulate a Composition, use `crossplane composition simulate`, to simulate a project, use `crossplane project simulate`, and to simulate managed resources directly, use `crossplane resource simulate`. A claim is simulated as its -composite resource, and its result is listed under the claim's name. +composite resource, and its result is listed under the claim's name. A claim or +namespaced composite resource with no `metadata.namespace` is simulated in the +current context's namespace, as `kubectl apply` would. Functions come from the cluster. `--cache-dir` sets where provider package contents are cached. diff --git a/internal/simulate/inputs.go b/internal/simulate/inputs.go index 4dfb7884..973c792e 100644 --- a/internal/simulate/inputs.go +++ b/internal/simulate/inputs.go @@ -90,18 +90,32 @@ func RejectDuplicateNames(comps []*un.Unstructured) error { // checkComposites rejects resources that no XRD in the cluster defines as a // composite resource or claim, such as package metadata, a Provider or a // ConfigMap, which the engine would otherwise fail on with no hint at what -f -// accepts. The definition client caches the XRDs when it initializes, so the -// check costs no extra calls. -func checkComposites(ctx context.Context, defs xpclient.DefinitionClient, resources []*un.Unstructured) error { +// accepts. It also gives a claim or namespaced composite resource with no +// namespace the kubeconfig context namespace ns, as kubectl apply would. The +// definition client caches the XRDs when it initializes, so the check costs +// no extra calls. +func checkComposites(ctx context.Context, defs xpclient.DefinitionClient, ns string, resources []*un.Unstructured) error { for _, r := range resources { gvk := r.GroupVersionKind() if xrd, err := defs.GetXRDForClaim(ctx, gvk); err == nil && xrd != nil { + defaultNamespace(r, ns) continue } if xrd, err := defs.GetXRDForXR(ctx, gvk); err == nil && xrd != nil { + // Only a Namespaced scope is namespaced; Cluster and LegacyCluster are not. + if scope, _, _ := un.NestedString(xrd.Object, "spec", "scope"); scope == "Namespaced" { + defaultNamespace(r, ns) + } continue } return rejectInput(r, "which no XRD in the cluster defines as a composite resource or claim (if it is one, check the kubeconfig context, its apiVersion and that its XRD is installed)", XRTakes) } return nil } + +// defaultNamespace sets r's namespace to ns if it has none. +func defaultNamespace(r *un.Unstructured, ns string) { + if r.GetNamespace() == "" { + r.SetNamespace(ns) + } +} diff --git a/internal/simulate/inputs_test.go b/internal/simulate/inputs_test.go index f26c1a51..0d08a0ce 100644 --- a/internal/simulate/inputs_test.go +++ b/internal/simulate/inputs_test.go @@ -266,7 +266,7 @@ func TestCheckComposites(t *testing.T) { } for name, tc := range cases { t.Run(name, func(t *testing.T) { - err := checkComposites(context.Background(), defs, tc.resources) + err := checkComposites(context.Background(), defs, "ctx-ns", tc.resources) if tc.err == "" { if err != nil { t.Fatalf("\n%s\ncheckComposites(...): %v", tc.reason, err) @@ -279,3 +279,59 @@ func TestCheckComposites(t *testing.T) { }) } } + +func TestCheckCompositesNamespace(t *testing.T) { + xrdWithScope := func(scope string) *un.Unstructured { + return &un.Unstructured{Object: map[string]any{"spec": map[string]any{"scope": scope}}} + } + gvk := func(kind string) schema.GroupVersionKind { + return schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: kind} + } + notFound := func(_ context.Context, _ schema.GroupVersionKind) (*un.Unstructured, error) { + return nil, errors.New("no XRD found") + } + defs := &testutils.MockDefinitionClient{ + GetXRDForXRFn: func(ctx context.Context, g schema.GroupVersionKind) (*un.Unstructured, error) { + switch g { + case gvk("XNamespaced"): + return xrdWithScope("Namespaced"), nil + case gvk("XCluster"): + return xrdWithScope("Cluster"), nil + case gvk("XLegacy"): + return xrdWithScope("LegacyCluster"), nil + } + return notFound(ctx, g) + }, + GetXRDForClaimFn: func(ctx context.Context, g schema.GroupVersionKind) (*un.Unstructured, error) { + if g == gvk("Claim") { + return xrdWithScope("LegacyCluster"), nil + } + return notFound(ctx, g) + }, + } + + cases := map[string]struct { + kind, namespace, want string + }{ + "NamespacedXRWithoutNamespace": {kind: "XNamespaced", want: "ctx-ns"}, + "NamespacedXRWithNamespace": {kind: "XNamespaced", namespace: "mine", want: "mine"}, + "ClusterXR": {kind: "XCluster", want: ""}, + "LegacyClusterXR": {kind: "XLegacy", want: ""}, + "ClaimWithoutNamespace": {kind: "Claim", want: "ctx-ns"}, + "ClaimWithNamespace": {kind: "Claim", namespace: "mine", want: "mine"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + r := &un.Unstructured{Object: map[string]any{"apiVersion": "example.org/v1", "kind": tc.kind, "metadata": map[string]any{"name": "r"}}} + if tc.namespace != "" { + r.SetNamespace(tc.namespace) + } + if err := checkComposites(context.Background(), defs, "ctx-ns", []*un.Unstructured{r}); err != nil { + t.Fatalf("checkComposites(...): %v", err) + } + if got := r.GetNamespace(); got != tc.want { + t.Errorf("checkComposites(...): namespace = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/simulate/run.go b/internal/simulate/run.go index 41fb46f7..df489dad 100644 --- a/internal/simulate/run.go +++ b/internal/simulate/run.go @@ -234,9 +234,9 @@ func (c *Flags) diffCompositions(k *kong.Context, logger logging.Logger, proc dp // crossplane-diff's xr command. A claim is simulated as its XR, but the // report names the input, so its result is listed under the claim's name // (crossplane/cli#367). -func (c *Flags) diffComposites(k *kong.Context, logger logging.Logger, proc dp.DiffProcessor, xp *xpclient.Clients, xrs []*un.Unstructured) func(context.Context) (bool, error) { +func (c *Flags) diffComposites(k *kong.Context, logger logging.Logger, proc dp.DiffProcessor, xp *xpclient.Clients, ns string, xrs []*un.Unstructured) func(context.Context) (bool, error) { return func(ctx context.Context) (bool, error) { - if err := checkComposites(ctx, xp.Definition, xrs); err != nil { + if err := checkComposites(ctx, xp.Definition, ns, xrs); err != nil { return false, err } report, err := proc.PerformDiff(ctx, xrs, xp.Composition.FindMatchingComposition) @@ -619,7 +619,7 @@ func (c *Flags) Run(sigCtx context.Context, k *kong.Context, logger logging.Logg } xrProc := dp.NewDiffProcessor(clients.K8sClients, clients.XpClients, opts...) if in.Composites != nil { - return c.simulate(sigCtx, logger, clients.Initialize, xrProc, c.diffComposites(k, logger, xrProc, &clients.XpClients, in.Composites)) + return c.simulate(sigCtx, logger, clients.Initialize, xrProc, c.diffComposites(k, logger, xrProc, &clients.XpClients, ns, in.Composites)) } proc := dp.NewCompDiffProcessor(xrProc, clients.XpClients.Composition, opts...) return c.simulate(sigCtx, logger, clients.Initialize, proc, c.diffCompositions(k, logger, proc, comps, msg)) diff --git a/internal/simulate/run_test.go b/internal/simulate/run_test.go index e3df3665..a8c4de47 100644 --- a/internal/simulate/run_test.go +++ b/internal/simulate/run_test.go @@ -1043,7 +1043,7 @@ func TestDiffComposites(t *testing.T) { k := &kong.Context{Kong: &kong.Kong{Stdout: stdout, Stderr: &bytes.Buffer{}}} xp := &xpclient.Clients{Definition: defs, Composition: &testutils.MockCompositionClient{}} - _, err := c.diffComposites(k, logging.NewNopLogger(), proc, xp, tc.xrs)(context.Background()) + _, err := c.diffComposites(k, logging.NewNopLogger(), proc, xp, "", tc.xrs)(context.Background()) if err == nil || !strings.Contains(err.Error(), tc.wantErr) { t.Errorf("\n%s\ndiffComposites(...): want error containing %q, got %v", tc.reason, tc.wantErr, err) }