Skip to content

Module contract

A cuefn module is a CUE module that declares a platform API and a transform. The same module drives both XRD codegen (authoring time) and rendering (runtime), so the schema and the transform never drift. This page describes the contract the CLI and the engine rely on.

All examples are the canonical example module shipped at example/module/.

Module identity

The module is an ordinary CUE module with a cue.mod/module.cue:

module: "cuefn.example/app@v0"
language: {
    version: "v0.16.0"
}
source: {
    kind: "self"
}
deps: {
    "cue.dev/x/k8s.io@v0": {
        v:       "v0.7.0"
        default: true
    }
    "github.com/meigma/crossplane-cuefn/contract@v0": {
        v: "v0.1.0"
    }
}

The module path and major version (cuefn.example/app@v0) form the path@version reference used everywhere a <module-ref> is required.

The deps block records the module's dependencies. The example transform instantiates its Kubernetes objects from the official Kubernetes schema (cue.dev/x/k8s.io), and adopts the cuefn module contract (github.com/meigma/crossplane-cuefn/contract@v0) for author-time validation, so the module carries both as dependencies (the contract is imported in api.cue and transform.cue — see author-time validation). Run cue mod tidy in the module directory to populate deps after adding an import; modern CUE records the resolved versions inline in module.cue (there is no separate cue.sum). Both dependencies resolve from the default central registry (registry.cue.works) — see configuration.

The API envelope: #API

#API is a concrete definition the CLI decodes to build the XRD envelope. A single served version is supported.

#API: {
    group:   "platform.meigma.io"
    version: "v1alpha1"
    kind:    "XApp"
    plural:  "xapps"
    scope:   *"Namespaced" | "Cluster"
}
Field Required Description
group yes API group of the composite resource.
version yes The single served API version.
kind yes Composite resource kind.
plural yes Plural name (XRD names.plural).
scope yes "Namespaced" or "Cluster"; a same-type disjunction with a default is allowed.
shortNames no Optional list of short names.
categories no Optional list of resource categories.
printerColumns no Optional additional printer columns.

Multi-version XRDs and conversion are not supported; the module declares exactly one served version.

The spec schema: #Spec

#Spec is the authoritative, closed XR spec schema. It is the single source of the spec's defaults and constraints: the CLI translates it to the XRD's .properties.spec, and the runtime engine unifies the observed spec against it.

#Spec: {
    image:    string | *"ghcr.io/stefanprodan/podinfo:6.7.0"
    replicas: *1 | int & >=1 & <=10
}

Because #Spec is closed, fields the author did not declare are rejected. The engine first projects Crossplane-reserved keys out of the observed spec so they do not collide with the closed schema — see reserved-key projection.

Defaults (*…) and bounds (>=1 & <=10) live here once and apply in both places: the API server fills XRD defaults, and the engine applies the same defaults at render, so there is no drift between cluster-filled and render-filled values.

The status schema: #Status (optional)

#Status is the optional XR status schema. When present, the CLI emits .properties.status in the XRD and the transform returns a value of this shape to be patched onto the composite.

#Status: {
    ready: bool
    url:   string
}

When a module omits #Status, the generated XRD has no status schema and the transform's out.status field is simply absent.

The transform

The transform is a regular (non-definition) CUE field set nested under a single top-level out field: the engine fills out.input and reads out.resources and an optional out.status. The schema definitions (#API/#Spec/#Status) stay top-level.

out: {
    input: {/* filled by the engine */}
    resources: {/* the composed Kubernetes objects */}
    status?: {/* optional; patched onto the composite */}
}

Nesting the transform under one out field keeps the well-known field set in a single place that one closed definition can validate.

Inputs the engine fills

out: input: {
    spec: #Spec
    metadata: {
        name:       string | *"app"
        namespace?: string
        ...
    }
    environment: {
        tier: string | *"unset"
        ...
    }
    // Declare this regular field only when the module needs self-observation.
    observedResources: [string]: contract.#ObservedResource
}
Fill point Source
out.input.spec The observed XR's spec, projected (reserved keys stripped) and unified against #Spec.
out.input.metadata The XR's identifying metadata: name (and optional namespace).
out.input.environment The merged EnvironmentConfig data from the pipeline context.
out.input.observedResources Opt-in map of observed composed objects, keyed by stable resource name.

Binding out.input.spec: #Spec is what ties render-time defaults/validation to the same schema the XRD is generated from. The engine fills out.input by JSON marshalling, so an integral spec value (e.g. a float64 replica count from YAML) unifies cleanly against a bounded integer field.

out.input.environment is populated in-cluster by a function-environment-configs step — present when the Configuration is published with --environment-config — that merges the referenced EnvironmentConfig into the pipeline context (context key apiextensions.crossplane.io/environment). With cuefn render, the --env file supplies it directly.

Outputs the engine reads

The example instantiates each object from the official Kubernetes schema (cue.dev/x/k8s.io) rather than hand-writing the maps, so apiVersion/kind are supplied by the definition and any invalid field name, type, or shape fails at render time instead of on apply. Writing the objects as plain maps is still valid; importing the schema is the recommended practice.

import (
    appsv1 "cue.dev/x/k8s.io/api/apps/v1"
    corev1 "cue.dev/x/k8s.io/api/core/v1"
)

out: {
    resources: {
        deployment: {
            ready: "Ready"
            object: appsv1.#Deployment & { metadata: {/* ... */}, spec: {/* ... */} }
        }
        service: {
            ready: "NotReady"
            object: corev1.#Service & { metadata: {/* ... */}, spec: {/* ... */} }
        }
        config: {
            object: corev1.#ConfigMap & { metadata: {/* ... */}, data: {/* ... */} }
        }
    }

    status: #Status & {
        ready: true
        url:   "http://\(input.metadata.name).svc"
    }
}

