crossplane-contrib / crossplane-contrib/function-extra-resources

Reference-typed extra resources fail for namespaced kinds when ref.namespace is omitted

Open
#106 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
Go
Stars
33
Forks
22
PR merge metrics
No merged PRs in 30d

Description

### What happened?

`function-extra-resources` doesn't infer the XR's namespace when a `Reference`-typed extra resource omits `ref.namespace`. The function forwards `ref.namespace` to the emitted `ResourceSelector` verbatim — empty string in, empty string out — which means the downstream fetcher does a `Get(name, namespace="")` against the apiserver. For namespaced kinds (the common case for `ConfigMap`, `Secret`, etc.) that's a malformed request, and the fetcher returns NotFound. The pipeline step then FATALs with `Required extra resource "" not found`.

The user reading the field name `ref:` reasonably expects it to behave like every other reference shape in core Kubernetes and the Crossplane ecosystem — when the holding object is namespaced and the referenced kind is namespaced, an omitted `namespace` should default to the holding object's namespace. Examples of that convention:

| Reference | Behavior on omitted `namespace` |
|---|---|
| `corev1.ConfigMapKeyRef` / `SecretKeyRef` (Pod env) | No `namespace` field exists at all — always Pod's namespace |
| `corev1.PersistentVolumeClaimVolumeSource` | Same as Pod |
| `metav1.OwnerReference` | No `namespace` field — same as owned (or owner is cluster-scoped) |
| Ingress `Backend.Service` | Same as Ingress |
| `RoleBinding.Subjects[*].ServiceAccount` | Defaults to RoleBinding's namespace |
| `NetworkPolicy.podSelector` | Same namespace as the NetworkPolicy |
| Crossplane `Composition.compositeTypeRef` | XRD is cluster-scoped, no namespace concern |
| Crossplane `crossplane-runtime` Local/CrossplaneRef helpers | Default to same namespace as the holding object |

