Quickstart¶
In this tutorial we will take a platform API from a blank directory to a running Crossplane resource: write one CUE module, render it locally, publish it, build and install a Configuration, and watch an XR render. By the end you will have seen the whole cuefn loop end to end.
The repository ships a complete example under example/. We will build the same
shape from scratch so each step is visible.
Prerequisites¶
cuefnandcueon your PATH. See installing the CLI for Homebrew, Scoop, mise, Nix, andgo install. Working in this repo instead?mise install(see local toolchain) thenmise exec -- ..., orgo build -o bin/cuefn ./cmd/cuefn.- An OCI registry for the CUE module. A plain-HTTP local registry is fine for the local steps; for the cluster steps the module registry must be reachable from the cluster (see configure the function runtime).
- For the cluster steps: a Crossplane v2 cluster and an HTTPS registry for the Configuration and Function packages.
Step 1 — Write the module¶
Create a module directory with a cue.mod/module.cue:
module: "cuefn.example/app@v0"
language: {
version: "v0.16.0"
}
source: {
kind: "self"
}
We will instantiate the Kubernetes objects from the official schema, which adds a
deps block. You do not write it by hand — cue mod tidy (below) fills it in.
Declare the platform API and the spec schema. #API is the XRD envelope; #Spec
is the closed, authoritative spec schema (with defaults and bounds):
package app
#API: {
group: "platform.meigma.io"
version: "v1alpha1"
kind: "XApp"
plural: "xapps"
scope: *"Namespaced" | "Cluster"
}
#Spec: {
image: string | *"ghcr.io/stefanprodan/podinfo:6.7.0"
replicas: *1 | int & >=1 & <=10
}
#Status: {
ready: bool
url: string
}
Now the transform, nested under a single out field. The engine fills
out.input (spec, metadata, environment); we read it and return an author-keyed
out.resources map and an out.status. We instantiate each object from the
official Kubernetes schema (cue.dev/x/k8s.io) so apiVersion/kind come from
the definition and an invalid object is caught here, at render time, not on apply:
package app
import (
appsv1 "cue.dev/x/k8s.io/api/apps/v1"
corev1 "cue.dev/x/k8s.io/api/core/v1"
)
out: {
input: {
spec: #Spec
metadata: {
name: string | *"app"
namespace?: string
...
}
environment: {
tier: string | *"unset"
...
}
}
_name: input.metadata.name
_tier: input.environment.tier
resources: {
deployment: {
ready: "Ready"
object: appsv1.#Deployment & {
metadata: {name: _name, labels: {app: _name, tier: _tier}}
spec: {
replicas: input.spec.replicas
selector: matchLabels: app: _name
template: {
metadata: labels: {app: _name, tier: _tier}
spec: containers: [{
name: "app"
image: input.spec.image
ports: [{containerPort: 9898}]
}]
}
}
}
}
config: {
ready: "Ready"
object: corev1.#ConfigMap & {
metadata: {name: _name, labels: {app: _name, tier: _tier}}
data: tier: _tier
}
}
}
status: #Status & {
ready: true
url: "http://\(_name).svc"
}
}
Binding out.input.spec: #Spec is the key move: the schema the XRD is generated
from is the same value the transform renders against, so the two never drift.
The ConfigMap carries ready: "Ready" because the supported Crossplane runtime
counts only an explicit function readiness hint. Without one it does not infer
readiness from the object, and the XR is held at Ready=False (see the
readiness mapping).
Validate the shape at author time
The example also imports github.com/meigma/crossplane-cuefn/contract@v0 and
writes #API: contract.#API & {…} / out: contract.#Transform & {…}, so a
misspelled or unknown field is caught by cue vet -c=false ./... (or your
editor). It's optional — see
How to enforce the module contract.
Resolve the new dependency, which records it in cue.mod/module.cue:
This fetches cue.dev/x/k8s.io from the default central registry
(registry.cue.works) — no CUE_REGISTRY needed for public dependencies — and
writes a deps block. There is no separate cue.sum; modern CUE records the
resolved versions inline.
Step 2 — Render it locally¶
Before touching a registry or cluster, render the module against an XR. Create a sample XR:
apiVersion: platform.meigma.io/v1alpha1
kind: XApp
metadata:
name: demo
namespace: default
spec:
image: ghcr.io/stefanprodan/podinfo:6.7.0
replicas: 2
Render it from the directory:
--dir serves the module from disk; its cue.dev/x/k8s.io dependency resolves
from the central registry on the first run (and is cached after), so this stays
cluster-free. You will see a resources map (a Deployment with replicas: 2, a
ConfigMap, both marked "True") and a status. Add --env to see the
environment flow through:
echo 'tier: production' > env.yaml
cuefn render cuefn.example/app@v0 --dir . --xr xr.yaml --env env.yaml
The tier label and ConfigMap datum change from unset to production. This
--dir render is your fast inner loop — iterate here until the output is right.
Step 3 — Configure module publication¶
Publish the module to its CUE registry. Start a throwaway local registry first —
the container listens on 5000, and we publish it on host port 5001 to avoid the
macOS AirPlay receiver that holds localhost:5000 — then point CUE_REGISTRY at
it. A plain-HTTP registry needs the +insecure suffix:
docker run -d -p 5001:5000 --name cuefn-registry registry:2
export CUE_REGISTRY=localhost:5001+insecure
cue mod tidy --check
The next command publishes the module and Configuration together, avoiding the ordering hazard of separately publishing local source and then resolving its registry digest.
The cluster fetches the module too
The in-cluster function pulls this module at render time, so for Steps 5–6 the
module registry must be reachable from the cluster and the function pointed
at it — see
configure the function runtime. A
localhost registry is fine for the local steps here.
Step 4 — Build and push the Configuration¶
Now turn the published module into an installable Configuration. The Configuration and Function packages go to an HTTPS registry (Crossplane's package manager is HTTPS-only), distinct from the CUE module registry:
cuefn publish cuefn.example/app@v0.1.0 \
--dir . \
--publish-module \
--metadata org.opencontainers.image.source=https://github.com/example/platform-modules \
--package registry.example.com/xapp-configuration:v0.1.0 \
--environment-config app-environment
By default cuefn publish builds a single-step cuefn Composition. Passing
--environment-config app-environment adds a function-environment-configs step
(so the named EnvironmentConfig's values reach the module under
out.input.environment) and declares both functions in the Configuration's
dependsOn, so installing it pulls them automatically. It also records the module
ref and the exact manifest digest prepared and published in the same transaction
(the runtime
digest lock-step). Confirm the package parses:
# --from-daemon reads from the local Docker daemon, so pull the pushed package first
docker pull registry.example.com/xapp-configuration:v0.1.0
crossplane xpkg extract --from-daemon registry.example.com/xapp-configuration:v0.1.0 -o out.gz
Step 5 — Install the Configuration¶
Install only the Configuration. Crossplane reads its dependsOn and
auto-installs both functions — the cuefn function (as meigma-function-cuefn) and
function-environment-configs — so there is nothing else to apply:
apiVersion: pkg.crossplane.io/v1
kind: Configuration
metadata:
name: xapp
spec:
package: registry.example.com/xapp-configuration:v0.1.0
Do not hand-install the cuefn Function
Let dependsOn install it. Applying your own Function that points at the
same package source as the Configuration's dependency puts two Functions on one
source and poisons Crossplane's package Lock — every package then goes
unhealthy with node … already exists.
Point the function at your module registry. The installed function fetches the
module at render and defaults to the CUE central registry, so a non-central
registry is supplied through a DeploymentRuntimeConfig bound to the function.
Once the dependency is installed:
apiVersion: pkg.crossplane.io/v1beta1
kind: DeploymentRuntimeConfig
metadata:
name: cuefn-runtime
spec:
deploymentTemplate:
spec:
selector: {}
template:
spec:
containers:
- name: package-runtime
env:
# Prefix form: cuefn.example/* resolves from your registry, while
# central stays the fallback for public dependencies.
- name: CUE_REGISTRY
value: "cuefn.example=modules.example.com"
kubectl apply -f runtime.yaml
kubectl patch function.pkg.crossplane.io meigma-function-cuefn --type merge \
-p '{"spec":{"runtimeConfigRef":{"name":"cuefn-runtime"}}}'
The cache needs no configuration — the function falls back to a writable temp dir. For the full registry-routing recipe and the RBAC needed to compose native kinds beyond core workloads, see configure the function runtime.
Step 6 — Instantiate an XR and observe the result¶
Supply the EnvironmentConfig the Composition references (its name matches the
--environment-config from Step 4):
apiVersion: apiextensions.crossplane.io/v1beta1
kind: EnvironmentConfig
metadata:
name: app-environment
data:
tier: production
Apply it and the XR (the same xr.yaml from Step 2):
Watch the composite and its composed resources appear:
kubectl get xapp demo -o yaml # status.ready, status.url patched by the module
kubectl get deployment,configmap -l app=demo
The Deployment carries replicas: 2 from the XR spec, and the tier label and
ConfigMap datum read production from the EnvironmentConfig — the same output
you saw locally in Step 2, now rendered by the in-cluster function.
The shipped example is a teaching device
The module you built here marks every resource ready: "Ready", so its XR
reconciles to Ready=True. The repository's example/module deliberately
leaves its ConfigMap readiness unspecified (to show all three readiness
states), so that example's XR remains Ready=False indefinitely unless the
module emits an explicit Ready hint — see the
readiness mapping.
What you built¶
One CUE module became two artifacts — an XRD-bearing Configuration and the transform the runtime evaluates — from a single source of truth. To go deeper:
- The module contract — the full schema and transform surface.
- Check a module — the static health gate: formatting, vet, and XRD schema drift.
- Test a module — declarative test cases for the behavior you just rendered.
- Configure the function runtime — registry routing, the cache, and RBAC for composed kinds.
- One module, two outputs — why it is shaped this way.
- The how-to guides for each command in isolation.