diff --git a/cmd/diff/client/crossplane/composition_client.go b/cmd/diff/client/crossplane/composition_client.go index c992049..43b7892 100644 --- a/cmd/diff/client/crossplane/composition_client.go +++ b/cmd/diff/client/crossplane/composition_client.go @@ -3,6 +3,7 @@ package crossplane import ( "context" "fmt" + "strings" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/core" "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/kubernetes" @@ -37,6 +38,7 @@ type CompositionClient interface { type DefaultCompositionClient struct { resourceClient kubernetes.ResourceClient definitionClient DefinitionClient + revisionClient CompositionRevisionClient logger logging.Logger // Cache of compositions @@ -49,6 +51,7 @@ func NewCompositionClient(resourceClient kubernetes.ResourceClient, definitionCl return &DefaultCompositionClient{ resourceClient: resourceClient, definitionClient: definitionClient, + revisionClient: NewCompositionRevisionClient(resourceClient, logger), logger: logger, compositions: make(map[string]*apiextensionsv1.Composition), } @@ -65,6 +68,11 @@ func (c *DefaultCompositionClient) Initialize(ctx context.Context) error { c.gvks = gvks + // Initialize revision client + if err := c.revisionClient.Initialize(ctx); err != nil { + return errors.Wrap(err, "cannot initialize composition revision client") + } + // List compositions to populate the cache comps, err := c.ListCompositions(ctx) if err != nil { @@ -155,6 +163,132 @@ func (c *DefaultCompositionClient) GetComposition(ctx context.Context, name stri return comp, nil } +// getCompositionRevisionRef reads the compositionRevisionRef from an XR/Claim spec. +// Returns the revision name and whether it was found. +func (c *DefaultCompositionClient) getCompositionRevisionRef(xrd, res *un.Unstructured) (string, bool) { + revisionRefName, found, _ := un.NestedString(res.Object, makeCrossplaneRefPath(xrd.GetAPIVersion(), "compositionRevisionRef", "name")...) + return revisionRefName, found && revisionRefName != "" +} + +// getCompositionUpdatePolicy reads the compositionUpdatePolicy from an XR/Claim. +// Returns the policy value and whether it was found. Defaults to "Automatic" if not found. +func (c *DefaultCompositionClient) getCompositionUpdatePolicy(xrd, res *un.Unstructured) string { + policy, found, _ := un.NestedString(res.Object, makeCrossplaneRefPath(xrd.GetAPIVersion(), "compositionUpdatePolicy")...) + if !found || policy == "" { + return "Automatic" // Default policy + } + + return policy +} + +// resolveCompositionFromRevisions determines which composition to use based on revision logic. +// Returns a composition or nil if standard resolution should be used. +func (c *DefaultCompositionClient) resolveCompositionFromRevisions( + ctx context.Context, + xrd, res *un.Unstructured, + compositionName string, + resourceID string, +) (*apiextensionsv1.Composition, error) { + // Check if there's a composition revision reference + revisionRefName, hasRevisionRef := c.getCompositionRevisionRef(xrd, res) + updatePolicy := c.getCompositionUpdatePolicy(xrd, res) + + c.logger.Debug("Checking revision resolution", + "resource", resourceID, + "hasRevisionRef", hasRevisionRef, + "revisionRef", revisionRefName, + "updatePolicy", updatePolicy) + + switch { + case updatePolicy == "Automatic": + // Case 1: Automatic policy - always use latest revision (if available) + latest, err := c.revisionClient.GetLatestRevisionForComposition(ctx, compositionName) + if err != nil { + // Check if this is a "no revisions found" case (new/unpublished composition) + if strings.Contains(err.Error(), "no composition revisions found") { + c.logger.Debug("No revisions found for composition (likely unpublished), falling back to composition directly", + "compositionName", compositionName, + "resource", resourceID) + + // Fall back to using composition directly for unpublished compositions + return nil, nil + } + + // For other errors, fail the diff to ensure accuracy + return nil, errors.Wrapf(err, + "cannot resolve latest composition revision for %s with Automatic update policy (composition: %s)", + resourceID, compositionName) + } + + comp := c.revisionClient.GetCompositionFromRevision(latest) + c.logger.Debug("Using latest revision for Automatic policy", + "resource", resourceID, + "revisionName", latest.GetName(), + "revisionNumber", latest.Spec.Revision) + + return comp, nil + + case updatePolicy == "Manual" && hasRevisionRef: + // Case 2: Manual policy with revision reference - use that specific revision + revision, err := c.revisionClient.GetCompositionRevision(ctx, revisionRefName) + if err != nil { + return nil, errors.Wrapf(err, + "cannot get pinned composition revision %s for %s (composition: %s, policy: Manual)", + revisionRefName, resourceID, compositionName) + } + + // Validate that revision belongs to the referenced composition + if labels := revision.GetLabels(); labels != nil { + if revCompName := labels[LabelCompositionName]; revCompName != "" && revCompName != compositionName { + return nil, errors.Errorf( + "composition revision %s belongs to composition %s, not %s (resource: %s)", + revisionRefName, revCompName, compositionName, resourceID) + } + } + + comp := c.revisionClient.GetCompositionFromRevision(revision) + c.logger.Debug("Using pinned revision for Manual policy", + "resource", resourceID, + "revisionName", revisionRefName, + "revisionNumber", revision.Spec.Revision) + + return comp, nil + + default: + // Case 3: Manual policy without revision reference in spec + // When creating a new XR with Manual policy and no compositionRevisionRef, + // Crossplane pins it to the latest revision at creation time. + // Use the latest revision to match this behavior. + c.logger.Debug("Manual policy without revision ref - using latest revision (will be pinned on creation)", + "resource", resourceID, + "compositionName", compositionName) + + latest, err := c.revisionClient.GetLatestRevisionForComposition(ctx, compositionName) + if err != nil { + // Check if this is a "no revisions found" case (new/unpublished composition) + if strings.Contains(err.Error(), "no composition revisions found") { + c.logger.Debug("No revisions found for composition (likely unpublished), falling back to composition directly", + "compositionName", compositionName, + "resource", resourceID) + + return nil, nil + } + + return nil, errors.Wrapf(err, + "cannot resolve latest composition revision for %s with Manual policy (composition: %s)", + resourceID, compositionName) + } + + comp := c.revisionClient.GetCompositionFromRevision(latest) + c.logger.Debug("Using latest revision for Manual policy", + "resource", resourceID, + "revisionName", latest.GetName(), + "revisionNumber", latest.Spec.Revision) + + return comp, nil + } +} + // FindMatchingComposition finds a composition matching the given resource. func (c *DefaultCompositionClient) FindMatchingComposition(ctx context.Context, res *un.Unstructured) (*apiextensionsv1.Composition, error) { gvk := res.GroupVersionKind() @@ -299,8 +433,23 @@ func (c *DefaultCompositionClient) findByDirectReference(ctx context.Context, xr "resource", resourceID, "compositionName", compositionRefName) - // Look up composition by name - comp, err := c.GetComposition(ctx, compositionRefName) + // Check if we should use a revision instead + comp, err := c.resolveCompositionFromRevisions(ctx, xrd, res, compositionRefName, resourceID) + if err != nil { + return nil, err + } + + if comp != nil { + // Validate that the composition's compositeTypeRef matches the target GVK + if !c.isCompositionCompatible(comp, targetGVK) { + return nil, errors.Errorf("composition from revision is not compatible with %s", targetGVK.String()) + } + + return comp, nil + } + + // No revision-based resolution, use composition directly + comp, err = c.GetComposition(ctx, compositionRefName) if err != nil { return nil, errors.Errorf("composition %s referenced in %s not found", compositionRefName, resourceID) diff --git a/cmd/diff/client/crossplane/composition_client_test.go b/cmd/diff/client/crossplane/composition_client_test.go index 8bf3706..c510ab4 100644 --- a/cmd/diff/client/crossplane/composition_client_test.go +++ b/cmd/diff/client/crossplane/composition_client_test.go @@ -509,6 +509,7 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { return []*un.Unstructured{}, nil }). + WithResourcesFoundByLabel([]*un.Unstructured{}, LabelCompositionName, "matching-comp"). Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). @@ -702,6 +703,7 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { return []*un.Unstructured{}, nil }). + WithResourcesFoundByLabel([]*un.Unstructured{}, LabelCompositionName, "matching-comp"). Build(), mockDef: *tu.NewMockDefinitionClient(). WithSuccessfulInitialize(). @@ -755,6 +757,7 @@ func TestDefaultCompositionClient_FindMatchingComposition(t *testing.T) { c := &DefaultCompositionClient{ resourceClient: &tt.mockResource, definitionClient: &tt.mockDef, + revisionClient: NewCompositionRevisionClient(&tt.mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: tt.fields.compositions, } @@ -861,6 +864,7 @@ func TestDefaultCompositionClient_GetComposition(t *testing.T) { t.Run(name, func(t *testing.T) { c := &DefaultCompositionClient{ resourceClient: mockResource, + revisionClient: NewCompositionRevisionClient(mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: tt.cache, } @@ -979,6 +983,7 @@ func TestDefaultCompositionClient_ListCompositions(t *testing.T) { t.Run(name, func(t *testing.T) { c := &DefaultCompositionClient{ resourceClient: tt.mockResource, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: make(map[string]*apiextensionsv1.Composition), } @@ -1068,6 +1073,7 @@ func TestDefaultCompositionClient_Initialize(t *testing.T) { t.Run(name, func(t *testing.T) { c := &DefaultCompositionClient{ resourceClient: tt.mockResource, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), logger: tu.TestLogger(t, false), compositions: make(map[string]*apiextensionsv1.Composition), } @@ -1082,3 +1088,384 @@ func TestDefaultCompositionClient_Initialize(t *testing.T) { }) } } + +func TestDefaultCompositionClient_ResolveCompositionFromRevisions(t *testing.T) { + ctx := t.Context() + + // Create test revisions + rev1 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + rev2 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev2", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 2, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Convert revisions to unstructured + toUnstructured := func(rev *apiextensionsv1.CompositionRevision) *un.Unstructured { + u := &un.Unstructured{} + obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(rev) + u.SetUnstructuredContent(obj) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: "CompositionRevision", + }) + + return u + } + + // Create test XRD + v1XRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xr1s.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]interface{}{ + "kind": "XR1", + }). + WithSpecField("versions", []interface{}{ + map[string]interface{}{ + "name": "v1", + "served": true, + "referenceable": true, + }, + }).Build() + + v2XRD := tu.NewResource(CrossplaneAPIExtGroupV1, CompositeResourceDefinitionKind, "xr1s.example.org"). + WithSpecField("group", "example.org"). + WithSpecField("names", map[string]interface{}{ + "kind": "XR1", + }). + WithSpecField("versions", []interface{}{ + map[string]interface{}{ + "name": "v2", + "served": true, + "referenceable": true, + }, + }).Build() + + tests := map[string]struct { + reason string + xrd *un.Unstructured + res *un.Unstructured + compositionName string + mockResource *tu.MockResourceClient + expectComp *apiextensionsv1.Composition + expectNil bool + expectError bool + errorPattern string + }{ + "AutomaticPolicyUsesLatestRevision": { + reason: "Should use latest revision when update policy is Automatic", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]interface{}{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Automatic"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectError: false, + }, + "ManualPolicyWithRevisionRefUsesSpecifiedRevision": { + reason: "Should use specified revision when update policy is Manual with revision ref", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]interface{}{ + "name": "test-comp", + }). + WithSpecField("compositionRevisionRef", map[string]interface{}{ + "name": "test-comp-rev1", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if name == "test-comp-rev1" { + return toUnstructured(rev1), nil + } + + return nil, errors.New("not found") + }). + Build(), + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectError: false, + }, + "ManualPolicyWithoutRevisionRefUsesLatestRevision": { + reason: "Should use latest revision when update policy is Manual without revision ref (net new XR case)", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]interface{}{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectError: false, + }, + "V2XRWithAutomaticPolicy": { + reason: "Should use latest revision for v2 XR with Automatic policy", + xrd: v2XRD, + res: tu.NewResource("example.org/v2", "XR1", "my-xr"). + WithSpecField("crossplane", map[string]interface{}{ + "compositionRef": map[string]interface{}{ + "name": "test-comp", + }, + "compositionUpdatePolicy": "Automatic", + }). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectError: false, + }, + "V2XRWithManualPolicyWithoutRevisionRef": { + reason: "Should use latest revision for v2 XR with Manual policy but no revision ref", + xrd: v2XRD, + res: tu.NewResource("example.org/v2", "XR1", "my-xr"). + WithSpecField("crossplane", map[string]interface{}{ + "compositionRef": map[string]interface{}{ + "name": "test-comp", + }, + "compositionUpdatePolicy": "Manual", + }). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), + }, LabelCompositionName, "test-comp"). + Build(), + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectError: false, + }, + "NoRevisionsFoundFallsBackToNil": { + reason: "Should return nil when no revisions exist (unpublished composition)", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]interface{}{ + "name": "test-comp", + }). + WithSpecField("compositionUpdatePolicy", "Automatic"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{}, LabelCompositionName, "test-comp"). + Build(), + expectNil: true, + expectError: false, + }, + "ManualPolicyWithNonexistentRevisionRef": { + reason: "Should return error when specified revision doesn't exist", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]interface{}{ + "name": "test-comp", + }). + WithSpecField("compositionRevisionRef", map[string]interface{}{ + "name": "nonexistent-rev", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, _ string) (*un.Unstructured, error) { + return nil, errors.New("not found") + }). + Build(), + expectError: true, + errorPattern: "cannot get pinned composition revision", + }, + "ManualPolicyWithRevisionFromDifferentComposition": { + reason: "Should return error when revision belongs to different composition", + xrd: v1XRD, + res: tu.NewResource("example.org/v1", "XR1", "my-xr"). + WithSpecField("compositionRef", map[string]interface{}{ + "name": "test-comp", + }). + WithSpecField("compositionRevisionRef", map[string]interface{}{ + "name": "other-comp-rev1", + }). + WithSpecField("compositionUpdatePolicy", "Manual"). + Build(), + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, _ schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if name == "other-comp-rev1" { + wrongRev := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "other-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + return toUnstructured(wrongRev), nil + } + + return nil, errors.New("not found") + }). + Build(), + expectError: true, + errorPattern: "belongs to composition other-comp, not test-comp", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionClient{ + resourceClient: tt.mockResource, + revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)), + logger: tu.TestLogger(t, false), + compositions: make(map[string]*apiextensionsv1.Composition), + } + + comp, err := c.resolveCompositionFromRevisions(ctx, tt.xrd, tt.res, tt.compositionName, "test-resource-id") + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): expected error but got none", tt.reason) + return + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): expected error containing %q, got %q", + tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): unexpected error: %v", tt.reason, err) + return + } + + if tt.expectNil { + if comp != nil { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): expected nil composition, got %v", tt.reason, comp) + } + + return + } + + if comp == nil { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): unexpected nil composition", tt.reason) + return + } + + if diff := cmp.Diff(tt.expectComp.GetName(), comp.GetName()); diff != "" { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): -want name, +got name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.expectComp.Spec.CompositeTypeRef, comp.Spec.CompositeTypeRef); diff != "" { + t.Errorf("\n%s\nresolveCompositionFromRevisions(...): -want type ref, +got type ref:\n%s", tt.reason, diff) + } + }) + } +} diff --git a/cmd/diff/client/crossplane/composition_revision_client.go b/cmd/diff/client/crossplane/composition_revision_client.go new file mode 100644 index 0000000..5471cfb --- /dev/null +++ b/cmd/diff/client/crossplane/composition_revision_client.go @@ -0,0 +1,278 @@ +package crossplane + +import ( + "context" + "sort" + + "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/core" + "github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/kubernetes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + apiextensionsv1 "github.com/crossplane/crossplane/v2/apis/apiextensions/v1" +) + +const ( + // LabelCompositionName is the label key for the composition name on CompositionRevisions. + LabelCompositionName = "crossplane.io/composition-name" +) + +// CompositionRevisionClient handles operations related to CompositionRevisions. +type CompositionRevisionClient interface { + core.Initializable + + // GetCompositionRevision gets a composition revision by name + GetCompositionRevision(ctx context.Context, name string) (*apiextensionsv1.CompositionRevision, error) + + // ListCompositionRevisions lists all composition revisions in the cluster + ListCompositionRevisions(ctx context.Context) ([]*apiextensionsv1.CompositionRevision, error) + + // GetLatestRevisionForComposition finds the latest revision for a given composition + GetLatestRevisionForComposition(ctx context.Context, compositionName string) (*apiextensionsv1.CompositionRevision, error) + + // GetCompositionFromRevision extracts a Composition from a CompositionRevision + GetCompositionFromRevision(revision *apiextensionsv1.CompositionRevision) *apiextensionsv1.Composition +} + +// DefaultCompositionRevisionClient implements CompositionRevisionClient. +type DefaultCompositionRevisionClient struct { + resourceClient kubernetes.ResourceClient + logger logging.Logger + + // Cache of composition revisions by name (for individual revision lookups) + revisions map[string]*apiextensionsv1.CompositionRevision + // Cache of revisions per composition (lazy-loaded on demand) + revisionsByComposition map[string][]*apiextensionsv1.CompositionRevision + gvks []schema.GroupVersionKind +} + +// NewCompositionRevisionClient creates a new DefaultCompositionRevisionClient. +func NewCompositionRevisionClient(resourceClient kubernetes.ResourceClient, logger logging.Logger) CompositionRevisionClient { + return &DefaultCompositionRevisionClient{ + resourceClient: resourceClient, + logger: logger, + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } +} + +// Initialize prepares the composition revision client for use. +func (c *DefaultCompositionRevisionClient) Initialize(ctx context.Context) error { + c.logger.Debug("Initializing composition revision client") + + gvks, err := c.resourceClient.GetGVKsForGroupKind(ctx, "apiextensions.crossplane.io", "CompositionRevision") + if err != nil { + return errors.Wrap(err, "cannot get CompositionRevision GVKs") + } + + c.gvks = gvks + + c.logger.Debug("Composition revision client initialized") + + return nil +} + +// listCompositionRevisionsForComposition lists composition revisions for a specific composition using label selector. +func (c *DefaultCompositionRevisionClient) listCompositionRevisionsForComposition(ctx context.Context, compositionName string) ([]*apiextensionsv1.CompositionRevision, error) { + c.logger.Debug("Listing composition revisions for composition", "compositionName", compositionName) + + // Define the composition revision GVK + gvk := schema.GroupVersionKind{ + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositionRevision", + } + + // Use label selector to filter server-side + labelSelector := metav1.LabelSelector{ + MatchLabels: map[string]string{ + LabelCompositionName: compositionName, + }, + } + + unRevisions, err := c.resourceClient.GetResourcesByLabel(ctx, gvk, "", labelSelector) + if err != nil { + c.logger.Debug("Failed to list composition revisions", "compositionName", compositionName, "error", err) + return nil, errors.Wrapf(err, "cannot list composition revisions for composition %s", compositionName) + } + + // Convert unstructured to typed + revisions := make([]*apiextensionsv1.CompositionRevision, 0, len(unRevisions)) + for _, obj := range unRevisions { + rev := &apiextensionsv1.CompositionRevision{} + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, rev) + if err != nil { + c.logger.Debug("Failed to convert composition revision from unstructured", + "name", obj.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot convert unstructured to CompositionRevision") + } + + revisions = append(revisions, rev) + } + + c.logger.Debug("Successfully retrieved composition revisions", "compositionName", compositionName, "count", len(revisions)) + + return revisions, nil +} + +// ListCompositionRevisions lists all composition revisions in the cluster. +func (c *DefaultCompositionRevisionClient) ListCompositionRevisions(ctx context.Context) ([]*apiextensionsv1.CompositionRevision, error) { + c.logger.Debug("Listing composition revisions from cluster") + + // Define the composition revision GVK + gvk := schema.GroupVersionKind{ + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositionRevision", + } + + // Get all composition revisions using the resource client + unRevisions, err := c.resourceClient.ListResources(ctx, gvk, "") + if err != nil { + c.logger.Debug("Failed to list composition revisions", "error", err) + return nil, errors.Wrap(err, "cannot list composition revisions from cluster") + } + + // Convert unstructured to typed + revisions := make([]*apiextensionsv1.CompositionRevision, 0, len(unRevisions)) + for _, obj := range unRevisions { + rev := &apiextensionsv1.CompositionRevision{} + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.Object, rev) + if err != nil { + c.logger.Debug("Failed to convert composition revision from unstructured", + "name", obj.GetName(), + "error", err) + + return nil, errors.Wrap(err, "cannot convert unstructured to CompositionRevision") + } + + revisions = append(revisions, rev) + } + + c.logger.Debug("Successfully retrieved composition revisions", "count", len(revisions)) + + return revisions, nil +} + +// GetCompositionRevision gets a composition revision by name. +func (c *DefaultCompositionRevisionClient) GetCompositionRevision(ctx context.Context, name string) (*apiextensionsv1.CompositionRevision, error) { + // Check cache first + if rev, ok := c.revisions[name]; ok { + return rev, nil + } + + // Not in cache, fetch from cluster + gvk := schema.GroupVersionKind{ + Group: "apiextensions.crossplane.io", + Version: "v1", + Kind: "CompositionRevision", + } + + unRev, err := c.resourceClient.GetResource(ctx, gvk, "" /* CompositionRevisions are cluster scoped */, name) + if err != nil { + return nil, errors.Wrapf(err, "cannot get composition revision %s", name) + } + + // Convert to typed + rev := &apiextensionsv1.CompositionRevision{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(unRev.Object, rev); err != nil { + return nil, errors.Wrap(err, "cannot convert unstructured to CompositionRevision") + } + + // Update cache + c.revisions[name] = rev + + return rev, nil +} + +// GetLatestRevisionForComposition finds the latest revision for a given composition. +func (c *DefaultCompositionRevisionClient) GetLatestRevisionForComposition(ctx context.Context, compositionName string) (*apiextensionsv1.CompositionRevision, error) { + c.logger.Debug("Finding latest revision for composition", "compositionName", compositionName) + + // Check if we've already loaded revisions for this composition + matchingRevisions, cached := c.revisionsByComposition[compositionName] + if !cached { + // Load revisions for this specific composition using label selector + c.logger.Debug("Loading revisions for composition", "compositionName", compositionName) + + revisions, err := c.listCompositionRevisionsForComposition(ctx, compositionName) + if err != nil { + return nil, errors.Wrap(err, "cannot list composition revisions") + } + + matchingRevisions = revisions + + // Cache by name for individual lookups + for _, rev := range matchingRevisions { + c.revisions[rev.GetName()] = rev + } + + // Cache the filtered list even if empty (to avoid re-querying) + c.revisionsByComposition[compositionName] = matchingRevisions + } + + if len(matchingRevisions) == 0 { + return nil, errors.Errorf("no composition revisions found for composition %s", compositionName) + } + + // Sort by revision number (highest first) + sort.Slice(matchingRevisions, func(i, j int) bool { + return matchingRevisions[i].Spec.Revision > matchingRevisions[j].Spec.Revision + }) + + latest := matchingRevisions[0] + + // Validate that we don't have duplicate revision numbers (would indicate a serious error) + if len(matchingRevisions) > 1 && matchingRevisions[0].Spec.Revision == matchingRevisions[1].Spec.Revision { + return nil, errors.Errorf( + "multiple composition revisions found with the same revision number %d for composition %s (revisions: %s, %s) - this indicates a serious error in the Crossplane runtime", + latest.Spec.Revision, compositionName, matchingRevisions[0].GetName(), matchingRevisions[1].GetName()) + } + + c.logger.Debug("Found latest revision", + "compositionName", compositionName, + "revisionName", latest.GetName(), + "revisionNumber", latest.Spec.Revision) + + return latest, nil +} + +// GetCompositionFromRevision extracts a Composition from a CompositionRevision. +// CompositionRevision contains the full Composition spec, so we construct a Composition object. +func (c *DefaultCompositionRevisionClient) GetCompositionFromRevision(revision *apiextensionsv1.CompositionRevision) *apiextensionsv1.Composition { + if revision == nil { + return nil + } + + comp := &apiextensionsv1.Composition{ + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: revision.Spec.CompositeTypeRef, + Mode: revision.Spec.Mode, + Pipeline: revision.Spec.Pipeline, + WriteConnectionSecretsToNamespace: revision.Spec.WriteConnectionSecretsToNamespace, + }, + } + + // Copy metadata from the revision to the composition + // Use the composition name from the label if available + if labels := revision.GetLabels(); labels != nil { + if compositionName := labels[LabelCompositionName]; compositionName != "" { + comp.SetName(compositionName) + } + } + + // If we couldn't get the name from labels, use the revision name (minus the hash suffix) + if comp.GetName() == "" { + comp.SetName(revision.GetName()) + } + + return comp +} diff --git a/cmd/diff/client/crossplane/composition_revision_client_test.go b/cmd/diff/client/crossplane/composition_revision_client_test.go new file mode 100644 index 0000000..1181512 --- /dev/null +++ b/cmd/diff/client/crossplane/composition_revision_client_test.go @@ -0,0 +1,528 @@ +package crossplane + +import ( + "context" + "strings" + "testing" + + tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils" + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextensionsv1 "github.com/crossplane/crossplane/v2/apis/apiextensions/v1" +) + +func TestDefaultCompositionRevisionClient_Initialize(t *testing.T) { + ctx := t.Context() + + tests := map[string]struct { + reason string + mockResource *tu.MockResourceClient + expectError bool + }{ + "SuccessfulInitialization": { + reason: "Should successfully initialize the client", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithFoundGVKs([]schema.GroupVersionKind{{Group: CrossplaneAPIExtGroup, Kind: "CompositionRevision"}}). + Build(), + expectError: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + resourceClient: tt.mockResource, + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + err := c.Initialize(ctx) + + if tt.expectError && err == nil { + t.Errorf("\n%s\nInitialize(): expected error but got none", tt.reason) + } else if !tt.expectError && err != nil { + t.Errorf("\n%s\nInitialize(): unexpected error: %v", tt.reason, err) + } + }) + } +} + +func TestDefaultCompositionRevisionClient_GetCompositionRevision(t *testing.T) { + ctx := t.Context() + + // Create a test composition revision + testRev := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Mock resource client + mockResource := tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) { + if gvk.Group == CrossplaneAPIExtGroup && gvk.Kind == "CompositionRevision" && name == "test-comp-abc123" { + u := &un.Unstructured{} + u.SetGroupVersionKind(gvk) + u.SetName(name) + + obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(testRev) + if err != nil { + return nil, err + } + + u.SetUnstructuredContent(obj) + + return u, nil + } + + return nil, errors.New("composition revision not found") + }). + Build() + + tests := map[string]struct { + reason string + name string + cache map[string]*apiextensionsv1.CompositionRevision + expectRev *apiextensionsv1.CompositionRevision + expectError bool + errorPattern string + }{ + "CachedRevision": { + reason: "Should return revision from cache when available", + name: "cached-rev", + cache: map[string]*apiextensionsv1.CompositionRevision{ + "cached-rev": testRev, + }, + expectRev: testRev, + expectError: false, + }, + "FetchFromCluster": { + reason: "Should fetch revision from cluster when not in cache", + name: "test-comp-abc123", + cache: map[string]*apiextensionsv1.CompositionRevision{}, + expectRev: testRev, + expectError: false, + }, + "NotFound": { + reason: "Should return error when revision doesn't exist", + name: "nonexistent-rev", + cache: map[string]*apiextensionsv1.CompositionRevision{}, + expectRev: nil, + expectError: true, + errorPattern: "cannot get composition revision", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + resourceClient: mockResource, + logger: tu.TestLogger(t, false), + revisions: tt.cache, + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + rev, err := c.GetCompositionRevision(ctx, tt.name) + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nGetCompositionRevision(...): expected error but got none", tt.reason) + return + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nGetCompositionRevision(...): expected error containing %q, got %q", + tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetCompositionRevision(...): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.expectRev.GetName(), rev.GetName()); diff != "" { + t.Errorf("\n%s\nGetCompositionRevision(...): -want name, +got name:\n%s", tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionRevisionClient_GetLatestRevisionForComposition(t *testing.T) { + ctx := t.Context() + + // Create test revisions + rev1 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + rev2 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev2", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 2, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + rev3 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-rev3", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 3, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Create duplicate revision numbers (error case) + duplicateRev1 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dup-comp-rev1", + Labels: map[string]string{ + LabelCompositionName: "dup-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 5, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + duplicateRev2 := &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dup-comp-rev2", + Labels: map[string]string{ + LabelCompositionName: "dup-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 5, // Same revision number as duplicateRev1 + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + } + + // Convert revisions to unstructured + toUnstructured := func(rev *apiextensionsv1.CompositionRevision) *un.Unstructured { + u := &un.Unstructured{} + obj, _ := runtime.DefaultUnstructuredConverter.ToUnstructured(rev) + u.SetUnstructuredContent(obj) + u.SetGroupVersionKind(schema.GroupVersionKind{ + Group: CrossplaneAPIExtGroup, + Version: "v1", + Kind: "CompositionRevision", + }) + + return u + } + + tests := map[string]struct { + reason string + compositionName string + mockResource *tu.MockResourceClient + cachedByComp map[string][]*apiextensionsv1.CompositionRevision + expectRev *apiextensionsv1.CompositionRevision + expectError bool + errorPattern string + }{ + "ReturnsLatestRevision": { + reason: "Should return the revision with the highest revision number", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), toUnstructured(rev2), toUnstructured(rev3), + }, LabelCompositionName, "test-comp"). + Build(), + expectRev: rev3, + expectError: false, + }, + "SingleRevision": { + reason: "Should return the only revision when there's just one", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(rev1), + }, LabelCompositionName, "test-comp"). + Build(), + expectRev: rev1, + expectError: false, + }, + "NoRevisionsFound": { + reason: "Should return error when no revisions exist for the composition", + compositionName: "nonexistent-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + // Set up one composition but query for a different one + toUnstructured(rev1), + }, LabelCompositionName, "test-comp"). + Build(), + expectRev: nil, + expectError: true, + errorPattern: "no composition revisions found", + }, + "DuplicateRevisionNumbers": { + reason: "Should return error when multiple revisions have the same number", + compositionName: "dup-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithResourcesFoundByLabel([]*un.Unstructured{ + toUnstructured(duplicateRev1), toUnstructured(duplicateRev2), + }, LabelCompositionName, "dup-comp"). + Build(), + expectRev: nil, + expectError: true, + errorPattern: "multiple composition revisions found with the same revision number", + }, + "UsesCache": { + reason: "Should use cached revisions when available", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ metav1.LabelSelector) ([]*un.Unstructured, error) { + // This should not be called because cache is populated + return nil, errors.New("should not call GetResourcesByLabel when cache is populated") + }). + Build(), + cachedByComp: map[string][]*apiextensionsv1.CompositionRevision{ + "test-comp": {rev1, rev2, rev3}, + }, + expectRev: rev3, + expectError: false, + }, + "ListRevisionsError": { + reason: "Should return error when listing revisions fails", + compositionName: "test-comp", + mockResource: tu.NewMockResourceClient(). + WithSuccessfulInitialize(). + WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, _ metav1.LabelSelector) ([]*un.Unstructured, error) { + return nil, errors.New("list error") + }). + Build(), + expectRev: nil, + expectError: true, + errorPattern: "cannot list composition revisions", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + resourceClient: tt.mockResource, + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: tt.cachedByComp, + } + + if tt.cachedByComp == nil { + c.revisionsByComposition = make(map[string][]*apiextensionsv1.CompositionRevision) + } + + rev, err := c.GetLatestRevisionForComposition(ctx, tt.compositionName) + + if tt.expectError { + if err == nil { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): expected error but got none", tt.reason) + return + } + + if tt.errorPattern != "" && !strings.Contains(err.Error(), tt.errorPattern) { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): expected error containing %q, got %q", + tt.reason, tt.errorPattern, err.Error()) + } + + return + } + + if err != nil { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): unexpected error: %v", tt.reason, err) + return + } + + if diff := cmp.Diff(tt.expectRev.GetName(), rev.GetName()); diff != "" { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): -want name, +got name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.expectRev.Spec.Revision, rev.Spec.Revision); diff != "" { + t.Errorf("\n%s\nGetLatestRevisionForComposition(...): -want revision number, +got revision number:\n%s", + tt.reason, diff) + } + }) + } +} + +func TestDefaultCompositionRevisionClient_GetCompositionFromRevision(t *testing.T) { + tests := map[string]struct { + reason string + revision *apiextensionsv1.CompositionRevision + expectComp *apiextensionsv1.Composition + expectNil bool + }{ + "ValidRevision": { + reason: "Should extract composition from revision", + revision: &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", + Labels: map[string]string{ + LabelCompositionName: "test-comp", + }, + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + Pipeline: []apiextensionsv1.PipelineStep{ + { + Step: "test-step", + FunctionRef: apiextensionsv1.FunctionReference{ + Name: "test-function", + }, + }, + }, + }, + }, + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp", + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + Pipeline: []apiextensionsv1.PipelineStep{ + { + Step: "test-step", + FunctionRef: apiextensionsv1.FunctionReference{ + Name: "test-function", + }, + }, + }, + }, + }, + expectNil: false, + }, + "NilRevision": { + reason: "Should return nil when revision is nil", + revision: nil, + expectComp: nil, + expectNil: true, + }, + "NoLabelUsesRevisionName": { + reason: "Should use revision name when label is missing", + revision: &apiextensionsv1.CompositionRevision{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", + }, + Spec: apiextensionsv1.CompositionRevisionSpec{ + Revision: 1, + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectComp: &apiextensionsv1.Composition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-comp-abc123", // Should use revision name + }, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XR1", + }, + }, + }, + expectNil: false, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &DefaultCompositionRevisionClient{ + logger: tu.TestLogger(t, false), + revisions: make(map[string]*apiextensionsv1.CompositionRevision), + revisionsByComposition: make(map[string][]*apiextensionsv1.CompositionRevision), + } + + comp := c.GetCompositionFromRevision(tt.revision) + + if tt.expectNil { + if comp != nil { + t.Errorf("\n%s\nGetCompositionFromRevision(...): expected nil, got composition", tt.reason) + } + + return + } + + if comp == nil { + t.Errorf("\n%s\nGetCompositionFromRevision(...): unexpected nil composition", tt.reason) + return + } + + if diff := cmp.Diff(tt.expectComp.GetName(), comp.GetName()); diff != "" { + t.Errorf("\n%s\nGetCompositionFromRevision(...): -want name, +got name:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.expectComp.Spec.CompositeTypeRef, comp.Spec.CompositeTypeRef); diff != "" { + t.Errorf("\n%s\nGetCompositionFromRevision(...): -want type ref, +got type ref:\n%s", tt.reason, diff) + } + }) + } +} diff --git a/cmd/diff/comp.go b/cmd/diff/comp.go index 57a4742..25c12a2 100644 --- a/cmd/diff/comp.go +++ b/cmd/diff/comp.go @@ -37,7 +37,8 @@ type CompCmd struct { Files []string `arg:"" help:"YAML files containing updated Composition(s)." optional:""` // Configuration options - Namespace string `default:"" help:"Namespace to find XRs (empty = all namespaces)." name:"namespace" short:"n"` + Namespace string `default:"" help:"Namespace to find XRs (empty = all namespaces)." name:"namespace" short:"n"` + IncludeManual bool `default:"false" help:"Include XRs with Manual update policy (default: only Automatic policy XRs)" name:"include-manual"` } // Help returns help instructions for the composition diff command. @@ -60,6 +61,9 @@ Examples: # Show compact diffs with minimal context crossplane-diff comp updated-composition.yaml --compact + + # Include XRs with Manual update policy (pinned revisions) + crossplane-diff comp updated-composition.yaml --include-manual ` } @@ -99,6 +103,7 @@ func makeDefaultCompProc(c *CompCmd, ctx *AppContext, log logging.Logger) dp.Com opts = append(opts, dp.WithLogger(log), dp.WithRenderMutex(&globalRenderMutex), + dp.WithIncludeManual(c.IncludeManual), ) // Create XR processor first (peer processor) diff --git a/cmd/diff/diff_integration_test.go b/cmd/diff/diff_integration_test.go index 53e21e5..3e6818d 100644 --- a/cmd/diff/diff_integration_test.go +++ b/cmd/diff/diff_integration_test.go @@ -69,10 +69,9 @@ func (s XrdAPIVersion) String() string { return versionNames[s] } -// runIntegrationTest runs a common integration test for both XR and composition diff commands. -func runIntegrationTest(t *testing.T, testType DiffTestType, tests map[string]IntegrationTestCase) { - t.Helper() - // Create a scheme with both Kubernetes and Crossplane types +// createTestScheme creates a runtime scheme with all required types registered. +// This can be shared across tests since it's just a type registry. +func createTestScheme() *runtime.Scheme { scheme := runtime.NewScheme() // Register Kubernetes types @@ -84,177 +83,184 @@ func runIntegrationTest(t *testing.T, testType DiffTestType, tests map[string]In _ = pkgv1.AddToScheme(scheme) _ = extv1.AddToScheme(scheme) - tu.SetupKubeTestLogger(t) + return scheme +} - for name, tt := range tests { - t.Run(name, func(t *testing.T) { - // Skip test if requested - if tt.skip { - t.Skip(tt.skipReason) - return - } - - // Setup a brand new test environment for each test case - _, thisFile, _, _ := run.Caller(0) - thisDir := filepath.Dir(thisFile) - - crdPaths := []string{ - filepath.Join(thisDir, "..", "..", "cluster", "main", "crds"), - filepath.Join(thisDir, "testdata", string(testType), "crds"), - } - - testEnv := &envtest.Environment{ - CRDDirectoryPaths: crdPaths, - ErrorIfCRDPathMissing: true, - Scheme: scheme, - } - - // Start the test environment - cfg, err := testEnv.Start() - if err != nil { - t.Fatalf("failed to start test environment: %v", err) - } - - // Ensure we clean up at the end of the test - defer func() { - err := testEnv.Stop() - if err != nil { - t.Logf("failed to stop test environment: %v", err) - } - }() - - // Create a controller-runtime client for setup operations - k8sClient, err := client.New(cfg, client.Options{Scheme: scheme}) - if err != nil { - t.Fatalf("failed to create client: %v", err) - } - - ctx, cancel := context.WithTimeout(t.Context(), timeout) - defer cancel() - - // Apply the setup resources - if err := applyResourcesFromFiles(ctx, k8sClient, tt.setupFiles); err != nil { - t.Fatalf("failed to setup resources: %v", err) - } - - // Default to v2 API version for XR resources unless otherwise specified - xrdAPIVersion := V2 - if tt.xrdAPIVersion != V2 { - xrdAPIVersion = tt.xrdAPIVersion - } - - // Apply resources with owner references - if len(tt.setupFilesWithOwnerRefs) > 0 { - err := applyHierarchicalOwnership(ctx, tu.TestLogger(t, false), k8sClient, xrdAPIVersion, tt.setupFilesWithOwnerRefs) - if err != nil { - t.Fatalf("failed to setup owner references: %v", err) - } - } - - // Set up the test files - var testFiles []string - - // Handle any additional input files - // Note: NewCompositeLoader handles both individual files and directories, - // so we can pass paths directly without special handling - testFiles = append(testFiles, tt.inputFiles...) - - // Create a buffer to capture the output - var stdout bytes.Buffer - - // Create command line args that match your pre-populated struct - args := []string{ - fmt.Sprintf("--timeout=%s", timeout.String()), - } - - // Add namespace if specified (for composition tests) - if tt.namespace != "" { - args = append(args, fmt.Sprintf("--namespace=%s", tt.namespace)) - } else if testType == XRDiffTest { - // For XR tests, always add default namespace - args = append(args, "--namespace=default") - } - - // Add no-color flag if true - if tt.noColor { - args = append(args, "--no-color") - } - - // Add files as positional arguments - args = append(args, testFiles...) - - // Set up the appropriate command based on test type - var cmd interface{} - if testType == CompositionDiffTest { - cmd = &CompCmd{} - } else { - cmd = &XRCmd{} - } - - logger := tu.TestLogger(t, true) - // Create a Kong context with stdout - parser, err := kong.New(cmd, - kong.Writers(&stdout, &stdout), - kong.Bind(cfg), - kong.BindTo(logger, (*logging.Logger)(nil)), - ) - if err != nil { - t.Fatalf("failed to create kong parser: %v", err) - } - - kongCtx, err := parser.Parse(args) - if err != nil { - t.Fatalf("failed to parse kong context: %v", err) - } - - err = kongCtx.Run(cfg) - - if tt.expectedError && err == nil { - t.Fatal("expected error but got none") - } - - if !tt.expectedError && err != nil { - t.Fatalf("expected no error but got: %v", err) - } - - // Check for specific error message if expected - if err != nil { - if tt.expectedErrorContains != "" && strings.Contains(err.Error(), tt.expectedErrorContains) { - // This is an expected error with the expected message - t.Logf("Got expected error containing: %s", tt.expectedErrorContains) - } else { - t.Errorf("Expected no error or specific error message, got: %v", err) - } - } - - // For expected errors with specific messages, we've already checked above - if tt.expectedError && tt.expectedErrorContains != "" { - // Skip output check for expected error cases - return - } - - // Check the output - outputStr := stdout.String() - // Using TrimSpace because the output might have trailing newlines - if !strings.Contains(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { - // Strings aren't equal, *including* ansi. but we can compare ignoring ansi to determine what output to - // show for the failure. if the difference is only in color codes, we'll show escaped ansi codes. - out := outputStr - - expect := tt.expectedOutput - if tu.CompareIgnoringAnsi(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { - out = strconv.QuoteToASCII(outputStr) - expect = strconv.QuoteToASCII(tt.expectedOutput) - } - - t.Fatalf("expected output to contain:\n%s\n\nbut got:\n%s", expect, out) - } - }) +// runIntegrationTest runs a single integration test case for both XR and composition diff commands. +func runIntegrationTest(t *testing.T, testType DiffTestType, scheme *runtime.Scheme, tt IntegrationTestCase) { + t.Helper() + + // Skip test if requested + if tt.skip { + t.Skip(tt.skipReason) + return + } + + // Setup a brand new test environment for each test case + _, thisFile, _, ok := run.Caller(0) + if !ok { + t.Fatal("failed to get caller information") + } + + thisDir := filepath.Dir(thisFile) + + crdPaths := []string{ + filepath.Join(thisDir, "..", "..", "cluster", "main", "crds"), + filepath.Join(thisDir, "testdata", string(testType), "crds"), + } + + testEnv := &envtest.Environment{ + CRDDirectoryPaths: crdPaths, + ErrorIfCRDPathMissing: true, + Scheme: scheme, + } + + // Start the test environment + cfg, err := testEnv.Start() + if err != nil { + t.Fatalf("failed to start test environment: %v", err) + } + + // Ensure we clean up at the end of the test + defer func() { + err := testEnv.Stop() + if err != nil { + t.Logf("failed to stop test environment: %v", err) + } + }() + + // Create a controller-runtime client for setup operations + k8sClient, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + ctx, cancel := context.WithTimeout(t.Context(), timeout) + defer cancel() + + // Apply the setup resources + if err := applyResourcesFromFiles(ctx, k8sClient, tt.setupFiles); err != nil { + t.Fatalf("failed to setup resources: %v", err) + } + + // Default to v2 API version for XR resources unless otherwise specified + xrdAPIVersion := V2 + if tt.xrdAPIVersion != V2 { + xrdAPIVersion = tt.xrdAPIVersion + } + + // Apply resources with owner references + if len(tt.setupFilesWithOwnerRefs) > 0 { + err := applyHierarchicalOwnership(ctx, tu.TestLogger(t, false), k8sClient, xrdAPIVersion, tt.setupFilesWithOwnerRefs) + if err != nil { + t.Fatalf("failed to setup owner references: %v", err) + } + } + + // Set up the test files + var testFiles []string + + // Handle any additional input files + // Note: NewCompositeLoader handles both individual files and directories, + // so we can pass paths directly without special handling + testFiles = append(testFiles, tt.inputFiles...) + + // Create a buffer to capture the output + var stdout bytes.Buffer + + // Create command line args that match your pre-populated struct + args := []string{ + fmt.Sprintf("--timeout=%s", timeout.String()), + } + + // Add namespace if specified (for composition tests) + if tt.namespace != "" { + args = append(args, fmt.Sprintf("--namespace=%s", tt.namespace)) + } else if testType == XRDiffTest { + // For XR tests, always add default namespace + args = append(args, "--namespace=default") + } + + // Add no-color flag if true + if tt.noColor { + args = append(args, "--no-color") + } + + // Add files as positional arguments + args = append(args, testFiles...) + + // Set up the appropriate command based on test type + var cmd interface{} + if testType == CompositionDiffTest { + cmd = &CompCmd{} + } else { + cmd = &XRCmd{} + } + + logger := tu.TestLogger(t, true) + // Create a Kong context with stdout + parser, err := kong.New(cmd, + kong.Writers(&stdout, &stdout), + kong.Bind(cfg), + kong.BindTo(logger, (*logging.Logger)(nil)), + ) + if err != nil { + t.Fatalf("failed to create kong parser: %v", err) + } + + kongCtx, err := parser.Parse(args) + if err != nil { + t.Fatalf("failed to parse kong context: %v", err) + } + + err = kongCtx.Run(cfg) + + if tt.expectedError && err == nil { + t.Fatal("expected error but got none") + } + + if !tt.expectedError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + // Check for specific error message if expected + if err != nil { + if tt.expectedErrorContains != "" && strings.Contains(err.Error(), tt.expectedErrorContains) { + // This is an expected error with the expected message + t.Logf("Got expected error containing: %s", tt.expectedErrorContains) + } else { + t.Errorf("Expected no error or specific error message, got: %v", err) + } + } + + // For expected errors with specific messages, we've already checked above + if tt.expectedError && tt.expectedErrorContains != "" { + // Skip output check for expected error cases + return + } + + // Check the output + outputStr := stdout.String() + // Using TrimSpace because the output might have trailing newlines + if !strings.Contains(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { + // Strings aren't equal, *including* ansi. but we can compare ignoring ansi to determine what output to + // show for the failure. if the difference is only in color codes, we'll show escaped ansi codes. + out := outputStr + + expect := tt.expectedOutput + if tu.CompareIgnoringAnsi(strings.TrimSpace(outputStr), strings.TrimSpace(tt.expectedOutput)) { + out = strconv.QuoteToASCII(outputStr) + expect = strconv.QuoteToASCII(tt.expectedOutput) + } + + t.Fatalf("expected output to contain:\n%s\n\nbut got:\n%s", expect, out) } } // TestDiffIntegration runs an integration test for the diff command. func TestDiffIntegration(t *testing.T) { + scheme := createTestScheme() + tests := map[string]IntegrationTestCase{ "New resource shows color diff": { inputFiles: []string{"testdata/diff/new-xr.yaml"}, @@ -338,6 +344,7 @@ func TestDiffIntegration(t *testing.T) { setupFiles: []string{ "testdata/diff/resources/xrd.yaml", "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/composition-revision-default.yaml", "testdata/diff/resources/functions.yaml", // put an existing XR in the cluster to diff against "testdata/diff/resources/existing-downstream-resource.yaml", @@ -373,13 +380,15 @@ func TestDiffIntegration(t *testing.T) { ` + tu.Green("+ coolField: modified-value") + ` --- -`, + +Summary: 2 modified`, expectedError: false, }, "Modified XR that creates new downstream resource shows color diff": { setupFiles: []string{ "testdata/diff/resources/xrd.yaml", "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/composition-revision-default.yaml", "testdata/diff/resources/functions.yaml", "testdata/diff/resources/existing-xr.yaml", }, @@ -411,7 +420,8 @@ func TestDiffIntegration(t *testing.T) { ` + tu.Green("+ coolField: modified-value") + ` --- -`, + +Summary: 1 added, 1 modified`, expectedError: false, }, "EnvironmentConfig (v1beta1) incorporation in diff": { @@ -559,9 +569,9 @@ func TestDiffIntegration(t *testing.T) { noColor: true, }, "Cross-namespace resource dependencies via fn-external-resources": { - // Skip this test until function-extra-resources supports the namespace field - // This test documents the intended cross-namespace functionality and will work - // once function-extra-resources is updated to support Crossplane v2 namespace specification + skip: true, + // TODO: we have updated this function now so we can fix the test + skipReason: "function-extra-resources does not yet support namespace field for cross-namespace resource access", setupFiles: []string{ "testdata/diff/resources/xrd.yaml", "testdata/diff/resources/functions.yaml", @@ -606,8 +616,6 @@ func TestDiffIntegration(t *testing.T) { `, expectedError: false, noColor: true, - skip: true, - skipReason: "function-extra-resources does not yet support namespace field for cross-namespace resource access", }, "Resource removal detection with hierarchy (v1 style resourceRefs; cluster scoped downstreams)": { xrdAPIVersion: V1, @@ -628,6 +636,7 @@ func TestDiffIntegration(t *testing.T) { setupFiles: []string{ "testdata/diff/resources/legacy-xrd.yaml", "testdata/diff/resources/removal-test-legacy-composition.yaml", + "testdata/diff/resources/removal-test-legacy-composition-revision.yaml", "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-legacy-xr.yaml"}, @@ -689,7 +698,8 @@ func TestDiffIntegration(t *testing.T) { + coolField: modified-value --- -`, + +Summary: 2 modified, 2 removed`, expectedError: false, noColor: true, }, @@ -711,6 +721,7 @@ func TestDiffIntegration(t *testing.T) { setupFiles: []string{ "testdata/diff/resources/xrd.yaml", "testdata/diff/resources/removal-test-composition.yaml", + "testdata/diff/resources/removal-test-composition-revision.yaml", "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-xr.yaml"}, @@ -775,7 +786,8 @@ func TestDiffIntegration(t *testing.T) { + coolField: modified-value --- -`, + +Summary: 2 modified, 2 removed`, expectedError: false, noColor: true, }, @@ -797,6 +809,7 @@ func TestDiffIntegration(t *testing.T) { setupFiles: []string{ "testdata/diff/resources/cluster-xrd.yaml", "testdata/diff/resources/removal-test-cluster-composition.yaml", + "testdata/diff/resources/removal-test-cluster-composition-revision.yaml", "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/modified-cluster-xr.yaml"}, @@ -857,7 +870,8 @@ func TestDiffIntegration(t *testing.T) { + coolField: modified-value --- -`, + +Summary: 2 modified, 2 removed`, expectedError: false, noColor: true, }, @@ -954,6 +968,7 @@ func TestDiffIntegration(t *testing.T) { setupFiles: []string{ "testdata/diff/resources/xrd.yaml", "testdata/diff/resources/composition.yaml", + "testdata/diff/resources/composition-revision-default.yaml", "testdata/diff/resources/functions.yaml", // Add an existing XR and downstream resource to test modification "testdata/diff/resources/existing-xr.yaml", @@ -1136,6 +1151,7 @@ Summary: 2 added, 2 modified // Add the necessary CRDs and compositions for claim diffing "testdata/diff/resources/claim-xrd.yaml", "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/claim-composition-revision.yaml", "testdata/diff/resources/functions.yaml", }, inputFiles: []string{"testdata/diff/new-claim.yaml"}, @@ -1179,6 +1195,7 @@ Summary: 2 added`, // Add necessary CRDs and composition "testdata/diff/resources/claim-xrd.yaml", "testdata/diff/resources/claim-composition.yaml", + "testdata/diff/resources/claim-composition-revision.yaml", "testdata/diff/resources/functions.yaml", // Add existing resources for comparison "testdata/diff/resources/existing-claim.yaml", @@ -1296,6 +1313,7 @@ Summary: 1 added`, setupFiles: []string{ "testdata/diff/resources/xrd-concurrent.yaml", "testdata/diff/resources/composition-multi-functions.yaml", + "testdata/diff/resources/composition-revision-multi-functions.yaml", "testdata/diff/resources/functions.yaml", }, // We expect successful processing of all 5 XRs @@ -1371,7 +1389,9 @@ Summary: 3 added`, "testdata/diff/resources/nested/child-xrd.yaml", // Compositions for parent and child "testdata/diff/resources/nested/parent-composition.yaml", + "testdata/diff/resources/nested/parent-composition-revision.yaml", "testdata/diff/resources/nested/child-composition.yaml", + "testdata/diff/resources/nested/child-composition-revision.yaml", // XRD for downstream managed resource "testdata/diff/resources/xdownstreamenvresource-xrd.yaml", "testdata/diff/resources/functions.yaml", @@ -1432,13 +1452,320 @@ Summary: 3 modified`, expectedError: false, noColor: true, }, + // Composition Revision tests for v2 XRDs + "v2 XR with Manual update policy stays on pinned revision": { + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-revision-v2.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + "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"}, + expectedOutput: ` +~~~ XDownstreamResource/test-manual-v1 + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: ++ crossplane.io/composition-resource-name: nop-resource + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-manual-v1 + name: test-manual-v1 + namespace: default + spec: + forProvider: +- configData: v1-existing-value ++ configData: v1-modified-value + +--- +~~~ XNopResource/test-manual-v1 + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: test-manual-v1 + namespace: default + spec: +- coolField: existing-value ++ coolField: modified-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-abc123 + compositionUpdatePolicy: Manual + +--- +`, + expectedError: false, + noColor: true, + }, + "v2 XR with Automatic update policy uses latest revision": { + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-revision-v2.yaml", + "testdata/diff/resources/composition-v2.yaml", // Current composition is v2 + "testdata/diff/resources/functions.yaml", + "testdata/diff/resources/existing-xr-automatic.yaml", // Still on v1 + "testdata/diff/resources/existing-downstream-automatic.yaml", // v1 data + }, + inputFiles: []string{"testdata/diff/modified-xr-automatic.yaml"}, + expectedOutput: ` +~~~ XDownstreamResource/test-automatic + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: ++ crossplane.io/composition-resource-name: nop-resource + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-automatic + name: test-automatic + namespace: default + spec: + forProvider: +- configData: v1-existing-value ++ configData: v2-modified-value + +--- +~~~ XNopResource/test-automatic + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: test-automatic + namespace: default + spec: +- coolField: existing-value ++ coolField: modified-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-abc123 + compositionUpdatePolicy: Automatic + +--- +`, + expectedError: false, + noColor: true, + }, + "v2 XR changing revision in Manual mode shows upgrade diff": { + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-revision-v2.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + "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"}, + expectedOutput: ` +~~~ XDownstreamResource/test-manual-v1 + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: ++ crossplane.io/composition-resource-name: nop-resource + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-manual-v1 + name: test-manual-v1 + namespace: default + spec: + forProvider: +- configData: v1-existing-value ++ configData: v2-modified-value + +--- +~~~ XNopResource/test-manual-v1 + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: test-manual-v1 + namespace: default + spec: +- coolField: existing-value ++ coolField: modified-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: +- name: xnopresources.diff.example.org-abc123 ++ name: xnopresources.diff.example.org-def456 + compositionUpdatePolicy: Manual + +--- +`, + expectedError: false, + noColor: true, + }, + "v2 XR switching from Manual to Automatic mode uses latest revision": { + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-revision-v2.yaml", + "testdata/diff/resources/composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + "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"}, + expectedOutput: ` +~~~ XDownstreamResource/test-manual-v1 + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: ++ crossplane.io/composition-resource-name: nop-resource + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-manual-v1 + name: test-manual-v1 + namespace: default + spec: + forProvider: +- configData: v1-existing-value ++ configData: v2-modified-value + +--- +~~~ XNopResource/test-manual-v1 + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + metadata: + name: test-manual-v1 + namespace: default + spec: +- coolField: existing-value ++ coolField: modified-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-abc123 +- compositionUpdatePolicy: Manual ++ compositionUpdatePolicy: Automatic + +--- +`, + expectedError: false, + noColor: true, + }, + "v2 Net new XR with Manual policy but no revision ref uses latest revision": { + setupFiles: []string{ + "testdata/diff/resources/xrd.yaml", + "testdata/diff/resources/composition-revision-v1.yaml", + "testdata/diff/resources/composition-revision-v2.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"}, + expectedOutput: ` ++++ XDownstreamResource/test-manual-no-ref ++ apiVersion: ns.nop.example.org/v1alpha1 ++ kind: XDownstreamResource ++ metadata: ++ annotations: ++ crossplane.io/composition-resource-name: nop-resource ++ labels: ++ crossplane.io/composite: test-manual-no-ref ++ name: test-manual-no-ref ++ namespace: default ++ spec: ++ forProvider: ++ configData: v2-new-value + +--- ++++ XNopResource/test-manual-no-ref ++ apiVersion: ns.diff.example.org/v1alpha1 ++ kind: XNopResource ++ metadata: ++ name: test-manual-no-ref ++ namespace: default ++ spec: ++ coolField: new-value ++ crossplane: ++ compositionRef: ++ name: xnopresources.diff.example.org ++ compositionUpdatePolicy: Manual + +--- + +Summary: 2 added`, + expectedError: false, + noColor: true, + }, + // Composition Revision tests for v1 XRDs (Crossplane 1.20 compatibility) + "v1 XR with Manual update policy changing revision shows upgrade diff": { + xrdAPIVersion: V1, + setupFiles: []string{ + "testdata/diff/resources/legacy-xrd.yaml", + "testdata/diff/resources/legacy-composition-revision-v1.yaml", + "testdata/diff/resources/legacy-composition-revision-v2.yaml", + "testdata/diff/resources/legacy-composition-v2.yaml", + "testdata/diff/resources/functions.yaml", + "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"}, + expectedOutput: ` +~~~ XDownstreamResource/test-legacy-manual-v1 + apiVersion: legacycluster.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: ++ crossplane.io/composition-resource-name: nop-resource + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-legacy-manual-v1 + name: test-legacy-manual-v1 + spec: + forProvider: +- configData: v1-existing-value ++ configData: v2-modified-value + +--- +~~~ XNopResource/test-legacy-manual-v1 + 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-abc123 +- compositionUpdatePolicy: Manual +- coolField: existing-value ++ name: xlegacynopresources.diff.example.org-def456 ++ compositionUpdatePolicy: Manual ++ coolField: modified-value + + + + +--- +`, + expectedError: false, + noColor: true, + }, } - runIntegrationTest(t, XRDiffTest, tests) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + runIntegrationTest(t, XRDiffTest, scheme, tt) + }) + } } // TestCompDiffIntegration runs an integration test for the composition diff command. func TestCompDiffIntegration(t *testing.T) { + scheme := createTestScheme() + tests := map[string]IntegrationTestCase{ "Composition change impacts existing XRs": { // Set up existing XRs that use the original composition @@ -1764,6 +2091,98 @@ No XRs found using composition xnopresources-v2.diff.example.org`, expectedError: false, noColor: true, }, + "Composition diff filters Manual policy XRs by default": { + // Set up existing XRs - one with Automatic policy and one with Manual policy + setupFiles: []string{ + "testdata/comp/resources/xrd.yaml", + "testdata/comp/resources/original-composition.yaml", + "testdata/comp/resources/composition-revision-v1.yaml", + "testdata/comp/resources/functions.yaml", + // Add existing XR with Automatic policy (should be included) + "testdata/comp/resources/existing-xr-1.yaml", + "testdata/comp/resources/existing-downstream-1.yaml", + // Add existing XR with Manual policy (should be filtered out by default) + "testdata/comp/resources/existing-xr-manual.yaml", + "testdata/comp/resources/existing-downstream-manual.yaml", + }, + // Updated composition + inputFiles: []string{"testdata/comp/updated-composition.yaml"}, + namespace: "default", + // Expected output should only show test-resource (Automatic), NOT manual-resource (Manual) + expectedOutput: ` +=== Composition Changes === + +~~~ Composition/xnopresources.diff.example.org + apiVersion: apiextensions.crossplane.io/v1 + kind: Composition + metadata: + name: xnopresources.diff.example.org + spec: + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: +- configData: {{ .observed.composite.resource.spec.coolField }} +- resourceTier: basic ++ configData: updated-{{ .observed.composite.resource.spec.coolField }} ++ resourceTier: premium + kind: GoTemplate + source: Inline + step: generate-resources + - functionRef: + name: function-auto-ready + step: automatically-detect-ready-composed-resources + +--- + +Summary: 1 modified + +=== Affected Composite Resources === + +- XNopResource/test-resource (namespace: default) + +=== Impact Analysis === + +~~~ XDownstreamResource/test-resource + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + annotations: ++ crossplane.io/composition-resource-name: nop-resource + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-resource + name: test-resource + namespace: default + spec: + forProvider: +- configData: existing-value +- resourceTier: basic ++ configData: updated-existing-value ++ resourceTier: premium + +--- + +Summary: 1 modified`, + expectedError: false, + noColor: true, + }, "Net-new composition with no downstream impact": { // Set up cluster with existing resources but no composition that matches the new one setupFiles: []string{ @@ -1829,5 +2248,9 @@ No XRs found using composition xnewresources.diff.example.org`, }, } - runIntegrationTest(t, CompositionDiffTest, tests) + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + runIntegrationTest(t, CompositionDiffTest, scheme, tt) + }) + } } diff --git a/cmd/diff/diffprocessor/comp_processor.go b/cmd/diff/diffprocessor/comp_processor.go index 5414342..9b0adfa 100644 --- a/cmd/diff/diffprocessor/comp_processor.go +++ b/cmd/diff/diffprocessor/comp_processor.go @@ -166,16 +166,41 @@ func (p *DefaultCompDiffProcessor) processSingleComposition(ctx context.Context, p.config.Logger.Debug("Found affected XRs", "composition", newComp.GetName(), "count", len(affectedXRs)) - if len(affectedXRs) == 0 { - p.config.Logger.Info("No XRs found using composition", "composition", newComp.GetName()) + // Filter XRs based on IncludeManual flag + filteredXRs := p.filterXRsByUpdatePolicy(affectedXRs) - if _, err := fmt.Fprintf(stdout, "No XRs found using composition %s\n", newComp.GetName()); err != nil { - return errors.Wrap(err, "cannot write no XRs message") + p.config.Logger.Debug("Filtered XRs by update policy", + "composition", newComp.GetName(), + "originalCount", len(affectedXRs), + "filteredCount", len(filteredXRs), + "includeManual", p.config.IncludeManual) + + if len(filteredXRs) == 0 { + if !p.config.IncludeManual && len(affectedXRs) > 0 { + // XRs exist but were filtered out due to Manual policy + p.config.Logger.Info("All XRs using composition have Manual update policy (use --include-manual to see them)", + "composition", newComp.GetName(), + "filteredCount", len(affectedXRs)) + + if _, err := fmt.Fprintf(stdout, "All %d XR(s) using composition %s have Manual update policy (use --include-manual to see them)\n", + len(affectedXRs), newComp.GetName()); err != nil { + return errors.Wrap(err, "cannot write filtered XRs message") + } + } else { + // No XRs found at all + p.config.Logger.Info("No XRs found using composition", "composition", newComp.GetName()) + + if _, err := fmt.Fprintf(stdout, "No XRs found using composition %s\n", newComp.GetName()); err != nil { + return errors.Wrap(err, "cannot write no XRs message") + } } return nil } + // Use filtered XRs for the rest of the processing + affectedXRs = filteredXRs + // Process affected XRs using the existing XR processor with composition override // List the affected XRs so users can understand the scope of impact var xrList strings.Builder @@ -298,3 +323,52 @@ func (p *DefaultCompDiffProcessor) displayCompositionDiff(ctx context.Context, s return nil } + +// filterXRsByUpdatePolicy filters XRs based on the IncludeManual configuration. +// By default (IncludeManual=false), only XRs with Automatic policy are included. +// When IncludeManual=true, all XRs are included regardless of policy. +func (p *DefaultCompDiffProcessor) filterXRsByUpdatePolicy(xrs []*un.Unstructured) []*un.Unstructured { + if p.config.IncludeManual { + // Include all XRs when flag is set + return xrs + } + + // Filter to only include Automatic policy XRs + filtered := make([]*un.Unstructured, 0, len(xrs)) + + for _, xr := range xrs { + policy := p.getCompositionUpdatePolicy(xr) + + p.config.Logger.Debug("Checking XR update policy", + "xr", xr.GetName(), + "kind", xr.GetKind(), + "policy", policy) + + // Include XRs that are not explicitly set to Manual (i.e., Automatic or empty/default) + if policy != "Manual" { + filtered = append(filtered, xr) + } + } + + return filtered +} + +// getCompositionUpdatePolicy retrieves the compositionUpdatePolicy from an XR. +// It checks both v2 (spec.crossplane.compositionUpdatePolicy) and v1 (spec.compositionUpdatePolicy) field paths. +// Returns "Automatic" as the default if not found (matching Crossplane behavior). +func (p *DefaultCompDiffProcessor) getCompositionUpdatePolicy(xr *un.Unstructured) string { + // Try v2 path first: spec.crossplane.compositionUpdatePolicy + policy, found, err := un.NestedString(xr.Object, "spec", "crossplane", "compositionUpdatePolicy") + if err == nil && found && policy != "" { + return policy + } + + // Try v1 path: spec.compositionUpdatePolicy + policy, found, err = un.NestedString(xr.Object, "spec", "compositionUpdatePolicy") + if err == nil && found && policy != "" { + return policy + } + + // Default to Automatic if not found (matching Crossplane default behavior) + return "Automatic" +} diff --git a/cmd/diff/diffprocessor/comp_processor_test.go b/cmd/diff/diffprocessor/comp_processor_test.go index 462c762..2f915e2 100644 --- a/cmd/diff/diffprocessor/comp_processor_test.go +++ b/cmd/diff/diffprocessor/comp_processor_test.go @@ -294,3 +294,209 @@ func TestDefaultCompDiffProcessor_DiffComposition(t *testing.T) { }) } } + +func TestDefaultCompDiffProcessor_filterXRsByUpdatePolicy(t *testing.T) { + tests := map[string]struct { + includeManual bool + xrs []*un.Unstructured + want []*un.Unstructured + }{ + "IncludeManualTrue_ReturnsAllXRs": { + includeManual: true, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr"). + WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + want: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr"). + WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + }, + "IncludeManualFalse_FiltersManualXRs": { + includeManual: false, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr"). + WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + want: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "auto-xr"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + }, + "IncludeManualFalse_AllManualXRs_ReturnsEmpty": { + includeManual: false, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "manual-xr-1"). + WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "manual-xr-2"). + WithNamespace("default"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + want: []*un.Unstructured{}, + }, + "IncludeManualFalse_AllAutomaticXRs_ReturnsAll": { + includeManual: false, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "auto-xr-1"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr-2"). + WithNamespace("default"). + Build(), // No policy specified, defaults to Automatic + }, + want: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "auto-xr-1"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr-2"). + WithNamespace("default"). + Build(), + }, + }, + "IncludeManualFalse_EmptyList_ReturnsEmpty": { + includeManual: false, + xrs: []*un.Unstructured{}, + want: []*un.Unstructured{}, + }, + "IncludeManualFalse_V1PathManualXR_FiltersCorrectly": { + includeManual: false, + xrs: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "legacy-manual-xr"). + WithNestedField("Manual", "spec", "compositionUpdatePolicy"). + Build(), + tu.NewResource("example.org/v1", "XResource", "auto-xr"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + want: []*un.Unstructured{ + tu.NewResource("example.org/v1", "XResource", "auto-xr"). + WithNamespace("default"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := &DefaultCompDiffProcessor{ + config: ProcessorConfig{ + IncludeManual: tt.includeManual, + Logger: tu.TestLogger(t, false), + }, + } + + got := processor.filterXRsByUpdatePolicy(tt.xrs) + + if len(got) != len(tt.want) { + t.Errorf("filterXRsByUpdatePolicy() returned %d XRs, want %d", len(got), len(tt.want)) + } + + // Compare XR names to verify correct filtering + gotNames := make([]string, len(got)) + for i, xr := range got { + gotNames[i] = xr.GetName() + } + + wantNames := make([]string, len(tt.want)) + for i, xr := range tt.want { + wantNames[i] = xr.GetName() + } + + if diff := gcmp.Diff(wantNames, gotNames); diff != "" { + t.Errorf("filterXRsByUpdatePolicy() XR names mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestDefaultCompDiffProcessor_getCompositionUpdatePolicy(t *testing.T) { + tests := map[string]struct { + xr *un.Unstructured + want string + }{ + "V2Path_Manual": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: "Manual", + }, + "V2Path_Automatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Automatic", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: "Automatic", + }, + "V1Path_Manual": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Manual", "spec", "compositionUpdatePolicy"). + Build(), + want: "Manual", + }, + "V1Path_Automatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Automatic", "spec", "compositionUpdatePolicy"). + Build(), + want: "Automatic", + }, + "NoPolicy_DefaultsToAutomatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr").Build(), + want: "Automatic", + }, + "EmptyPolicy_DefaultsToAutomatic": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("", "spec", "compositionUpdatePolicy"). + Build(), + want: "Automatic", + }, + "V2PathTakesPrecedenceOverV1Path": { + xr: tu.NewResource("example.org/v1", "XResource", "test-xr"). + WithNestedField("Automatic", "spec", "compositionUpdatePolicy"). + WithNestedField("Manual", "spec", "crossplane", "compositionUpdatePolicy"). + Build(), + want: "Manual", // v2 path value should be used + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + processor := &DefaultCompDiffProcessor{ + config: ProcessorConfig{ + Logger: tu.TestLogger(t, false), + }, + } + + got := processor.getCompositionUpdatePolicy(tt.xr) + + if got != tt.want { + t.Errorf("getCompositionUpdatePolicy() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/diff/diffprocessor/diff_processor_test.go b/cmd/diff/diffprocessor/diff_processor_test.go index e4ae835..de7160f 100644 --- a/cmd/diff/diffprocessor/diff_processor_test.go +++ b/cmd/diff/diffprocessor/diff_processor_test.go @@ -1430,7 +1430,7 @@ func TestDefaultDiffProcessor_ProcessNestedXRs(t *testing.T) { }, Spec: pkgv1.FunctionSpec{ PackageSpec: pkgv1.PackageSpec{ - Package: "xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.11.0", + Package: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", }, }, }, @@ -1520,7 +1520,7 @@ func TestDefaultDiffProcessor_ProcessNestedXRs(t *testing.T) { }, Spec: pkgv1.FunctionSpec{ PackageSpec: pkgv1.PackageSpec{ - Package: "xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.11.0", + Package: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.11.0", }, }, }, diff --git a/cmd/diff/diffprocessor/processor_config.go b/cmd/diff/diffprocessor/processor_config.go index eb040f7..4bf60c8 100644 --- a/cmd/diff/diffprocessor/processor_config.go +++ b/cmd/diff/diffprocessor/processor_config.go @@ -24,6 +24,9 @@ type ProcessorConfig struct { // MaxNestedDepth is the maximum depth for recursive nested XR processing MaxNestedDepth int + // IncludeManual determines whether to include XRs with Manual update policy in composition diffs + IncludeManual bool + // Logger is the logger to use Logger logging.Logger @@ -86,6 +89,13 @@ func WithMaxNestedDepth(depth int) ProcessorOption { } } +// WithIncludeManual sets whether to include XRs with Manual update policy in composition diffs. +func WithIncludeManual(includeManual bool) ProcessorOption { + return func(config *ProcessorConfig) { + config.IncludeManual = includeManual + } +} + // WithLogger sets the logger for the processor. func WithLogger(logger logging.Logger) ProcessorOption { return func(config *ProcessorConfig) { diff --git a/cmd/diff/diffprocessor/schema_validator.go b/cmd/diff/diffprocessor/schema_validator.go index 100490e..250c8e3 100644 --- a/cmd/diff/diffprocessor/schema_validator.go +++ b/cmd/diff/diffprocessor/schema_validator.go @@ -105,8 +105,9 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U // Create a logger writer to capture output loggerWriter := loggerwriter.NewLoggerWriter(v.logger) - // Validate using the CRD schemas - // Use skipSuccessLogs=true to avoid cluttering the output with success messages + // Note: SchemaValidation applies defaults IN-PLACE to resources, so we must pass + // the original resources (not sanitized copies) to get defaults applied. + // We strip Crossplane-managed fields AFTER validation for cleaner error messages. v.logger.Debug("Performing schema validation", "resourceCount", len(resources)) err = validate.SchemaValidation(ctx, resources, v.schemaClient.GetAllCRDs(), true, true, loggerWriter) @@ -114,6 +115,12 @@ func (v *DefaultSchemaValidator) ValidateResources(ctx context.Context, xr *un.U return errors.Wrap(err, "schema validation failed") } + // Strip Crossplane-managed fields from resources after validation + // These fields are set by Crossplane controllers and may not be in all XRD schemas + for i := range resources { + resources[i] = v.stripCrossplaneManagedFields(resources[i]) + } + // Additionally validate resource scope constraints (namespace requirements and cross-namespace refs) expectedNamespace := xr.GetNamespace() isClaimRoot := v.defClient.IsClaimResource(ctx, xr) @@ -235,3 +242,21 @@ func (v *DefaultSchemaValidator) ValidateScopeConstraints(ctx context.Context, r return nil } + +// stripCrossplaneManagedFields creates a copy of the resource with Crossplane-managed fields removed +// These fields are set by Crossplane controllers and may not be present in the CRD schema. +func (v *DefaultSchemaValidator) stripCrossplaneManagedFields(resource *un.Unstructured) *un.Unstructured { + // Create a deep copy to avoid modifying the original + sanitized := resource.DeepCopy() + + // Remove compositionRevisionRef from spec.crossplane if it exists + // This field is set automatically by Crossplane and may not be in all XRD schemas + crossplane, found, err := un.NestedMap(sanitized.Object, "spec", "crossplane") + if err == nil && found { + delete(crossplane, "compositionRevisionRef") + // Set the modified crossplane map back + _ = un.SetNestedMap(sanitized.Object, crossplane, "spec", "crossplane") + } + + return sanitized +} diff --git a/cmd/diff/diffprocessor/schema_validator_test.go b/cmd/diff/diffprocessor/schema_validator_test.go index 213f5c7..3c8a4cb 100644 --- a/cmd/diff/diffprocessor/schema_validator_test.go +++ b/cmd/diff/diffprocessor/schema_validator_test.go @@ -414,6 +414,126 @@ func (c *xrdCountingClient) GetXRDs(ctx context.Context) ([]*un.Unstructured, er return c.MockDefinitionClient.GetXRDs(ctx) } +func TestDefaultSchemaValidator_ValidateResources_AppliesDefaults(t *testing.T) { + ctx := t.Context() + + // Create a simple managed resource + managedResource := tu.NewResource("provider.example.org/v1", "ManagedResource", "test-managed"). + InNamespace("default"). + WithCompositeOwner("test-xr"). + WithCompositionResourceName("managed-resource"). + WithSpecField("field", "value"). + BuildUComposed() + + // Manually add compositionRevisionRef to spec.crossplane to ensure validation can handle it + _ = un.SetNestedMap(managedResource.Object, map[string]interface{}{ + "compositionRevisionRef": map[string]interface{}{ + "name": "some-revision-abc123", + }, + }, "spec", "crossplane") + + // Create XR + xr := tu.NewResource("example.org/v2", "XCompositeResource", "test-xr"). + InNamespace("default"). + WithSpecField("field", "value"). + Build() + + // Create CRD with defaults for the managed resource using OpenAPIV3Schema + managedCRD := makeCRD("managedresources.provider.example.org", "ManagedResource", "provider.example.org", "v1") + // Set a permissive schema with defaults + managedCRD.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties = map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + XPreserveUnknownFields: func() *bool { b := true; return &b }(), // Allow all fields + Properties: map[string]extv1.JSONSchemaProps{ + "deletionPolicy": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"Delete"`)}, + }, + "managementPolicies": { + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + Default: &extv1.JSON{Raw: []byte(`["*"]`)}, + }, + "providerConfigRef": { + Type: "object", + XPreserveUnknownFields: func() *bool { b := true; return &b }(), + Default: &extv1.JSON{Raw: []byte(`{"name":"default"}`)}, + }, + }, + }, + } + + xrCRD := makeCRD("xcompositeresources.example.org", "XCompositeResource", "example.org", "v2") + preserveUnknown := true + specProps := xrCRD.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] + specProps.XPreserveUnknownFields = &preserveUnknown + xrCRD.Spec.Versions[0].Schema.OpenAPIV3Schema.Properties["spec"] = specProps + + schemaClient := tu.NewMockSchemaClient(). + WithFoundCRDs(map[schema.GroupKind]*extv1.CustomResourceDefinition{ + {Group: "provider.example.org", Kind: "ManagedResource"}: managedCRD, + {Group: "example.org", Kind: "XCompositeResource"}: xrCRD, + }). + WithAllResourcesRequiringCRDs(). + WithCachingBehavior(). + Build() + + defClient := tu.NewMockDefinitionClient().Build() + logger := tu.TestLogger(t, false) + + validator := NewSchemaValidator(schemaClient, defClient, logger) + + // Verify compositionRevisionRef exists before validation + crossplane, found, _ := un.NestedMap(managedResource.Object, "spec", "crossplane") + if !found || crossplane["compositionRevisionRef"] == nil { + t.Fatal("Test setup failed: compositionRevisionRef not found in managed resource before validation") + } + + // Call ValidateResources + // This should succeed even with compositionRevisionRef present because the validator + // strips Crossplane-managed fields internally before scope validation + err := validator.ValidateResources(ctx, xr, []cpd.Unstructured{*managedResource}) + if err != nil { + t.Fatalf("ValidateResources() unexpected error: %v", err) + } + + // Verify defaults were applied to the ORIGINAL resource + // The defaults are applied in-place by validate.SchemaValidation, so they persist + deletionPolicy, found, err := un.NestedString(managedResource.Object, "spec", "deletionPolicy") + if err != nil { + t.Fatalf("Failed to get deletionPolicy: %v", err) + } + + if !found || deletionPolicy != "Delete" { + t.Errorf("Expected deletionPolicy default 'Delete' to be applied, got found=%v, value=%q", found, deletionPolicy) + } + + managementPolicies, found, err := un.NestedStringSlice(managedResource.Object, "spec", "managementPolicies") + if err != nil { + t.Fatalf("Failed to get managementPolicies: %v", err) + } + + if !found || len(managementPolicies) != 1 || managementPolicies[0] != "*" { + t.Errorf("Expected managementPolicies default ['*'] to be applied, got found=%v, value=%v", found, managementPolicies) + } + + providerConfigRef, found, err := un.NestedMap(managedResource.Object, "spec", "providerConfigRef") + if err != nil { + t.Fatalf("Failed to get providerConfigRef: %v", err) + } + + if !found || providerConfigRef["name"] != "default" { + t.Errorf("Expected providerConfigRef.name default 'default' to be applied, got found=%v, value=%v", found, providerConfigRef) + } + + // Note: We do NOT verify that compositionRevisionRef is stripped from the original resource, + // because the stripping only happens on temporary copies used for scope validation. + // The compositionRevisionRef remains in the original resource, which is correct behavior. +} + func TestDefaultSchemaValidator_ValidateScopeConstraints(t *testing.T) { ctx := t.Context() diff --git a/cmd/diff/main_test.go b/cmd/diff/main_test.go index d2b5229..f6b8102 100644 --- a/cmd/diff/main_test.go +++ b/cmd/diff/main_test.go @@ -28,6 +28,7 @@ func cleanupFunctionContainers() { // Use exec.CommandContext to run docker command cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "-q", "--filter", "name=-it$") + output, err := cmd.Output() if err != nil { // Docker might not be available or no containers found - that's okay diff --git a/cmd/diff/testdata/comp/resources/composition-revision-v1.yaml b/cmd/diff/testdata/comp/resources/composition-revision-v1.yaml new file mode 100644 index 0000000..59a3803 --- /dev/null +++ b/cmd/diff/testdata/comp/resources/composition-revision-v1.yaml @@ -0,0 +1,37 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xnopresources.diff.example.org-abc123 + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + crossplane.io/composition-hash: abc123 +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + resourceTier: basic + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/comp/resources/existing-downstream-manual.yaml b/cmd/diff/testdata/comp/resources/existing-downstream-manual.yaml new file mode 100644 index 0000000..c69b08d --- /dev/null +++ b/cmd/diff/testdata/comp/resources/existing-downstream-manual.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: manual-resource + namespace: default + labels: + crossplane.io/composite: manual-resource + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource +spec: + forProvider: + configData: manual-existing-value + resourceTier: basic diff --git a/cmd/diff/testdata/comp/resources/existing-xr-manual.yaml b/cmd/diff/testdata/comp/resources/existing-xr-manual.yaml new file mode 100644 index 0000000..09a2a5c --- /dev/null +++ b/cmd/diff/testdata/comp/resources/existing-xr-manual.yaml @@ -0,0 +1,13 @@ +apiVersion: ns.diff.example.org/v1alpha1 +kind: XNopResource +metadata: + name: manual-resource + namespace: default +spec: + coolField: manual-existing-value + crossplane: + compositionRef: + name: xnopresources.diff.example.org + compositionRevisionRef: + name: xnopresources.diff.example.org-abc123 + compositionUpdatePolicy: Manual diff --git a/cmd/diff/testdata/comp/resources/functions.yaml b/cmd/diff/testdata/comp/resources/functions.yaml index 7f7fdef..aafe31f 100644 --- a/cmd/diff/testdata/comp/resources/functions.yaml +++ b/cmd/diff/testdata/comp/resources/functions.yaml @@ -5,9 +5,7 @@ # The 'runtime-docker-name' annotation enables container reuse across multiple test cases. # The 'runtime-docker-cleanup: Orphan' annotation leaves containers running after render completes, # allowing subsequent tests to reuse them instead of creating new ones. -# -# IMPORTANT: These containers will persist after test runs. Clean them up manually with: -# docker rm -f $(docker ps -a -q --filter "name=function-*-it") +# Containers are automatically cleaned up after the test suite completes via TestMain. apiVersion: pkg.crossplane.io/v1beta1 kind: Function metadata: diff --git a/cmd/diff/testdata/diff/crds/xnopresource-legacycluster-crd.yaml b/cmd/diff/testdata/diff/crds/xnopresource-legacycluster-crd.yaml index aea2031..eb8f837 100644 --- a/cmd/diff/testdata/diff/crds/xnopresource-legacycluster-crd.yaml +++ b/cmd/diff/testdata/diff/crds/xnopresource-legacycluster-crd.yaml @@ -30,6 +30,11 @@ spec: properties: name: type: string + compositionRevisionRef: + type: object + properties: + name: + type: string compositionSelector: type: object properties: diff --git a/cmd/diff/testdata/diff/crds/xnopresource-ns-crd.yaml b/cmd/diff/testdata/diff/crds/xnopresource-ns-crd.yaml index a2838a3..e21ac4d 100644 --- a/cmd/diff/testdata/diff/crds/xnopresource-ns-crd.yaml +++ b/cmd/diff/testdata/diff/crds/xnopresource-ns-crd.yaml @@ -34,6 +34,11 @@ spec: properties: name: type: string + compositionRevisionRef: + type: object + properties: + name: + type: string compositionSelector: type: object properties: diff --git a/cmd/diff/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml b/cmd/diff/testdata/diff/modified-legacy-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 0000000..6b779e8 --- /dev/null +++ b/cmd/diff/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-def456 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/diff/testdata/diff/modified-xr-automatic.yaml b/cmd/diff/testdata/diff/modified-xr-automatic.yaml new file mode 100644 index 0000000..b0e5919 --- /dev/null +++ b/cmd/diff/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/diff/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml b/cmd/diff/testdata/diff/modified-xr-manual-upgrade-to-v2.yaml new file mode 100644 index 0000000..1fc8d26 --- /dev/null +++ b/cmd/diff/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-def456 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/diff/testdata/diff/modified-xr-manual-v1.yaml b/cmd/diff/testdata/diff/modified-xr-manual-v1.yaml new file mode 100644 index 0000000..ff8ddb5 --- /dev/null +++ b/cmd/diff/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-abc123 + compositionUpdatePolicy: Manual + coolField: modified-value diff --git a/cmd/diff/testdata/diff/modified-xr-switch-to-automatic.yaml b/cmd/diff/testdata/diff/modified-xr-switch-to-automatic.yaml new file mode 100644 index 0000000..8d72154 --- /dev/null +++ b/cmd/diff/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/diff/testdata/diff/new-xr-manual-no-ref.yaml b/cmd/diff/testdata/diff/new-xr-manual-no-ref.yaml new file mode 100644 index 0000000..2155782 --- /dev/null +++ b/cmd/diff/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/diff/testdata/diff/resources/claim-composition-revision.yaml b/cmd/diff/testdata/diff/resources/claim-composition-revision.yaml new file mode 100644 index 0000000..b17ed23 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/claim-composition-revision.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: claim-composition-default + labels: + crossplane.io/composition-name: claim-composition + crossplane.io/composition-hash: default +spec: + revision: 1 + 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/diff/testdata/diff/resources/composition-revision-default.yaml b/cmd/diff/testdata/diff/resources/composition-revision-default.yaml new file mode 100644 index 0000000..81bc63a --- /dev/null +++ b/cmd/diff/testdata/diff/resources/composition-revision-default.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xnopresources.diff.example.org-default + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + crossplane.io/composition-hash: default +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-resources + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: {{ .observed.composite.resource.metadata.name }} + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/diff/resources/composition-revision-multi-functions.yaml b/cmd/diff/testdata/diff/resources/composition-revision-multi-functions.yaml new file mode 100644 index 0000000..429370f --- /dev/null +++ b/cmd/diff/testdata/diff/resources/composition-revision-multi-functions.yaml @@ -0,0 +1,84 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xconcurrenttests.diff.example.org-default + labels: + crossplane.io/composition-name: xconcurrenttests.diff.example.org + crossplane.io/composition-hash: default +spec: + revision: 1 + 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 diff --git a/cmd/diff/testdata/diff/resources/composition-revision-v1.yaml b/cmd/diff/testdata/diff/resources/composition-revision-v1.yaml new file mode 100644 index 0000000..0d14221 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/composition-revision-v1.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xnopresources.diff.example.org-abc123 + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + crossplane.io/composition-hash: abc123 +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/diff/testdata/diff/resources/composition-revision-v2.yaml b/cmd/diff/testdata/diff/resources/composition-revision-v2.yaml new file mode 100644 index 0000000..0e58510 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/composition-revision-v2.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xnopresources.diff.example.org-def456 + labels: + crossplane.io/composition-name: xnopresources.diff.example.org + crossplane.io/composition-hash: def456 +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: v2-{{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/diff/resources/composition-v2.yaml b/cmd/diff/testdata/diff/resources/composition-v2.yaml new file mode 100644 index 0000000..17c012c --- /dev/null +++ b/cmd/diff/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/diff/testdata/diff/resources/existing-downstream-automatic.yaml b/cmd/diff/testdata/diff/resources/existing-downstream-automatic.yaml new file mode 100644 index 0000000..b463fd5 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/existing-downstream-automatic.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-automatic + namespace: default + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-automatic +spec: + forProvider: + configData: v1-existing-value diff --git a/cmd/diff/testdata/diff/resources/existing-downstream-manual-v1.yaml b/cmd/diff/testdata/diff/resources/existing-downstream-manual-v1.yaml new file mode 100644 index 0000000..62bd0b2 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/existing-downstream-manual-v1.yaml @@ -0,0 +1,12 @@ +apiVersion: ns.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-manual-v1 + namespace: default + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-manual-v1 +spec: + forProvider: + configData: v1-existing-value diff --git a/cmd/diff/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml b/cmd/diff/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml new file mode 100644 index 0000000..065e1c3 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/existing-legacy-downstream-manual-v1.yaml @@ -0,0 +1,11 @@ +apiVersion: legacycluster.nop.example.org/v1alpha1 +kind: XDownstreamResource +metadata: + name: test-legacy-manual-v1 + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: nop-resource + labels: + crossplane.io/composite: test-legacy-manual-v1 +spec: + forProvider: + configData: v1-existing-value diff --git a/cmd/diff/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml b/cmd/diff/testdata/diff/resources/existing-legacy-xr-manual-v1.yaml new file mode 100644 index 0000000..1c76cb1 --- /dev/null +++ b/cmd/diff/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-abc123 + compositionUpdatePolicy: Manual + coolField: existing-value diff --git a/cmd/diff/testdata/diff/resources/existing-xr-automatic.yaml b/cmd/diff/testdata/diff/resources/existing-xr-automatic.yaml new file mode 100644 index 0000000..832b454 --- /dev/null +++ b/cmd/diff/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-abc123 # Currently on v1, but should auto-upgrade + compositionUpdatePolicy: Automatic + coolField: existing-value diff --git a/cmd/diff/testdata/diff/resources/existing-xr-manual-v1.yaml b/cmd/diff/testdata/diff/resources/existing-xr-manual-v1.yaml new file mode 100644 index 0000000..5f98161 --- /dev/null +++ b/cmd/diff/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-abc123 + compositionUpdatePolicy: Manual + coolField: existing-value diff --git a/cmd/diff/testdata/diff/resources/functions.yaml b/cmd/diff/testdata/diff/resources/functions.yaml index 7f7fdef..aafe31f 100644 --- a/cmd/diff/testdata/diff/resources/functions.yaml +++ b/cmd/diff/testdata/diff/resources/functions.yaml @@ -5,9 +5,7 @@ # The 'runtime-docker-name' annotation enables container reuse across multiple test cases. # The 'runtime-docker-cleanup: Orphan' annotation leaves containers running after render completes, # allowing subsequent tests to reuse them instead of creating new ones. -# -# IMPORTANT: These containers will persist after test runs. Clean them up manually with: -# docker rm -f $(docker ps -a -q --filter "name=function-*-it") +# Containers are automatically cleaned up after the test suite completes via TestMain. apiVersion: pkg.crossplane.io/v1beta1 kind: Function metadata: diff --git a/cmd/diff/testdata/diff/resources/legacy-composition-revision-v1.yaml b/cmd/diff/testdata/diff/resources/legacy-composition-revision-v1.yaml new file mode 100644 index 0000000..2da2bd8 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/legacy-composition-revision-v1.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xlegacynopresources.diff.example.org-abc123 + labels: + crossplane.io/composition-name: xlegacynopresources.diff.example.org + crossplane.io/composition-hash: abc123 +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/diff/testdata/diff/resources/legacy-composition-revision-v2.yaml b/cmd/diff/testdata/diff/resources/legacy-composition-revision-v2.yaml new file mode 100644 index 0000000..1ac097c --- /dev/null +++ b/cmd/diff/testdata/diff/resources/legacy-composition-revision-v2.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xlegacynopresources.diff.example.org-def456 + labels: + crossplane.io/composition-name: xlegacynopresources.diff.example.org + crossplane.io/composition-hash: def456 +spec: + revision: 2 + 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/diff/testdata/diff/resources/legacy-composition-v2.yaml b/cmd/diff/testdata/diff/resources/legacy-composition-v2.yaml new file mode 100644 index 0000000..fc492bb --- /dev/null +++ b/cmd/diff/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/diff/testdata/diff/resources/nested/child-composition-revision.yaml b/cmd/diff/testdata/diff/resources/nested/child-composition-revision.yaml new file mode 100644 index 0000000..2abc9af --- /dev/null +++ b/cmd/diff/testdata/diff/resources/nested/child-composition-revision.yaml @@ -0,0 +1,37 @@ +# Child composition revision +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xchildresources.nested.example.org-default + labels: + crossplane.io/composition-name: xchildresources.nested.example.org + crossplane.io/composition-hash: default +spec: + revision: 1 + 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/diff/testdata/diff/resources/nested/parent-composition-revision.yaml b/cmd/diff/testdata/diff/resources/nested/parent-composition-revision.yaml new file mode 100644 index 0000000..1dc1dc4 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/nested/parent-composition-revision.yaml @@ -0,0 +1,36 @@ +# Parent composition revision +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: xparentresources.nested.example.org-default + labels: + crossplane.io/composition-name: xparentresources.nested.example.org + crossplane.io/composition-hash: default +spec: + revision: 1 + 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/diff/testdata/diff/resources/removal-test-cluster-composition-revision.yaml b/cmd/diff/testdata/diff/resources/removal-test-cluster-composition-revision.yaml new file mode 100644 index 0000000..6a40d1c --- /dev/null +++ b/cmd/diff/testdata/diff/resources/removal-test-cluster-composition-revision.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: removal-test-composition-default + labels: + crossplane.io/composition-name: removal-test-composition + crossplane.io/composition-hash: default +spec: + revision: 1 + 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/diff/testdata/diff/resources/removal-test-composition-revision.yaml b/cmd/diff/testdata/diff/resources/removal-test-composition-revision.yaml new file mode 100644 index 0000000..300adf7 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/removal-test-composition-revision.yaml @@ -0,0 +1,36 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: removal-test-composition-default + labels: + crossplane.io/composition-name: removal-test-composition + crossplane.io/composition-hash: default +spec: + revision: 1 + compositeTypeRef: + apiVersion: ns.diff.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: generate-remaining-resource + functionRef: + name: function-go-templating + input: + apiVersion: template.fn.crossplane.io/v1beta1 + kind: GoTemplate + source: Inline + inline: + template: | + apiVersion: ns.nop.example.org/v1alpha1 + kind: XDownstreamResource + metadata: + name: resource-to-be-kept + namespace: {{ .observed.composite.resource.metadata.namespace }} + annotations: + gotemplating.fn.crossplane.io/composition-resource-name: resource1 + spec: + forProvider: + configData: {{ .observed.composite.resource.spec.coolField }} + - step: automatically-detect-ready-composed-resources + functionRef: + name: function-auto-ready diff --git a/cmd/diff/testdata/diff/resources/removal-test-legacy-composition-revision.yaml b/cmd/diff/testdata/diff/resources/removal-test-legacy-composition-revision.yaml new file mode 100644 index 0000000..1be9693 --- /dev/null +++ b/cmd/diff/testdata/diff/resources/removal-test-legacy-composition-revision.yaml @@ -0,0 +1,35 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositionRevision +metadata: + name: removal-test-composition-default + labels: + crossplane.io/composition-name: removal-test-composition + crossplane.io/composition-hash: default +spec: + revision: 1 + 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/diff/testdata/diff/resources/xrd.yaml b/cmd/diff/testdata/diff/resources/xrd.yaml index b4b5706..88a6d15 100644 --- a/cmd/diff/testdata/diff/resources/xrd.yaml +++ b/cmd/diff/testdata/diff/resources/xrd.yaml @@ -24,6 +24,31 @@ spec: type: string environment: type: string + 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: diff --git a/cmd/diff/testutils/mock_builder.go b/cmd/diff/testutils/mock_builder.go index 61281a1..5c76893 100644 --- a/cmd/diff/testutils/mock_builder.go +++ b/cmd/diff/testutils/mock_builder.go @@ -127,7 +127,11 @@ func (b *MockResourceClientBuilder) WithListResources(fn func(context.Context, s // WithEmptyListResources mimics an empty but successful response. func (b *MockResourceClientBuilder) WithEmptyListResources() *MockResourceClientBuilder { - return b.WithListResources(func(context.Context, schema.GroupVersionKind, string) ([]*un.Unstructured, error) { + b.WithListResources(func(context.Context, schema.GroupVersionKind, string) ([]*un.Unstructured, error) { + return []*un.Unstructured{}, nil + }) + // Also set up GetResourcesByLabel to return empty (for composition revision queries) + return b.WithGetResourcesByLabel(func(context.Context, schema.GroupVersionKind, string, metav1.LabelSelector) ([]*un.Unstructured, error) { return []*un.Unstructured{}, nil }) } @@ -145,15 +149,40 @@ func (b *MockResourceClientBuilder) WithGetResourcesByLabel(fn func(context.Cont return b } -// WithResourcesFoundByLabel sets GetResourcesByLabel to return resources for a specific label. +// WithResourcesFoundByLabel sets GetResourcesByLabel to return resources for a specific label value. +// This method is stateful - multiple calls accumulate mappings for different label values. +// The label parameter specifies which label key to match on (e.g., "crossplane.io/composition-name"). func (b *MockResourceClientBuilder) WithResourcesFoundByLabel(resources []*un.Unstructured, label, value string) *MockResourceClientBuilder { - return b.WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, selector metav1.LabelSelector) ([]*un.Unstructured, error) { - // Check if the selector matches our expected label + // If we don't have an existing GetResourcesByLabel function, create a new one + if b.mock.GetResourcesByLabelFn == nil { + // Create a map to store resources by label value + resourcesByValue := make(map[string][]*un.Unstructured) + resourcesByValue[value] = resources + + return b.WithGetResourcesByLabel(func(_ context.Context, _ schema.GroupVersionKind, _ string, selector metav1.LabelSelector) ([]*un.Unstructured, error) { + // Check if the selector matches our expected label + if labelValue, exists := selector.MatchLabels[label]; exists { + if res, found := resourcesByValue[labelValue]; found { + return res, nil + } + } + + return []*un.Unstructured{}, nil + }) + } + + // If we already have a GetResourcesByLabel function, we need to wrap it + // This allows multiple calls to accumulate state + originalFn := b.mock.GetResourcesByLabelFn + + return b.WithGetResourcesByLabel(func(ctx context.Context, gvk schema.GroupVersionKind, namespace string, selector metav1.LabelSelector) ([]*un.Unstructured, error) { + // Check if the selector matches our new label/value pair if labelValue, exists := selector.MatchLabels[label]; exists && labelValue == value { return resources, nil } - return []*un.Unstructured{}, nil + // Fall through to the original function + return originalFn(ctx, gvk, namespace, selector) }) } @@ -1387,6 +1416,13 @@ func (b *ResourceBuilder) WithNamespace(namespace string) *ResourceBuilder { return b } +// WithNestedField sets a field at an arbitrary nested path in the resource. +// The path is a sequence of field names to traverse (e.g., "spec", "crossplane", "compositionUpdatePolicy"). +func (b *ResourceBuilder) WithNestedField(value interface{}, fields ...string) *ResourceBuilder { + _ = un.SetNestedField(b.resource.Object, value, fields...) + return b +} + // Build returns the built unstructured resource. func (b *ResourceBuilder) Build() *un.Unstructured { return b.resource.DeepCopy() diff --git a/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/functions.yaml b/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/functions.yaml index 49f9bcb..e724db6 100644 --- a/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/functions.yaml +++ b/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/functions.yaml @@ -3,6 +3,7 @@ kind: Function metadata: name: function-patch-and-transform spec: + # the earliest version of this from crossplane.io is 0.8.2 so stay at upbound (for pinning) package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0 --- apiVersion: pkg.crossplane.io/v1beta1 @@ -10,4 +11,5 @@ kind: Function metadata: name: function-auto-ready spec: + # the earliest version of this from crossplane.io is 0.4.2 so stay at upbound (for pinning) package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.3.0 diff --git a/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/provider.yaml b/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/provider.yaml index 2aef682..a989d63 100644 --- a/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/provider.yaml +++ b/test/e2e/manifests/beta/diff/release-1.20/v1-claim/setup/provider.yaml @@ -3,5 +3,5 @@ kind: Provider metadata: name: provider-nop spec: - package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.3.0 + package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.3.1 ignoreCrossplaneConstraints: true diff --git a/test/e2e/manifests/beta/diff/release-1.20/v1/setup/functions.yaml b/test/e2e/manifests/beta/diff/release-1.20/v1/setup/functions.yaml index 49f9bcb..e724db6 100644 --- a/test/e2e/manifests/beta/diff/release-1.20/v1/setup/functions.yaml +++ b/test/e2e/manifests/beta/diff/release-1.20/v1/setup/functions.yaml @@ -3,6 +3,7 @@ kind: Function metadata: name: function-patch-and-transform spec: + # the earliest version of this from crossplane.io is 0.8.2 so stay at upbound (for pinning) package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0 --- apiVersion: pkg.crossplane.io/v1beta1 @@ -10,4 +11,5 @@ kind: Function metadata: name: function-auto-ready spec: + # the earliest version of this from crossplane.io is 0.4.2 so stay at upbound (for pinning) package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.3.0 diff --git a/test/e2e/manifests/beta/diff/release-1.20/v1/setup/provider.yaml b/test/e2e/manifests/beta/diff/release-1.20/v1/setup/provider.yaml index 2aef682..a989d63 100644 --- a/test/e2e/manifests/beta/diff/release-1.20/v1/setup/provider.yaml +++ b/test/e2e/manifests/beta/diff/release-1.20/v1/setup/provider.yaml @@ -3,5 +3,5 @@ kind: Provider metadata: name: provider-nop spec: - package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.3.0 + package: xpkg.upbound.io/crossplane-contrib/provider-nop:v0.3.1 ignoreCrossplaneConstraints: true