Skip to content

How to require cluster resources

Sometimes a module needs to read an existing cluster object before it can render — a ConfigMap whose data it copies, a Secret it references, a sibling resource it depends on. cuefn supports this the way native Crossplane composition functions do: a module emits selectors under out.requirements, Crossplane fetches the matching objects, and they reappear under out.input.requiredResources keyed by the same author-chosen name.

This guide shows how to declare a requirement, guard a data-dependent resource on the fetched objects, test it offline with cuefn render, and grant the RBAC the in-cluster fetch needs. For why the loop converges, see Required resources and the fixpoint; for the field-by-field contract, see the module contract.

These fields live in the contract module from v0.2.0 onward. If you adopt the contract for author-time validation, run cue mod tidy so deps resolves github.com/meigma/crossplane-cuefn/contract@v0 to v0.2.0 or newer.

Use observed resources for composed children

Do not emit a requirement just to read a Job, Deployment, or other object the same module already creates. Crossplane supplies composed children through out.input.observedResources without a fetch loop or read RBAC. See derive readiness from observed resources.

1. Emit a requirement

A requirement is one selector under out.requirements, keyed by a name you choose. The engine enforces that each entry sets exactly one of matchName (an exact metadata.name) or matchLabels (a label subset):

import "github.com/meigma/crossplane-cuefn/contract@v0"

out: contract.#Transform & {
    requirements: cfg: contract.#Requirement & {
        apiVersion: "v1"
        kind:       "ConfigMap"
        matchName:  input.spec.configName
        namespace:  input.metadata.namespace
    }
    // ...input / resources / status...
}
Selector field Required Meaning
apiVersion yes API version of the kind to fetch.
kind yes Kind to fetch.
matchName one of Exact metadata.name.
matchLabels one of Map of labels; an object matches when it carries all of them.
namespace no Namespace to read. Omit to read a cluster-scoped kind, or to list a namespaced kind across all namespaces.

Requirements must be a pure function of stable inputs

Build the selector only from input.spec, input.metadata, and input.environment — never from input.requiredResources (the fetched data). Crossplane re-invokes the function until two consecutive responses emit byte-identical requirements (the proto.Equal fixpoint). A selector that depends on what was fetched changes every pass and never converges; Crossplane then fails the composite with requirements didn't stabilize, and cuefn render mirrors that with its own stabilization error. See the fixpoint explanation.

2. Guard the data-dependent resource

Read the fetched objects back under input.requiredResources[name] and only build the resource that needs them when something was delivered. The engine seeds an empty list for every requirement name before it reads out.resources, so the guard collapses to a concrete (empty) result on the first pass — no author boilerplate needed.

The idiomatic guard is a for comprehension, which emits nothing on the seeded empty list and one resource per fetched object on later passes:

out: resources: {
    // No-ops on the seeded [] (first pass -> out.resources is a concrete {});
    // emits one Deployment per fetched ConfigMap once Crossplane delivers them.
    for i, cm in input.requiredResources.cfg {
        "deployment-\(i)": {
            ready: "Ready"
            object: {
                apiVersion: "apps/v1"
                kind:       "Deployment"
                metadata: name: "\(input.metadata.name)-\(i)"
                spec: image: cm.data.image
            }
        }
    }
}

To gate a single resource on the first match instead, guard it with if len(...) > 0 and read element [0]:

out: resources: {
    if len(input.requiredResources.cfg) > 0 {
        app: {
            ready: "Ready"
            object: {
                apiVersion: "apps/v1"
                kind:       "Deployment"
                metadata: name: input.metadata.name
                spec: image: input.requiredResources.cfg[0].data.image
            }
        }
    }
}

Either form works because the engine guarantees input.requiredResources.cfg exists as a concrete list. The deciding factor is the concrete list, not the guard expression.

Supporting cluster-scoped XRs

A selector that writes namespace: input.metadata.namespace becomes non-concrete when the XR is cluster-scoped (no namespace), which fails the render. If your module supports scope: "Cluster", default metadata.namespace and emit the selector's namespace only when it is set:

out: {
    input: metadata: {
        name:      string
        namespace: string | *""
    }
    requirements: cfg: contract.#Requirement & {
        apiVersion: "v1"
        kind:       "ConfigMap"
        matchName:  input.spec.configName
        if input.metadata.namespace != "" {
            namespace: input.metadata.namespace
        }
    }
}

An omitted selector namespace reads cluster-wide; scope reads to the XR's own namespace by writing namespace: input.metadata.namespace as above.

3. Test it offline

cuefn render always prints the requirements a module emits, so you can see what to supply even before you have any objects. The commands below render the module you built in steps 1-2 — the one that emits the cfg requirement and guards a Deployment on input.requiredResources.cfg — served from its own directory with --dir. (The shipped example/module emits no requirements, so point --dir at your own module here.)

Rendering it against an XR named myapp with spec.configName: app-cfg in namespace default prints the selector from step 1:

cuefn render cuefn.example/myapp@v0 \
  --dir ./my-module \
  --xr ./xr.yaml
requirements:
  cfg:
    apiVersion: v1
    kind: ConfigMap
    matchName: app-cfg
    namespace: default
resources: {}

With nothing delivered, the for comprehension over the seeded empty input.requiredResources.cfg emits nothing, so the guarded Deployment is absent and resources is empty — exactly the first pass Crossplane sees.

Now supply a flat bag of real cluster objects with --required-resources. It accepts a single YAML file or a directory of them (multi-document files are fine); each object is matched against the emitted selectors — files are not keyed by name:

cat > /tmp/cfg.yaml <<'EOF'
apiVersion: v1
kind: ConfigMap
metadata:
  name: app-cfg
  namespace: default
data:
  image: ghcr.io/example/app:1.2.3
EOF

cuefn render cuefn.example/myapp@v0 \
  --dir ./my-module \
  --xr ./xr.yaml \
  --required-resources /tmp/cfg.yaml

The ConfigMap's name and namespace match the emitted selector, so it is delivered to input.requiredResources.cfg and the guarded Deployment renders with spec.image taken from cm.data.image:

requirements:
  cfg:
    apiVersion: v1
    kind: ConfigMap
    matchName: app-cfg
    namespace: default
resources:
  deployment-0:
    ready: Ready
    object:
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: myapp-0
      spec:
        image: ghcr.io/example/app:1.2.3

cuefn render runs the same fixed two-pass loop Crossplane converges to and fails with a stabilization error if the module's requirements are not pure (see the CLI reference).

4. Grant the in-cluster RBAC

In-cluster, required resources are fetched by Crossplane's core controller ServiceAccount — not the function pod. For every kind a module can request, the operator must grant that controller get/list/watch. The portable way is a ClusterRole carrying the aggregation label, which Crossplane's aggregated crossplane ClusterRole absorbs:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: crossplane-cuefn-required-configmaps
  labels:
    rbac.crossplane.io/aggregate-to-crossplane: "true"
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch"]

This is an operator responsibility: cuefn ships no RBAC for arbitrary requested kinds, because it cannot know which kinds an author will request. Without the rule the fetch silently returns nothing, the delivered bucket stays empty, and any guarded resource never renders — a silent under-render. See why reads go through the core controller.

This grants the read side (get/list/watch on requested objects). Composing native kinds beyond the core workloads needs the separate write grant — see configure the function runtime.

See also