out.resources is an author-keyed map. Each key is a stable, author-chosen name used verbatim as the Crossplane composed-resource name. Each entry has:

Field Required Description
object yes The fully rendered Kubernetes object.
ready no "Ready" or "NotReady". An absent hint is treated as unspecified.

The engine validates that every entry is concrete (cue.Concrete(true)); a non-concrete out.resources (or out.status) is an error.

out.status is optional. When present it must be concrete and is patched onto the composite.

Readiness mapping

The module's readiness hint maps to the runtime/render readiness as follows:

Module hint cuefn render output Crossplane readiness
"Ready" "True" Ready
"NotReady" "False" Not ready
(absent) Unspecified Not ready

Conditionless resources need an explicit ready hint

With the supported Crossplane 2.3.3 runtime, only a function response carrying READY_TRUE counts as ready. Unspecified is not inferred from the composed object's own Ready=True condition. An unmarked resource therefore holds the XR at Ready=False; conditionless objects such as ConfigMaps do so forever. Derive a conservative predicate from observedResources, then emit ready: "Ready" only when that predicate passes.

Observing composed resources

Contract v0.3.0 adds the optional #Input.observedResources field and open #ObservedResource definition. This is the self-observation path: it lets a module derive readiness and status from the composed objects Crossplane already returned on the current function request. It performs no API reads.

Explicit opt-in

Importing the contract alone does not expose observations. A module opts in by materializing observedResources as a regular field under out.input:

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

out: contract.#Transform & {
    input: {
        spec: #Spec
        metadata: {/* ... */}
        environment: {/* ... */}
        observedResources: [string]: contract.#ObservedResource
    }
    // ...resources and status...
}

The optional field inherited from contract.#Transform is not an opt-in. This distinction preserves modules written against older closed contracts: cuefn does not fill an unknown field into them, so their output remains unchanged. An opted-in module receives a concrete {} on the first pass, before Crossplane has observed any composed resource.

Map shape and fidelity

#ObservedResource: {
    apiVersion: string
    kind:       string
    ...
}

observedResources: [string]: #ObservedResource

Each map key is the stable name the author used in out.resources (the crossplane.io/composition-resource-name annotation in raw render fixtures), not the object's physical metadata.name. A resource Crossplane has not observed is absent from the map. Each value is the full Kubernetes object body supplied by Crossplane; the open definition preserves kind-specific metadata, spec, and status fields.

Connection details are a separate field in the function protocol and are never included. The map is a point-in-time request snapshot, so status may lag the latest API-server state. Modules should fail readiness closed: verify stable-key presence, expected identity, UID, observed generation, conditions, and counters as appropriate before returning ready: "Ready".

Because full object bodies can contain sensitive fields, treat module code and module registry integrity as part of the trust boundary. cuefn does not log observed object bodies. See derive readiness from observed resources for concrete Job, Deployment, and ConfigMap predicates.

Requiring cluster resources

A module may ask Crossplane to fetch existing cluster objects at render time and read them back. Two optional contract fields make this work, both keyed by a name the author chooses (the same name in both directions). They are available from contract v0.2.0 onward; a module that needs nothing omits them and is unaffected. The full authoring walkthrough is how to require cluster resources, and the loop that drives it is covered in required resources and the fixpoint.

Emitting selectors: #Transform.requirements

out.requirements is an optional, author-keyed map of selectors the module emits for Crossplane to fetch (and that cuefn render --required-resources matches against locally):

