Skip to content

Commit 6594f83

Browse files
authored
Merge pull request crossplane-contrib#111 from jakubramut/feat/crossplane-v1
Crossplane v1-compatible build of function-extra-resources
2 parents 50e373b + 86ce973 commit 6594f83

6 files changed

Lines changed: 264 additions & 13 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,30 @@ spec:
6363
{{- end}}
6464
```
6565
66+
## Crossplane compatibility
67+
68+
A single build works on Crossplane v1.20+ and v2.x. It requests extra resources
69+
over the deprecated `extra_resources` protocol fields, which Crossplane v2 still
70+
resolves, rather than the `required_resources` fields that only exist on v2.
71+
72+
`namespace` behaves differently across the two majors, because Crossplane v1 has
73+
no notion of a namespace on an extra resource selector:
74+
75+
- On v2, matches are filtered server-side and only the namespace's resources are
76+
sent to the function.
77+
- On v1, the namespace is dropped from the request. Selector matches are
78+
resolved across every namespace and filtered down by the function, so the
79+
result is the same but the full cluster-wide match still crosses the wire —
80+
which can exceed the 4MB default gRPC receive limit. Raise it with
81+
`--max-recv-message-size` if you hit it.
82+
- On v1, a `Reference` to a namespaced resource cannot be resolved at all, since
83+
the lookup is by name only. Such a reference fails rather than resolving to
84+
the wrong object.
85+
86+
In both cases `namespace` only applies to namespaced kinds. Cluster-scoped
87+
objects — `EnvironmentConfig` among them — have an empty namespace, so setting
88+
one selects nothing.
89+
6690
## Local dev.
6791

6892
### Air

fn.go

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -61,13 +61,13 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest)
6161
// function-extra-resources does not know if it has requested the resources already or not.
6262
//
6363
// If it has and these resources are now present, proceed with verification and conversion.
64-
if req.RequiredResources == nil {
64+
if req.ExtraResources == nil { //nolint:staticcheck // Deprecated field used intentionally for Crossplane v1.x compatibility.
6565
f.log.Debug("No extra resources present, exiting", "requirements", rsp.GetRequirements())
6666
return rsp, nil
6767
}
6868

6969
// Pull extra resources from the ExtraResources request field.
70-
extraResources, err := request.GetRequiredResources(req)
70+
extraResources, err := request.GetExtraResources(req) //nolint:staticcheck // Deprecated helper used intentionally for Crossplane v1.x compatibility.
7171
if err != nil {
7272
response.Fatal(rsp, errors.Errorf("fetching extra resources %T: %w", req, err))
7373
return rsp, nil
@@ -143,11 +143,11 @@ func buildRequirements(in *v1beta1.Input, xr *resource.Composite) (*fnv1.Require
143143
}
144144
}
145145
}
146-
return &fnv1.Requirements{Resources: extraResources}, nil
146+
return &fnv1.Requirements{ExtraResources: extraResources}, nil
147147
}
148148

149149
// Verify Min/Max and sort extra resources by field path within a single kind.
150-
func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource.Required, //nolint:gocyclo // TODO(reedjosh): refactor
150+
func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource.Required, //nolint:gocyclo,gocognit // TODO(reedjosh): refactor
151151
) (map[string]any, error) {
152152
cleanedExtras := make(map[string]any)
153153
for _, extraResource := range in.Spec.ExtraResources {
@@ -156,12 +156,29 @@ func verifyAndSortExtras(in *v1beta1.Input, extraResources map[string][]resource
156156
if !ok {
157157
return nil, errors.Errorf("cannot find expected extra resource %q", extraResName)
158158
}
159+
// When a namespace is set, keep only resources in that namespace so the
160+
// min/max counting below sees the filtered set. Crossplane v2 filters
161+
// server-side (a no-op here); Crossplane v1.20 ignores the namespace on
162+
// label selectors and returns matches from every namespace, so this
163+
// keeps cross-namespace resources out of the context.
164+
if extraResource.Namespace != nil {
165+
kept := make([]resource.Required, 0, len(resources))
166+
for _, r := range resources {
167+
if r.Resource.GetNamespace() == *extraResource.Namespace {
168+
kept = append(kept, r)
169+
}
170+
}
171+
resources = kept
172+
}
159173
switch extraResource.GetType() {
160174
case v1beta1.ResourceSourceTypeReference:
161175
if len(resources) == 0 {
162176
if in.Spec.Policy.IsResolutionPolicyOptional() {
163177
continue
164178
}
179+
if extraResource.Namespace != nil {
180+
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)
181+
}
165182
return nil, errors.Errorf("Required extra resource %q not found", extraResName)
166183
}
167184
if len(resources) > 1 {

fn_test.go

Lines changed: 207 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"strings"
56
"testing"
67

78
"github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath"
@@ -160,7 +161,7 @@ func TestRunFunction(t *testing.T) {
160161
Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)},
161162
Results: []*fnv1.Result{},
162163
Requirements: &fnv1.Requirements{
163-
Resources: map[string]*fnv1.ResourceSelector{
164+
ExtraResources: map[string]*fnv1.ResourceSelector{
164165
"obj-0": {
165166
ApiVersion: "apiextensions.crossplane.io/v1beta1",
166167
Kind: "EnvironmentConfig",
@@ -244,7 +245,7 @@ func TestRunFunction(t *testing.T) {
244245
}`),
245246
},
246247
},
247-
RequiredResources: map[string]*fnv1.Resources{
248+
ExtraResources: map[string]*fnv1.Resources{
248249
"obj-0": {
249250
Items: []*fnv1.Resource{
250251
{
@@ -418,7 +419,7 @@ func TestRunFunction(t *testing.T) {
418419
Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)},
419420
Results: []*fnv1.Result{},
420421
Requirements: &fnv1.Requirements{
421-
Resources: map[string]*fnv1.ResourceSelector{
422+
ExtraResources: map[string]*fnv1.ResourceSelector{
422423
"obj-0": {
423424
ApiVersion: "apiextensions.crossplane.io/v1beta1",
424425
Kind: "EnvironmentConfig",
@@ -555,7 +556,7 @@ func TestRunFunction(t *testing.T) {
555556
}`),
556557
},
557558
},
558-
RequiredResources: map[string]*fnv1.Resources{
559+
ExtraResources: map[string]*fnv1.Resources{
559560
"environment-config-0": {
560561
Items: []*fnv1.Resource{},
561562
},
@@ -589,7 +590,74 @@ func TestRunFunction(t *testing.T) {
589590
},
590591
},
591592
Requirements: &fnv1.Requirements{
592-
Resources: map[string]*fnv1.ResourceSelector{
593+
ExtraResources: map[string]*fnv1.ResourceSelector{
594+
"obj-0": {
595+
ApiVersion: "apiextensions.crossplane.io/v1beta1",
596+
Kind: "EnvironmentConfig",
597+
Match: &fnv1.ResourceSelector_MatchName{
598+
MatchName: "my-env-config",
599+
},
600+
},
601+
},
602+
},
603+
},
604+
},
605+
},
606+
"IgnoresRequiredResources": {
607+
reason: "The Function should ignore the v2-only RequiredResources field and only read the legacy ExtraResources one.",
608+
args: args{
609+
req: &fnv1.RunFunctionRequest{
610+
Meta: &fnv1.RequestMeta{Tag: "hello"},
611+
Observed: &fnv1.State{
612+
Composite: &fnv1.Resource{
613+
Resource: resource.MustStructJSON(`{
614+
"apiVersion": "test.crossplane.io/v1alpha1",
615+
"kind": "XR",
616+
"metadata": {
617+
"name": "my-xr"
618+
}
619+
}`),
620+
},
621+
},
622+
RequiredResources: map[string]*fnv1.Resources{
623+
"obj-0": {
624+
Items: []*fnv1.Resource{
625+
{
626+
Resource: resource.MustStructJSON(`{
627+
"apiVersion": "apiextensions.crossplane.io/v1beta1",
628+
"kind": "EnvironmentConfig",
629+
"metadata": {
630+
"name": "my-env-config"
631+
}
632+
}`),
633+
},
634+
},
635+
},
636+
},
637+
Input: resource.MustStructJSON(`{
638+
"apiVersion": "extra-resources.fn.crossplane.io/v1beta1",
639+
"kind": "Input",
640+
"spec": {
641+
"extraResources": [
642+
{
643+
"type": "Reference",
644+
"into": "obj-0",
645+
"kind": "EnvironmentConfig",
646+
"apiVersion": "apiextensions.crossplane.io/v1beta1",
647+
"ref": {
648+
"name": "my-env-config"
649+
}
650+
}
651+
]
652+
}
653+
}`),
654+
},
655+
},
656+
want: want{
657+
rsp: &fnv1.RunFunctionResponse{
658+
Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)},
659+
Requirements: &fnv1.Requirements{
660+
ExtraResources: map[string]*fnv1.ResourceSelector{
593661
"obj-0": {
594662
ApiVersion: "apiextensions.crossplane.io/v1beta1",
595663
Kind: "EnvironmentConfig",
@@ -618,7 +686,7 @@ func TestRunFunction(t *testing.T) {
618686
}`),
619687
},
620688
},
621-
RequiredResources: map[string]*fnv1.Resources{
689+
ExtraResources: map[string]*fnv1.Resources{
622690
"obj-0": {
623691
Items: []*fnv1.Resource{
624692
{
@@ -660,7 +728,7 @@ func TestRunFunction(t *testing.T) {
660728
Meta: &fnv1.ResponseMeta{Tag: "hello", Ttl: durationpb.New(response.DefaultTTL)},
661729
Results: []*fnv1.Result{},
662730
Requirements: &fnv1.Requirements{
663-
Resources: map[string]*fnv1.ResourceSelector{
731+
ExtraResources: map[string]*fnv1.ResourceSelector{
664732
"obj-0": {
665733
ApiVersion: "apiextensions.crossplane.io/v1beta1",
666734
Kind: "EnvironmentConfig",
@@ -734,6 +802,138 @@ func resourceWithFieldPathValue(path string, value any) resource.Required {
734802
}
735803
}
736804

805+
func TestVerifyAndSortExtras(t *testing.T) {
806+
nsResource := func(ns, name string) resource.Required {
807+
return resource.Required{
808+
Resource: &unstructured.Unstructured{
809+
Object: map[string]any{
810+
"apiVersion": "example.org/v1",
811+
"kind": "Thing",
812+
"metadata": map[string]any{"name": name, "namespace": ns},
813+
},
814+
},
815+
}
816+
}
817+
818+
clusterResource := func(name string) resource.Required {
819+
return resource.Required{
820+
Resource: &unstructured.Unstructured{
821+
Object: map[string]any{
822+
"apiVersion": "example.org/v1",
823+
"kind": "Thing",
824+
"metadata": map[string]any{"name": name},
825+
},
826+
},
827+
}
828+
}
829+
830+
cases := map[string]struct {
831+
reason string
832+
in *v1beta1.Input
833+
extra map[string][]resource.Required
834+
wantNames []string
835+
wantErr string
836+
}{
837+
"SelectorNamespaceFilter": {
838+
reason: "A namespace on a Selector drops resources from other namespaces before counting.",
839+
in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{
840+
Type: v1beta1.ResourceSourceTypeSelector,
841+
Into: "objs",
842+
Namespace: ptr.To("ns-a"),
843+
Selector: &v1beta1.ResourceSourceSelector{},
844+
}}}},
845+
extra: map[string][]resource.Required{"objs": {nsResource("ns-a", "keep"), nsResource("ns-b", "drop")}},
846+
wantNames: []string{"keep"},
847+
},
848+
"SelectorNamespaceFilterAppliesBeforeMinMatch": {
849+
reason: "Resources dropped by the namespace filter do not count towards minMatch.",
850+
in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{
851+
Type: v1beta1.ResourceSourceTypeSelector,
852+
Into: "objs",
853+
Namespace: ptr.To("ns-a"),
854+
Selector: &v1beta1.ResourceSourceSelector{MinMatch: ptr.To[uint64](2)},
855+
}}}},
856+
extra: map[string][]resource.Required{"objs": {nsResource("ns-a", "keep"), nsResource("ns-b", "drop")}},
857+
wantErr: `expected at least 2 extra resources "objs", got 1`,
858+
},
859+
"SelectorNamespaceFilterAppliesBeforeSortAndMaxMatch": {
860+
reason: "The namespace filter runs before sorting and truncation, so maxMatch selects from the in-namespace resources only.",
861+
in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{
862+
Type: v1beta1.ResourceSourceTypeSelector,
863+
Into: "objs",
864+
Namespace: ptr.To("ns-a"),
865+
Selector: &v1beta1.ResourceSourceSelector{
866+
MaxMatch: ptr.To[uint64](2),
867+
SortByFieldPath: "metadata.name",
868+
},
869+
}}}},
870+
extra: map[string][]resource.Required{"objs": {
871+
nsResource("ns-a", "c"),
872+
nsResource("ns-b", "a"),
873+
nsResource("ns-a", "b"),
874+
nsResource("ns-a", "a"),
875+
}},
876+
wantNames: []string{"a", "b"},
877+
},
878+
"SelectorNamespaceOnClusterScopedKind": {
879+
reason: "Cluster-scoped resources have an empty namespace, so setting one selects nothing rather than everything.",
880+
in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{
881+
Type: v1beta1.ResourceSourceTypeSelector,
882+
Into: "objs",
883+
Namespace: ptr.To("ns-a"),
884+
Selector: &v1beta1.ResourceSourceSelector{MinMatch: ptr.To[uint64](1)},
885+
}}}},
886+
extra: map[string][]resource.Required{"objs": {clusterResource("env-a"), clusterResource("env-b")}},
887+
wantErr: `expected at least 1 extra resources "objs", got 0`,
888+
},
889+
"SelectorEmptyNamespaceKeepsClusterScoped": {
890+
reason: "An empty namespace is not the same as an unset one: it matches the empty namespace of cluster-scoped resources.",
891+
in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{
892+
Type: v1beta1.ResourceSourceTypeSelector,
893+
Into: "objs",
894+
Namespace: ptr.To(""),
895+
Selector: &v1beta1.ResourceSourceSelector{},
896+
}}}},
897+
extra: map[string][]resource.Required{"objs": {clusterResource("cluster-scoped"), nsResource("ns-a", "namespaced")}},
898+
wantNames: []string{"cluster-scoped"},
899+
},
900+
"ReferenceNamespaceHint": {
901+
reason: "A Reference that set a namespace but resolved nothing returns a namespace hint.",
902+
in: &v1beta1.Input{Spec: v1beta1.InputSpec{ExtraResources: []v1beta1.ResourceSource{{
903+
Type: v1beta1.ResourceSourceTypeReference,
904+
Into: "obj",
905+
Namespace: ptr.To("ns-a"),
906+
Ref: &v1beta1.ResourceSourceReference{Name: "missing"},
907+
}}}},
908+
extra: map[string][]resource.Required{"obj": {}},
909+
wantErr: `required extra resource "obj" not found in namespace "ns-a"`,
910+
},
911+
}
912+
913+
for name, tc := range cases {
914+
t.Run(name, func(t *testing.T) {
915+
got, err := verifyAndSortExtras(tc.in, tc.extra)
916+
if tc.wantErr != "" {
917+
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
918+
t.Fatalf("%s\nwant error containing %q, got: %v", tc.reason, tc.wantErr, err)
919+
}
920+
return
921+
}
922+
if err != nil {
923+
t.Fatalf("%s\nunexpected error: %v", tc.reason, err)
924+
}
925+
var gotNames []string
926+
for _, o := range got["objs"].([]any) {
927+
md := o.(map[string]any)["metadata"].(map[string]any)
928+
gotNames = append(gotNames, md["name"].(string))
929+
}
930+
if diff := cmp.Diff(tc.wantNames, gotNames); diff != "" {
931+
t.Errorf("%s\n-want +got:\n%s", tc.reason, diff)
932+
}
933+
})
934+
}
935+
}
936+
737937
func TestSortExtrasByFieldPath(t *testing.T) {
738938
type args struct {
739939
extras []resource.Required

input/v1beta1/resource_select.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,11 @@ type ResourceSource struct {
117117

118118
// Namespace is the namespace in which to look for the ExtraResource.
119119
// If not set, the resource is assumed to be cluster-scoped.
120+
//
121+
// Only applies to namespaced kinds: setting it on a cluster-scoped kind
122+
// selects nothing. Crossplane v1 does not honor it server-side, so Selector
123+
// matches are filtered by the function and namespaced References cannot be
124+
// resolved at all.
120125
// +optional
121126
Namespace *string `json:"namespace,omitempty"`
122127

package/crossplane.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ metadata:
1616
can be used by other functions.
1717
spec:
1818
crossplane:
19-
version: ">=v2.0.0-0"
19+
version: ">=v1.20.0-0"
2020
capabilities:
21-
- required-resources
21+
- composition

0 commit comments

Comments
 (0)