Skip to content
Merged
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
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion fn.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
133 changes: 132 additions & 1 deletion fn_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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
Expand All @@ -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{{
Expand All @@ -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"`,
},
}

Expand Down
5 changes: 5 additions & 0 deletions input/v1beta1/resource_select.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`

Expand Down
2 changes: 2 additions & 0 deletions package/crossplane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ metadata:
spec:
crossplane:
version: ">=v1.20.0-0"
capabilities:
- composition
5 changes: 5 additions & 0 deletions package/input/extra-resources.fn.crossplane.io_inputs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |-
Expand Down