out: requirements: cfg: contract.#Requirement & {
    apiVersion: "v1"
    kind:       "ConfigMap"
    matchName:  input.spec.configName
    namespace:  input.metadata.namespace
}

Each entry is a #Requirement:

Field Required Description
apiVersion yes API version of the kind to fetch.
kind yes Kind to fetch.
matchName one of Exact metadata.name of the object.
matchLabels one of Label map; an object matches when it carries all of them.
namespace no Namespace to read. Omitted, it reads a cluster-scoped kind, or lists a namespaced kind across all namespaces.

The engine enforces that each requirement sets exactly one of matchName or matchLabels at render time (in readRequirements); setting neither or both is an error naming the offending requirement. out.requirements must be a pure function of stable inputs (spec/metadata/environment) so it is identical every pass and the fixpoint converges — see the explanation.

Receiving objects: #Input.requiredResources

out.input.requiredResources is the matching optional field the engine fills with the objects Crossplane delivered, keyed by the same requirement name:

requiredResources?: [string]: [...#Required]

A populated entry holds the matched objects; an empty list means "requested, none found". cuefn seeds an empty list for every declared requirement name before it reads out.resources, so a guard on input.requiredResources[name] stays concrete on the first pass with no author default. #Required is intentionally open (any Kubernetes kind) — the author dereferences whatever fields the fetched object carries:

#Required: {
    apiVersion: string
    kind:       string
    ...
}

Read the bucket back with a for comprehension or an if len(...) > 0 guard; both work because the seeded value is a concrete list. See how to require cluster resources.

Author-time validation with the contract module

The contract above is published as a CUE module of closed definitions, github.com/meigma/crossplane-cuefn/contract@v0. Importing it and unifying your module against it validates the shape at author time (cue vet -c=false / editor) — a misspelled or unknown field is rejected before render. The example module adopts it:

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

#API: contract.#API & {group: "platform.meigma.io", version: "v1alpha1", kind: "XApp", plural: "xapps"}
out: contract.#Transform & {input: {/* ... */}, resources: {/* ... */}, status: #Status & {/* ... */}}

#Spec and #Status are not wrapped — they are your own schemas and feed the XRD codegen. Adoption is optional (the plain shape renders identically); it adds the in-editor guarantee.

contract.#API is itself closed and defines only group/version/kind/plural/scope. The optional shortNames, categories, and printerColumns fields listed above are read by the CLI codegen from a plain #API, but they are not part of the contract envelope — unifying them with contract.#API is a cue vet error. Omit them to adopt contract.#API, or keep a plain #API if you need them. See How to enforce the module contract.

What a render produces

Rendering the example module against example/xr.yaml (replicas: 2) with no environment yields:

resources:
  config:
    object:
      apiVersion: v1
      kind: ConfigMap
      data:
        tier: unset
      metadata:
        name: demo
        labels: {app: demo, tier: unset}
    ready: Unspecified
  deployment:
    object:
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: demo
        labels: {app: demo, tier: unset}
      spec:
        replicas: 2
        # ...
    ready: "True"
  service:
    object:
      apiVersion: v1
      kind: Service
      # ...
    ready: "False"
status:
  ready: true
  url: http://demo.svc

With an environment supplying tier: production, the tier label and the ConfigMap datum become production instead of the "unset" default — proving the environment flows through. Reproduce both with render locally.

Runtime behaviors

Two engine behaviors are part of the contract, not optional add-ons.

Reserved-key projection

Before unifying the observed spec against the closed #Spec, the engine strips the Crossplane-reserved crossplane block and legacy machinery keys (compositionRef, resourceRefs, claimRef, environmentConfigRefs, and others). This lets #Spec stay closed without conflicting with fields the author never declared. The stripping happens in the engine; the schema stays closed. Full list and rationale in reserved-key projection.

Digest verify-after-fetch

CUE references modules by semver, not digest. To lock the runtime to the exact module bytes the Configuration was built from, the loader re-resolves the tag on every load, computes the manifest digest, and verifies it against an expected digest (from the Composition's Input.ExpectedDigest). A mismatch fails the render. The publish-time half records the live digest. See the digest lock-step.

Authoring guardrails

The one real constraint on the schema definitions is that type-crossing disjunctions are not expressible. A field whose type spans two kinds (string | int, or a struct union {a} | {b}) generates an OpenAPI oneOf, which Kubernetes structural schemas reject. cuefn generate detects this and fails with a DisjunctionError naming the offending field.

Same-type disjunctions are fine and become an enum:

scope:    *"Namespaced" | "Cluster"   // ok — string enum
port:     80 | 443                    // ok — integer enum
mixed:    string | int                // rejected — DisjunctionError

This is a Kubernetes CRD limitation surfaced early, not a cuefn restriction.