Skip to content

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

Description

@jcogilvie

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 "<name>" 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 FetchingFunctionRunnerExistingRequiredResourcesFetcherclient.Get(name, ns) chain since the subcommand was introduced in Crossplane v2.3.0 (PR crossplane/crossplane#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:

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)

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)

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

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 introduced the crossplane internal render subcommand and the current FetchingFunctionRunnerExistingRequiredResourcesFetcher 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)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions