From cd5fa8d041560b0c1256c5daa0215f3f40cd4069 Mon Sep 17 00:00:00 2001 From: Jakub Ramut Date: Tue, 28 Jul 2026 11:32:33 +0200 Subject: [PATCH 1/7] use legacy extra_resources protocol for Crossplane v1.15+ compatibility Signed-off-by: Jakub Ramut --- README.md | 10 ++++++++++ fn.go | 6 +++--- fn_test.go | 14 +++++++------- package/crossplane.yaml | 4 +--- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 964581a..a684091 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,16 @@ A function for selecting extra resources via [composition function][functions]s in [Go][go]. +> **Crossplane v1 compatibility branch.** This branch uses the legacy +> `extra_resources` protocol fields and therefore works on Crossplane +> **v1.15+ as well as v2.x** (Crossplane v2 still resolves the deprecated +> fields). Compared to `main` it does not declare the `required-resources` +> package capability. The `namespace` selector field is only honored by +> Crossplane v2.2+. On older Crossplane the namespace filter is ignored +> server-side - for namespaced kinds selected by labels this would match +> across all namespaces - so this build fails fast if `namespace` is set +> and the server does not advertise capabilities. + ## Using `function-extra-resources` Please see the example in `./example` diff --git a/fn.go b/fn.go index 2b5a947..0261cc4 100644 --- a/fn.go +++ b/fn.go @@ -61,13 +61,13 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) // function-extra-resources does not know if it has requested the resources already or not. // // If it has and these resources are now present, proceed with verification and conversion. - if req.RequiredResources == nil { + if req.ExtraResources == nil { //nolint:staticcheck // Deprecated field used intentionally for Crossplane v1.x compatibility. f.log.Debug("No extra resources present, exiting", "requirements", rsp.GetRequirements()) return rsp, nil } // Pull extra resources from the ExtraResources request field. - extraResources, err := request.GetRequiredResources(req) + extraResources, err := request.GetExtraResources(req) //nolint:staticcheck // Deprecated helper used intentionally for Crossplane v1.x compatibility. if err != nil { response.Fatal(rsp, errors.Errorf("fetching extra resources %T: %w", req, err)) return rsp, nil @@ -143,7 +143,7 @@ func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Require } } } - return &fnv1.Requirements{Resources: extraResources}, nil + return &fnv1.Requirements{ExtraResources: extraResources}, nil } // Verify Min/Max and sort extra resources by field path within a single kind. diff --git a/fn_test.go b/fn_test.go index dcd0007..91c448d 100644 --- a/fn_test.go +++ b/fn_test.go @@ -160,7 +160,7 @@ func TestRunFunction(t *testing.T) { Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ - Resources: map[string]*fnv1.ResourceSelector{ + ExtraResources: map[string]*fnv1.ResourceSelector{ "obj-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", @@ -244,7 +244,7 @@ func TestRunFunction(t *testing.T) { }`), }, }, - RequiredResources: map[string]*fnv1.Resources{ + ExtraResources: map[string]*fnv1.Resources{ "obj-0": { Items: []*fnv1.Resource{ { @@ -418,7 +418,7 @@ func TestRunFunction(t *testing.T) { Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ - Resources: map[string]*fnv1.ResourceSelector{ + ExtraResources: map[string]*fnv1.ResourceSelector{ "obj-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", @@ -555,7 +555,7 @@ func TestRunFunction(t *testing.T) { }`), }, }, - RequiredResources: map[string]*fnv1.Resources{ + ExtraResources: map[string]*fnv1.Resources{ "environment-config-0": { Items: []*fnv1.Resource{}, }, @@ -589,7 +589,7 @@ func TestRunFunction(t *testing.T) { }, }, Requirements: &fnv1.Requirements{ - Resources: map[string]*fnv1.ResourceSelector{ + ExtraResources: map[string]*fnv1.ResourceSelector{ "obj-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", @@ -618,7 +618,7 @@ func TestRunFunction(t *testing.T) { }`), }, }, - RequiredResources: map[string]*fnv1.Resources{ + ExtraResources: map[string]*fnv1.Resources{ "obj-0": { Items: []*fnv1.Resource{ { @@ -660,7 +660,7 @@ func TestRunFunction(t *testing.T) { Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, Results: []*fnv1.Result{}, Requirements: &fnv1.Requirements{ - Resources: map[string]*fnv1.ResourceSelector{ + ExtraResources: map[string]*fnv1.ResourceSelector{ "obj-0": { ApiVersion: "apiextensions.crossplane.io/v1beta1", Kind: "EnvironmentConfig", diff --git a/package/crossplane.yaml b/package/crossplane.yaml index 1751634..f7e72b7 100644 --- a/package/crossplane.yaml +++ b/package/crossplane.yaml @@ -16,6 +16,4 @@ metadata: can be used by other functions. spec: crossplane: - version: ">=v2.0.0-0" - capabilities: - - required-resources + version: ">=v1.15.0-0" From 9f3aff5a20fdb27ad9d85c783ed0c2f6d1b8ccfc Mon Sep 17 00:00:00 2001 From: Jakub Ramut Date: Tue, 28 Jul 2026 11:37:03 +0200 Subject: [PATCH 2/7] add namespace fail-fast guard for pre-v2.2 Crossplane Signed-off-by: Jakub Ramut --- fn.go | 14 +++++++ fn_test.go | 107 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/fn.go b/fn.go index 0261cc4..5fbbc49 100644 --- a/fn.go +++ b/fn.go @@ -46,6 +46,20 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } + // Crossplane only advertises its capabilities since v2.2. If it doesn't, + // we may be talking to Crossplane v1.x, which ignores the namespace field + // of resource selectors server-side. For namespaced kinds selected by + // labels this would silently match across ALL namespaces, so fail loudly + // instead of leaking resources across namespaces into the context. + if !request.AdvertisesCapabilities(req) { + for _, er := range in.Spec.ExtraResources { + if er.Namespace != nil { + response.Fatal(rsp, errors.Errorf("selector %q sets namespace, but Crossplane did not advertise capabilities (v1.x or Date: Tue, 28 Jul 2026 18:03:41 +0200 Subject: [PATCH 3/7] require Crossplane v1.20+ and drop v1-compat README note Signed-off-by: Jakub Ramut --- README.md | 10 ---------- package/crossplane.yaml | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/README.md b/README.md index a684091..964581a 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,6 @@ A function for selecting extra resources via [composition function][functions]s in [Go][go]. -> **Crossplane v1 compatibility branch.** This branch uses the legacy -> `extra_resources` protocol fields and therefore works on Crossplane -> **v1.15+ as well as v2.x** (Crossplane v2 still resolves the deprecated -> fields). Compared to `main` it does not declare the `required-resources` -> package capability. The `namespace` selector field is only honored by -> Crossplane v2.2+. On older Crossplane the namespace filter is ignored -> server-side - for namespaced kinds selected by labels this would match -> across all namespaces - so this build fails fast if `namespace` is set -> and the server does not advertise capabilities. - ## Using `function-extra-resources` Please see the example in `./example` diff --git a/package/crossplane.yaml b/package/crossplane.yaml index f7e72b7..4cb3bc1 100644 --- a/package/crossplane.yaml +++ b/package/crossplane.yaml @@ -16,4 +16,4 @@ metadata: can be used by other functions. spec: crossplane: - version: ">=v1.15.0-0" + version: ">=v1.20.0-0" From 53cb965d95515ea32697c731b574c20a1fd8f00b Mon Sep 17 00:00:00 2001 From: Jakub Ramut Date: Tue, 28 Jul 2026 22:40:21 +0200 Subject: [PATCH 4/7] filter extra resources by namespace in-function instead of a capability guard Signed-off-by: Jakub Ramut --- fn.go | 33 +++++----- fn_test.go | 176 +++++++++++++++++++++-------------------------------- 2 files changed, 88 insertions(+), 121 deletions(-) diff --git a/fn.go b/fn.go index 5fbbc49..e1717a7 100644 --- a/fn.go +++ b/fn.go @@ -46,20 +46,6 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) return rsp, nil } - // Crossplane only advertises its capabilities since v2.2. If it doesn't, - // we may be talking to Crossplane v1.x, which ignores the namespace field - // of resource selectors server-side. For namespaced kinds selected by - // labels this would silently match across ALL namespaces, so fail loudly - // instead of leaking resources across namespaces into the context. - if !request.AdvertisesCapabilities(req) { - for _, er := range in.Spec.ExtraResources { - if er.Namespace != nil { - response.Fatal(rsp, errors.Errorf("selector %q sets namespace, but Crossplane did not advertise capabilities (v1.x or 1 { diff --git a/fn_test.go b/fn_test.go index f4702bb..1042804 100644 --- a/fn_test.go +++ b/fn_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "strings" "testing" "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" @@ -36,116 +37,11 @@ func TestRunFunction(t *testing.T) { args args want want }{ - "NamespaceRejectedWithoutCapabilities": { - reason: "The Function should fatal when a selector sets namespace but Crossplane does not advertise capabilities.", - args: args{ - req: &fnv1.RunFunctionRequest{ - Meta: &fnv1.RequestMeta{Tag: "hello"}, - Observed: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "test.crossplane.io/v1alpha1", - "kind": "XR", - "metadata": {"name": "my-xr"} - }`), - }, - }, - Input: resource.MustStructJSON(`{ - "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", - "kind": "Input", - "spec": { - "extraResources": [ - { - "type": "Selector", - "kind": "Bar", - "apiVersion": "test.crossplane.io/v1alpha1", - "namespace": "foo", - "into": "obj-0", - "selector": { - "matchLabels": [ - {"type": "Value", "key": "k", "value": "v"} - ] - } - } - ] - } - }`), - }, - }, - want: want{ - rsp: &fnv1.RunFunctionResponse{ - Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, - Results: []*fnv1.Result{ - { - Severity: fnv1.Severity_SEVERITY_FATAL, - Target: fnv1.Target_TARGET_COMPOSITE.Enum(), - }, - }, - }, - }, - }, - "NamespaceAllowedWithCapabilities": { - reason: "The Function should request a namespaced selector when Crossplane advertises capabilities.", - args: args{ - req: &fnv1.RunFunctionRequest{ - Meta: &fnv1.RequestMeta{Tag: "hello", Capabilities: []fnv1.Capability{fnv1.Capability_CAPABILITY_CAPABILITIES}}, - Observed: &fnv1.State{ - Composite: &fnv1.Resource{ - Resource: resource.MustStructJSON(`{ - "apiVersion": "test.crossplane.io/v1alpha1", - "kind": "XR", - "metadata": {"name": "my-xr"} - }`), - }, - }, - Input: resource.MustStructJSON(`{ - "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", - "kind": "Input", - "spec": { - "extraResources": [ - { - "type": "Selector", - "kind": "Bar", - "apiVersion": "test.crossplane.io/v1alpha1", - "namespace": "foo", - "into": "obj-0", - "selector": { - "matchLabels": [ - {"type": "Value", "key": "k", "value": "v"} - ] - } - } - ] - } - }`), - }, - }, - want: want{ - rsp: &fnv1.RunFunctionResponse{ - Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, - Results: []*fnv1.Result{}, - Requirements: &fnv1.Requirements{ - ExtraResources: map[string]*fnv1.ResourceSelector{ - "obj-0": { - ApiVersion: "test.crossplane.io/v1alpha1", - Kind: "Bar", - Match: &fnv1.ResourceSelector_MatchLabels{ - MatchLabels: &fnv1.MatchLabels{ - Labels: map[string]string{"k": "v"}, - }, - }, - Namespace: ptr.To("foo"), - }, - }, - }, - }, - }, - }, "RequestExtraResources": { reason: "The Function should request ExtraResources", args: args{ req: &fnv1.RunFunctionRequest{ - Meta: &fnv1.RequestMeta{Tag: "hello", Capabilities: []fnv1.Capability{fnv1.Capability_CAPABILITY_CAPABILITIES}}, + Meta: &fnv1.RequestMeta{Tag: "hello"}, Observed: &fnv1.State{ Composite: &fnv1.Resource{ Resource: resource.MustStructJSON(`{ @@ -839,6 +735,74 @@ func resourceWithFieldPathValue(path string, value any) resource.Required { } } +func TestVerifyAndSortExtras(t *testing.T) { + nsResource := func(ns, name string) resource.Required { + return resource.Required{ + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Thing", + "metadata": map[string]any{"name": name, "namespace": ns}, + }, + }, + } + } + + cases := map[string]struct { + reason string + in *v1beta1.Input + extra map[string][]resource.Required + wantNames []string + wantErr string + }{ + "SelectorNamespaceFilter": { + reason: "A namespace on a Selector drops resources from other namespaces before counting.", + in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ + Type: v1beta1.ResourceSourceTypeSelector, + Into: "objs", + Namespace: ptr.To("ns-a"), + Selector: &v1beta1.ResourceSourceSelector{}, + }}}}, + extra: map[string][]resource.Required{"objs": {nsResource("ns-a", "keep"), nsResource("ns-b", "drop")}}, + wantNames: []string{"keep"}, + }, + "ReferenceNamespaceHint": { + reason: "A Reference that set a namespace but resolved nothing returns a namespace hint.", + in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ + Type: v1beta1.ResourceSourceTypeReference, + Into: "obj", + Namespace: ptr.To("ns-a"), + Ref: &v1beta1.ResourceSourceReference{Name: "missing"}, + }}}}, + extra: map[string][]resource.Required{"obj": {}}, + wantErr: "only honored on Crossplane v2.0+", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := verifyAndSortExtras(tc.in, tc.extra) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("%s\nwant error containing %q, got: %v", tc.reason, tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("%s\nunexpected error: %v", tc.reason, err) + } + var gotNames []string + for _, o := range got["objs"].([]any) { + md := o.(map[string]any)["metadata"].(map[string]any) + gotNames = append(gotNames, md["name"].(string)) + } + if diff := cmp.Diff(tc.wantNames, gotNames); diff != "" { + t.Errorf("%s\n-want +got:\n%s", tc.reason, diff) + } + }) + } +} + func TestSortExtrasByFieldPath(t *testing.T) { type args struct { extras []resource.Required From 43a22d52b4a440ecef26de23dfbc5269f398df1f Mon Sep 17 00:00:00 2001 From: Philippe Scorsolini <5697904+phisco@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:18 +0000 Subject: [PATCH 5/7] declare the composition capability explicitly Crossplane only defaults a Function to the composition capability when no capabilities are declared at all, so an empty list would silently break every composition using this function. Signed-off-by: Philippe Scorsolini <5697904+phisco@users.noreply.github.com> --- package/crossplane.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package/crossplane.yaml b/package/crossplane.yaml index 4cb3bc1..bc3e92b 100644 --- a/package/crossplane.yaml +++ b/package/crossplane.yaml @@ -17,3 +17,5 @@ metadata: spec: crossplane: version: ">=v1.20.0-0" + capabilities: + - composition From a8914d9042581979ea719c13846f3b57d968bbda Mon Sep 17 00:00:00 2001 From: Philippe Scorsolini <5697904+phisco@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:18 +0000 Subject: [PATCH 6/7] document namespace behavior across Crossplane majors The namespace is not honored server-side on Crossplane v1 and never matches a cluster-scoped kind on either major. The Reference not-found error also blamed the Crossplane version for what is usually a typo. Signed-off-by: Philippe Scorsolini <5697904+phisco@users.noreply.github.com> --- README.md | 24 +++++++++++++++++++ fn.go | 2 +- input/v1beta1/resource_select.go | 5 ++++ ...tra-resources.fn.crossplane.io_inputs.yaml | 5 ++++ 4 files changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 964581a..34488e8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,30 @@ spec: {{- end}} ``` +## Crossplane compatibility + +A single build works on Crossplane v1.20+ and v2.x. It requests extra resources +over the deprecated `extra_resources` protocol fields, which Crossplane v2 still +resolves, rather than the `required_resources` fields that only exist on v2. + +`namespace` behaves differently across the two majors, because Crossplane v1 has +no notion of a namespace on an extra resource selector: + +- On v2, matches are filtered server-side and only the namespace's resources are + sent to the function. +- On v1, the namespace is dropped from the request. Selector matches are + resolved across every namespace and filtered down by the function, so the + result is the same but the full cluster-wide match still crosses the wire — + which can exceed the 4MB default gRPC receive limit. Raise it with + `--max-recv-message-size` if you hit it. +- On v1, a `Reference` to a namespaced resource cannot be resolved at all, since + the lookup is by name only. Such a reference fails rather than resolving to + the wrong object. + +In both cases `namespace` only applies to namespaced kinds. Cluster-scoped +objects — `EnvironmentConfig` among them — have an empty namespace, so setting +one selects nothing. + ## Local dev. ### Air diff --git a/fn.go b/fn.go index e1717a7..9e16f4b 100644 --- a/fn.go +++ b/fn.go @@ -177,7 +177,7 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource continue } if extraResource.Namespace != nil { - return nil, errors.Errorf("required extra resource %q not found; a namespace (%q) is set, which is only honored on Crossplane v2.0+ (older versions ignore it on Reference lookups)", extraResName, *extraResource.Namespace) + return nil, errors.Errorf("required extra resource %q not found in namespace %q: check that it exists there, that its kind is namespaced (a namespace never matches a cluster-scoped kind), and note that Crossplane before v2.0 ignores the namespace on Reference lookups", extraResName, *extraResource.Namespace) } return nil, errors.Errorf("Required extra resource %q not found", extraResName) } diff --git a/input/v1beta1/resource_select.go b/input/v1beta1/resource_select.go index b7d9701..3c45c22 100644 --- a/input/v1beta1/resource_select.go +++ b/input/v1beta1/resource_select.go @@ -117,6 +117,11 @@ type ResourceSource struct { // Namespace is the namespace in which to look for the ExtraResource. // If not set, the resource is assumed to be cluster-scoped. + // + // Only applies to namespaced kinds: setting it on a cluster-scoped kind + // selects nothing. Crossplane v1 does not honor it server-side, so Selector + // matches are filtered by the function and namespaced References cannot be + // resolved at all. // +optional Namespace *string `json:"namespace,omitempty"` diff --git a/package/input/extra-resources.fn.crossplane.io_inputs.yaml b/package/input/extra-resources.fn.crossplane.io_inputs.yaml index c5fa61f..4ee3502 100644 --- a/package/input/extra-resources.fn.crossplane.io_inputs.yaml +++ b/package/input/extra-resources.fn.crossplane.io_inputs.yaml @@ -77,6 +77,11 @@ spec: description: |- Namespace is the namespace in which to look for the ExtraResource. If not set, the resource is assumed to be cluster-scoped. + + Only applies to namespaced kinds: setting it on a cluster-scoped kind + selects nothing. Crossplane v1 does not honor it server-side, so Selector + matches are filtered by the function and namespaced References cannot be + resolved at all. type: string ref: description: |- From d6447670bd8080040bc6fd5e0db424aae3447625 Mon Sep 17 00:00:00 2001 From: Philippe Scorsolini <5697904+phisco@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:28:18 +0000 Subject: [PATCH 7/7] cover the namespace filter and legacy-only protocol in tests Signed-off-by: Philippe Scorsolini <5697904+phisco@users.noreply.github.com> --- fn_test.go | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 132 insertions(+), 1 deletion(-) diff --git a/fn_test.go b/fn_test.go index 1042804..2e53f0a 100644 --- a/fn_test.go +++ b/fn_test.go @@ -603,6 +603,73 @@ func TestRunFunction(t *testing.T) { }, }, }, + "IgnoresRequiredResources": { + reason: "The Function should ignore the v2-only RequiredResources field and only read the legacy ExtraResources one.", + args: args{ + req: &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "hello"}, + Observed: &fnv1.State{ + Composite: &fnv1.Resource{ + Resource: resource.MustStructJSON(`{ + "apiVersion": "test.crossplane.io/v1alpha1", + "kind": "XR", + "metadata": { + "name": "my-xr" + } + }`), + }, + }, + RequiredResources: map[string]*fnv1.Resources{ + "obj-0": { + Items: []*fnv1.Resource{ + { + Resource: resource.MustStructJSON(`{ + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "kind": "EnvironmentConfig", + "metadata": { + "name": "my-env-config" + } + }`), + }, + }, + }, + }, + Input: resource.MustStructJSON(`{ + "apiVersion": "extra-resources.fn.crossplane.io/v1beta1", + "kind": "Input", + "spec": { + "extraResources": [ + { + "type": "Reference", + "into": "obj-0", + "kind": "EnvironmentConfig", + "apiVersion": "apiextensions.crossplane.io/v1beta1", + "ref": { + "name": "my-env-config" + } + } + ] + } + }`), + }, + }, + want: want{ + rsp: &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)}, + Requirements: &fnv1.Requirements{ + ExtraResources: map[string]*fnv1.ResourceSelector{ + "obj-0": { + ApiVersion: "apiextensions.crossplane.io/v1beta1", + Kind: "EnvironmentConfig", + Match: &fnv1.ResourceSelector_MatchName{ + MatchName: "my-env-config", + }, + }, + }, + }, + }, + }, + }, "CustomContextKey": { reason: "The Function should put resolved extra resources into custom context key when specified.", args: args{ @@ -748,6 +815,18 @@ func TestVerifyAndSortExtras(t *testing.T) { } } + clusterResource := func(name string) resource.Required { + return resource.Required{ + Resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "example.org/v1", + "kind": "Thing", + "metadata": map[string]any{"name": name}, + }, + }, + } + } + cases := map[string]struct { reason string in *v1beta1.Input @@ -766,6 +845,58 @@ func TestVerifyAndSortExtras(t *testing.T) { extra: map[string][]resource.Required{"objs": {nsResource("ns-a", "keep"), nsResource("ns-b", "drop")}}, wantNames: []string{"keep"}, }, + "SelectorNamespaceFilterAppliesBeforeMinMatch": { + reason: "Resources dropped by the namespace filter do not count towards minMatch.", + in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ + Type: v1beta1.ResourceSourceTypeSelector, + Into: "objs", + Namespace: ptr.To("ns-a"), + Selector: &v1beta1.ResourceSourceSelector{MinMatch: ptr.To[uint64](2)}, + }}}}, + extra: map[string][]resource.Required{"objs": {nsResource("ns-a", "keep"), nsResource("ns-b", "drop")}}, + wantErr: `expected at least 2 extra resources "objs", got 1`, + }, + "SelectorNamespaceFilterAppliesBeforeSortAndMaxMatch": { + reason: "The namespace filter runs before sorting and truncation, so maxMatch selects from the in-namespace resources only.", + in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ + Type: v1beta1.ResourceSourceTypeSelector, + Into: "objs", + Namespace: ptr.To("ns-a"), + Selector: &v1beta1.ResourceSourceSelector{ + MaxMatch: ptr.To[uint64](2), + SortByFieldPath: "metadata.name", + }, + }}}}, + extra: map[string][]resource.Required{"objs": { + nsResource("ns-a", "c"), + nsResource("ns-b", "a"), + nsResource("ns-a", "b"), + nsResource("ns-a", "a"), + }}, + wantNames: []string{"a", "b"}, + }, + "SelectorNamespaceOnClusterScopedKind": { + reason: "Cluster-scoped resources have an empty namespace, so setting one selects nothing rather than everything.", + in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ + Type: v1beta1.ResourceSourceTypeSelector, + Into: "objs", + Namespace: ptr.To("ns-a"), + Selector: &v1beta1.ResourceSourceSelector{MinMatch: ptr.To[uint64](1)}, + }}}}, + extra: map[string][]resource.Required{"objs": {clusterResource("env-a"), clusterResource("env-b")}}, + wantErr: `expected at least 1 extra resources "objs", got 0`, + }, + "SelectorEmptyNamespaceKeepsClusterScoped": { + reason: "An empty namespace is not the same as an unset one: it matches the empty namespace of cluster-scoped resources.", + in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ + Type: v1beta1.ResourceSourceTypeSelector, + Into: "objs", + Namespace: ptr.To(""), + Selector: &v1beta1.ResourceSourceSelector{}, + }}}}, + extra: map[string][]resource.Required{"objs": {clusterResource("cluster-scoped"), nsResource("ns-a", "namespaced")}}, + wantNames: []string{"cluster-scoped"}, + }, "ReferenceNamespaceHint": { reason: "A Reference that set a namespace but resolved nothing returns a namespace hint.", in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{ @@ -775,7 +906,7 @@ func TestVerifyAndSortExtras(t *testing.T) { Ref: &v1beta1.ResourceSourceReference{Name: "missing"}, }}}}, extra: map[string][]resource.Required{"obj": {}}, - wantErr: "only honored on Crossplane v2.0+", + wantErr: `required extra resource "obj" not found in namespace "ns-a"`, }, }