`function-extra-resources` is the outlier — `ref.namespace` is treated as a static string with no defaulting, so the only working configuration for a namespaced kind is to hardcode a literal namespace (which doesn't work when the same composition is used across XRs in different namespaces).

The bug surfaces clearly in `crossplane internal render`, which has run the same `FetchingFunctionRunner` → `ExistingRequiredResourcesFetcher` → `client.Get(name, ns)` chain since the subcommand was introduced in Crossplane v2.3.0 (PR [crossplane/crossplane#7339](https://github.com/crossplane/crossplane/pull/7339), merged 2026-05-04). Production reconciles use the same chain, so the failure mode applies to live clusters too — `crossplane internal render` just makes it easy to reproduce out-of-cluster.

#### Proposed fix

In `buildRequirements` (`fn.go:119`), default `Namespace` from the XR when the user yaml omits it AND the referenced kind is namespaced:

```go
case v1beta1.ResourceSourceTypeReference, "":
ns := extraResource.Namespace
if ns == "" {
// Match the convention used by every other namespaced
// reference in Kubernetes and Crossplane: when the user
// doesn't say where to look, look in the same namespace
// as the holding object.
if scoped, err := isNamespacedKind(extraResource.APIVersion, extraResource.Kind); err == nil && scoped {
ns = xr.Resource.GetNamespace()
}
}
extraResources[name] = &fnv1.ResourceSelector{
ApiVersion: extraResource.APIVersion,
Kind: extraResource.Kind,
Match: &fnv1.ResourceSelector_MatchName{MatchName: extraResource.Ref.Name},
Namespace: ns,
}
```

Determining "is this kind namespaced" needs either runtime discovery or a static hint. Two paths:

- **Static hint on the input** — add an optional `scope: Cluster | Namespaced` (default `Namespaced`) so the user disambiguates. Matches the pattern of other contrib functions (`function-go-templating`, `function-kcl`, etc.), which are designed to be apiserver-free and declare needs via `Requirements` rather than fetching directly.
- **Discovery via a kube client** — wire up an apiserver client + cached discovery REST mapper inside the function. `function-sdk-go` does not expose one, so this would mean a new dep on apiserver access from the function pod (RBAC, kubeconfig boot, cache lifecycle) and would break `crossplane internal render` flows that lack apiserver access.

Behaviorally (with the static-hint approach):
- Existing manifests that **do** set `namespace` are unchanged.
- Existing manifests that **don't** set `namespace` and target a namespaced kind: broken in v2.3+ (and would fail in any production reconciler running this code path); now work.
- Existing manifests that **don't** set `namespace` and target a cluster-scoped kind: must add `scope: Cluster` to opt out of the new defaulting. (Worked incidentally before because the function emitted an empty namespace verbatim.)

I'm happy to send the PR if you'd like.

### How can we reproduce it?

Two XRs in different namespaces, each consuming a same-named ConfigMap from their own namespace via a single shared composition:

**XRD (any namespaced XR works)**
```yaml
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: xnopresources.collision.example.org
spec:
scope: Namespaced
group: collision.example.org
names:
kind: XNopResource
plural: xnopresources
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
value: { type: string }
```

**Composition** (note the `ref:` block has no `namespace`)
```yaml
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xnopresources.collision.example.org
spec:
compositeTypeRef:
apiVersion: collision.example.org/v1alpha1
kind: XNopResource
mode: Pipeline
pipeline:
- step: fetch-external-resources
functionRef:
name: function-extra-resources
input:
apiVersion: extra-resources.fn.crossplane.io/v1beta1
kind: Input
spec:
extraResources:
- apiVersion: v1
kind: ConfigMap
into: nsConfig
type: Reference
ref:
name: collision-config
- step: ready
functionRef: { name: function-auto-ready }
```

**Resources in the cluster**
```yaml
apiVersion: v1
kind: Namespace
metadata: { name: ns-a }
---
apiVersion: v1
kind: Namespace
metadata: { name: ns-b }
---
apiVersion: v1
kind: ConfigMap
metadata: { name: collision-config, namespace: ns-a }
data: { value: from-ns-a }
---
apiVersion: v1
kind: ConfigMap
metadata: { name: collision-config, namespace: ns-b }
data: { value: from-ns-b }
---
apiVersion: collision.example.org/v1alpha1
kind: XNopResource
metadata: { name: xr-in-ns-a, namespace: ns-a }
spec: { value: hi }
---
apiVersion: collision.example.org/v1alpha1
kind: XNopResource
metadata: { name: xr-in-ns-b, namespace: ns-b }
spec: { value: hi }
```

**Expected**: each XR's pipeline sees its own namespace's `collision-config`. Reconciliation succeeds, downstream resources reflect each namespace's `data.value`.

**Actual**: both XRs FATAL with `pipeline step "fetch-external-resources" returned a fatal result: verifying and sorting extra resources: Required extra resource "nsConfig" not found`. The function emits `ResourceSelector{MatchName: "collision-config", Namespace: ""}`, the fetcher's `client.Get(NamespacedName{Namespace: "", Name: "collision-config"})` for a namespaced kind doesn't match either ConfigMap.

The same composition works if the user adds an explicit `ref.namespace: ns-a` (and a separate composition for `ns-b`). That defeats the point of a single composition serving multiple namespaces.

This was originally surfaced in `crossplane-contrib/crossplane-diff` as `TestCompDiffIntegration/CrossNamespaceResourceCollision`. The same composition would fail identically in a live cluster — the bug isn't render-tooling-specific.

### What environment did it happen in?

Function version: `v0.2.0` (also confirmed against `v0.3.0` source — same code path). The behavior has been the same since at least `v0.2.0`.

* Crossplane: `v2.3.0` and onward (PR [crossplane/crossplane#7339](https://github.com/crossplane/crossplane/pull/7339) introduced the `crossplane internal render` subcommand and the current `FetchingFunctionRunner` → `ExistingRequiredResourcesFetcher` chain in v2.3.0; production reconciles use the same chain so the bug applies to live clusters since v2.3.0 too)
* Kubernetes: any (the apiserver-side behavior — `Get` with empty namespace on a namespaced kind — is universal)

(issue created with 🤖 from a conversation w/@jcogilvie in crossplane-diff where I experienced the bug failing a test after switching to `internal render`)

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.