Skip to content

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

  • cuefn and cue on your PATH. See installing the CLI for Homebrew, Scoop, mise, Nix, and go install. Working in this repo instead? mise install (see local toolchain) then mise exec -- ..., or go 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:

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):

api.cue
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:

transform.cue
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:

cue mod tidy

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:

xr.yaml
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:

cuefn render cuefn.example/app@v0 --dir . --xr xr.yaml

--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:

install.yaml
apiVersion: pkg.crossplane.io/v1
kind: Configuration
metadata:
  name: xapp
spec:
  package: registry.example.com/xapp-configuration:v0.1.0
kubectl apply -f install.yaml

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:

kubectl get functions.pkg.crossplane.io   # wait for meigma-function-cuefn INSTALLED=True
runtime.yaml
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):

environment.yaml
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):

kubectl apply -f environment.yaml
kubectl apply -f xr.yaml

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: