Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 273 additions & 0 deletions .requirements/20260518T224830Z_comp_resource_filter/REQUIREMENTS.md

Large diffs are not rendered by default.

15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ crossplane-diff comp updated-composition.yaml --context production
# Show impact only on XRs in a specific namespace
crossplane-diff comp updated-composition.yaml -n production

# Limit impact analysis to specific composites — useful for fast PR-time validation
# against a representative subset of XRs/Claims, or for debugging against a single composite.
# Format is [namespace/]name; bare name means cluster-scoped (v1 XRs and v2 cluster-scoped XRs).
crossplane-diff comp updated-composition.yaml --resource=default/my-claim
crossplane-diff comp updated-composition.yaml --resource=default/xr-1,default/xr-2
# Note: --resource cannot be combined with --namespace. Composites with Manual update policy
# are surfaced with status "filtered_by_policy" unless --include-manual is also passed.

# Include XRs with Manual update policy (pinned revisions)
crossplane-diff comp updated-composition.yaml --include-manual

Expand Down Expand Up @@ -202,6 +210,13 @@ Flags:
--eventual-state Show eventual state after all reconciliation cycles
complete. Useful with function-sequencer which hides
later stage resources until earlier stages become Ready.
--resource=STRING,... Limit impact analysis to specific composites in
[namespace/]name format. Repeatable or comma-separated.
Bare name means cluster-scoped. Mutually exclusive with
--namespace. Composites matched by --resource but excluded
by the update-policy filter are reported in the impact
analysis with status "filtered_by_policy" (use
--include-manual to evaluate them instead).
```

**Note**: The `diff` subcommand is deprecated. Use `xr` instead.
Expand Down
118 changes: 118 additions & 0 deletions cmd/diff/client/crossplane/composition_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import (

"github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/core"
"github.com/crossplane-contrib/crossplane-diff/cmd/diff/client/kubernetes"
dtypes "github.com/crossplane-contrib/crossplane-diff/cmd/diff/types"
apierrors "k8s.io/apimachinery/pkg/api/errors"
un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
Expand All @@ -18,6 +20,8 @@ import (
)

// CompositionClient handles operations related to Compositions.
//
//nolint:interfacebloat // Composition operations are cohesive; splitting would fragment the API.
type CompositionClient interface {
core.Initializable

Expand All @@ -32,6 +36,14 @@ type CompositionClient interface {

// FindCompositesUsingComposition finds all composites (XRs and Claims) that use the specified composition
FindCompositesUsingComposition(ctx context.Context, compositionName string, namespace string) ([]*un.Unstructured, error)

// GetCompositesByName fetches the user-named composites (XRs or Claims) for a composition.
// For each ResourceRef, the XR GVK derived from comp.Spec.CompositeTypeRef is tried first, then the
// claim GVK derived from the XRD if defined. A ref is "matched" only when (a) the cluster lookup
// succeeds AND (b) the resource references this composition by name. Refs whose lookups all 404, or
// that exist but reference a different composition, are returned in `unmatched` (not as an error).
// NotFound responses are tolerated; non-NotFound transport errors propagate.
GetCompositesByName(ctx context.Context, comp *apiextensionsv1.Composition, refs []dtypes.ResourceRef) (matched []*un.Unstructured, unmatched []dtypes.ResourceRef, err error)
}

// DefaultCompositionClient implements CompositionClient.
Expand Down Expand Up @@ -822,3 +834,109 @@ func (c *DefaultCompositionClient) resourceUsesComposition(resource *un.Unstruct

return false
}

// GetCompositesByName fetches the user-named composites for a composition.
// See the CompositionClient interface for the full contract.
func (c *DefaultCompositionClient) GetCompositesByName(ctx context.Context, comp *apiextensionsv1.Composition, refs []dtypes.ResourceRef) ([]*un.Unstructured, []dtypes.ResourceRef, error) {
if len(refs) == 0 {
return nil, nil, nil
}

// Resolve XR GVK from the (possibly net-new) composition file. No cluster lookup needed.
gv, err := schema.ParseGroupVersion(comp.Spec.CompositeTypeRef.APIVersion)
if err != nil {
return nil, nil, errors.Wrapf(err, "cannot parse compositeTypeRef apiVersion %q", comp.Spec.CompositeTypeRef.APIVersion)
}

xrGVK := schema.GroupVersionKind{
Group: gv.Group,
Version: gv.Version,
Kind: comp.Spec.CompositeTypeRef.Kind,
}

// Resolve claim GVK best-effort. Missing XRD or no claimNames → claim lookups are skipped.
var claimGVK schema.GroupVersionKind

xrd, xrdErr := c.definitionClient.GetXRDForXR(ctx, xrGVK)

switch {
case xrdErr != nil:
c.logger.Debug("XRD lookup failed; skipping claim-GVK fallback for --resource lookups",
"xrGVK", xrGVK.String(), "error", xrdErr)
case xrd != nil:
gvk, err := c.getClaimTypeFromXRD(xrd)
if err != nil {
c.logger.Debug("could not extract claim type from XRD; skipping claim-GVK fallback",
"xrd", xrd.GetName(), "error", err)
} else {
claimGVK = gvk
}
}

var (
matched []*un.Unstructured
unmatched []dtypes.ResourceRef
)

for _, ref := range refs {
// Try XR-GVK first.
obj, err := c.resourceClient.GetResource(ctx, xrGVK, ref.Namespace, ref.Name)

switch {
case err == nil:
if c.resourceUsesComposition(obj, comp.GetName()) {
c.logger.Debug("matched ref via XR GVK",
"ref", ref.String(), "composition", comp.GetName())

matched = append(matched, obj)

continue
}

c.logger.Debug("ref exists as XR but does not reference this composition",
"ref", ref.String(), "composition", comp.GetName())

unmatched = append(unmatched, ref)

continue
case !apierrors.IsNotFound(err):
return nil, nil, errors.Wrapf(err, "cannot fetch composite %s as %s", ref.String(), xrGVK)
}

// XR-GVK was 404. Try claim GVK if available.
Comment on lines +899 to +906
if claimGVK.Empty() {
c.logger.Debug("ref not found as XR and no claim GVK to try",
"ref", ref.String())

unmatched = append(unmatched, ref)

continue
}

obj, err = c.resourceClient.GetResource(ctx, claimGVK, ref.Namespace, ref.Name)

switch {
case err == nil:
if c.resourceUsesComposition(obj, comp.GetName()) {
c.logger.Debug("matched ref via claim GVK",
"ref", ref.String(), "composition", comp.GetName())

matched = append(matched, obj)

continue
}

c.logger.Debug("ref exists as claim but does not reference this composition",
"ref", ref.String(), "composition", comp.GetName())

unmatched = append(unmatched, ref)
case apierrors.IsNotFound(err):
c.logger.Debug("ref not found as XR or claim", "ref", ref.String())
unmatched = append(unmatched, ref)
default:
return nil, nil, errors.Wrapf(err, "cannot fetch composite %s as %s", ref.String(), claimGVK)
}
}

return matched, unmatched, nil
}
194 changes: 194 additions & 0 deletions cmd/diff/client/crossplane/composition_client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import (
"testing"

tu "github.com/crossplane-contrib/crossplane-diff/cmd/diff/testutils"
dtypes "github.com/crossplane-contrib/crossplane-diff/cmd/diff/types"
"github.com/google/go-cmp/cmp"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
Expand Down Expand Up @@ -1809,3 +1811,195 @@ func TestDefaultCompositionClient_getClaimTypeFromXRD(t *testing.T) {
})
}
}

func TestDefaultCompositionClient_GetCompositesByName(t *testing.T) {
ctx := t.Context()

// Composition targeting (example.org/v1, XBucket).
comp := tu.NewComposition("test-comp").
WithCompositeTypeRef("example.org/v1", "XBucket").
Build()

// XRD with claim "Bucket" (so claim GVK is example.org/v1, Bucket).
xrd := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket").
WithPlural("xbuckets").
WithClaimNames("Bucket", "buckets").
WithVersion("v1", true, true).
BuildAsUnstructured()

// XRD with no claim type (just XR).
xrdNoClaim := tu.NewXRD("xbuckets.example.org", "example.org", "XBucket").
WithPlural("xbuckets").
WithVersion("v1", true, true).
BuildAsUnstructured()

// Cluster-scoped XR named "xr-cluster", refs comp via v1 path.
xrCluster := tu.NewResource("example.org/v1", "XBucket", "xr-cluster").
WithSpecField("compositionRef", map[string]any{"name": "test-comp"}).
Build()

// Namespaced claim "claim-ns/claim-1", refs comp via v1 path.
claim := tu.NewResource("example.org/v1", "Bucket", "claim-1").
InNamespace("claim-ns").
WithSpecField("compositionRef", map[string]any{"name": "test-comp"}).
Build()

// XR that uses a DIFFERENT composition.
xrWrongComp := tu.NewResource("example.org/v1", "XBucket", "xr-wrong").
WithSpecField("compositionRef", map[string]any{"name": "some-other-comp"}).
Build()

xrGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "XBucket"}
claimGVK := schema.GroupVersionKind{Group: "example.org", Version: "v1", Kind: "Bucket"}

tests := map[string]struct {
reason string
mockResource *tu.MockResourceClient
mockDef *tu.MockDefinitionClient
comp *apiextensionsv1.Composition
refs []dtypes.ResourceRef
wantMatched []string // names of matched composites
wantUnmatched []dtypes.ResourceRef
wantErr bool
}{
"XRGVKHit_ClusterScoped": {
reason: "Cluster-scoped XR with matching composition is matched via XR-GVK lookup",
mockResource: tu.NewMockResourceClient().
WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) {
if gvk == xrGVK && name == "xr-cluster" {
return xrCluster, nil
}

return nil, apierrors.NewNotFound(schema.GroupResource{Group: gvk.Group, Resource: "xbuckets"}, name)
}).
Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Name: "xr-cluster"}},
wantMatched: []string{"xr-cluster"},
},
"ClaimGVKHit_NamespacedClaim": {
reason: "Claim found via claim-GVK fallback when XR-GVK 404s",
mockResource: tu.NewMockResourceClient().
WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, ns, name string) (*un.Unstructured, error) {
if gvk == claimGVK && ns == "claim-ns" && name == "claim-1" {
return claim, nil
}

return nil, apierrors.NewNotFound(schema.GroupResource{Group: gvk.Group, Resource: "x"}, name)
}).
Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Namespace: "claim-ns", Name: "claim-1"}},
wantMatched: []string{"claim-1"},
},
"BothLookupsNotFound_Unmatched": {
reason: "Returned in unmatched when neither XR-GVK nor claim-GVK lookup succeeds",
mockResource: tu.NewMockResourceClient().WithResourceNotFound().Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Namespace: "claim-ns", Name: "ghost"}},
wantUnmatched: []dtypes.ResourceRef{{Namespace: "claim-ns", Name: "ghost"}},
},
"FoundButUsesDifferentComposition_Unmatched": {
reason: "Resource exists but its compositionRef points to a different composition",
mockResource: tu.NewMockResourceClient().
WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) {
if gvk == xrGVK && name == "xr-wrong" {
return xrWrongComp, nil
}

return nil, apierrors.NewNotFound(schema.GroupResource{Group: gvk.Group, Resource: "x"}, name)
}).
Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Name: "xr-wrong"}},
wantUnmatched: []dtypes.ResourceRef{{Name: "xr-wrong"}},
},
"TransportErrorPropagated": {
reason: "Non-NotFound errors from GetResource propagate up",
mockResource: tu.NewMockResourceClient().
WithGetResource(func(context.Context, schema.GroupVersionKind, string, string) (*un.Unstructured, error) {
return nil, errors.New("connection refused")
}).
Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrd).Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Name: "x"}},
wantErr: true,
},
"NoClaimType_OnlyXRLookupAttempted": {
reason: "When XRD has no claimNames, claim-GVK lookup is skipped without crashing",
mockResource: tu.NewMockResourceClient().
WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) {
if gvk == xrGVK && name == "xr-cluster" {
return xrCluster, nil
}

return nil, apierrors.NewNotFound(schema.GroupResource{Group: gvk.Group, Resource: "x"}, name)
}).
Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXR(xrdNoClaim).Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Name: "xr-cluster"}, {Namespace: "x", Name: "ghost"}},
wantMatched: []string{"xr-cluster"},
wantUnmatched: []dtypes.ResourceRef{{Namespace: "x", Name: "ghost"}},
},
"XRDNotFound_OnlyXRLookupAttempted": {
reason: "When XRD itself is missing, claim-GVK lookup is skipped",
mockResource: tu.NewMockResourceClient().
WithGetResource(func(_ context.Context, gvk schema.GroupVersionKind, _, name string) (*un.Unstructured, error) {
if gvk == xrGVK && name == "xr-cluster" {
return xrCluster, nil
}

return nil, apierrors.NewNotFound(schema.GroupResource{Group: gvk.Group, Resource: "x"}, name)
}).
Build(),
mockDef: tu.NewMockDefinitionClient().WithXRDForXRNotFound().Build(),
comp: comp,
refs: []dtypes.ResourceRef{{Name: "xr-cluster"}},
wantMatched: []string{"xr-cluster"},
},
}

for name, tt := range tests {
t.Run(name, func(t *testing.T) {
c := &DefaultCompositionClient{
resourceClient: tt.mockResource,
definitionClient: tt.mockDef,
revisionClient: NewCompositionRevisionClient(tt.mockResource, tu.TestLogger(t, false)),
logger: tu.TestLogger(t, false),
}

matched, unmatched, err := c.GetCompositesByName(ctx, tt.comp, tt.refs)

if tt.wantErr {
if err == nil {
t.Fatalf("\n%s: expected error, got matched=%v unmatched=%v", tt.reason, matched, unmatched)
}

return
}

if err != nil {
t.Fatalf("\n%s: unexpected error: %v", tt.reason, err)
}

var gotMatchedNames []string
for _, m := range matched {
gotMatchedNames = append(gotMatchedNames, m.GetName())
}

if diff := cmp.Diff(tt.wantMatched, gotMatchedNames); diff != "" {
t.Errorf("\n%s: matched mismatch:\n%s", tt.reason, diff)
}

if diff := cmp.Diff(tt.wantUnmatched, unmatched); diff != "" {
t.Errorf("\n%s: unmatched mismatch:\n%s", tt.reason, diff)
}
})
}
}
Loading
Loading