Your first Composition ⏱️ 12m
Same commands, same cluster. See Solo local setup (k3d).
4.1 Before you start ⏱️ 2m
Crossplane installs cleanly, but it doesn't do anything yet. You haven't told it what kinds of resources to create or how to create them. This module is the smallest possible end-to-end loop: define a tiny API, write a recipe that creates one ConfigMap, apply an instance, watch the ConfigMap appear.
You're using Crossplane v2, so the resources Crossplane composes can be plain Kubernetes objects — ConfigMap, Deployment, Service, anything. No provider package required for the basic case. (You'll see why provider packages still matter for some use cases in module 7.)
What's about to happen, in one picture
The thick arrows are work Crossplane does for you; the thin arrows are things you kubectl apply yourself. Each step in the rest of this module is one of the thin arrows.
The pieces in the picture, defined
Five terms, all introduced here. Treat the table as the diagram's glossary. The full v2-correct version — including the pieces you'll meet in modules 5 and 7 — lives in the Cheatsheet.
| Component | What it is | Object scope | Scope of the kind it defines / produces |
|---|---|---|---|
| CRD (Kubernetes) | Custom Resource Definition. Extends the API server with a new kind. You won't write one directly — Crossplane generates one for you when you apply an XRD | Cluster | Namespaced or Cluster, per the CRD's spec.scope |
| XRD (Composite Resource Definition) | Declares the schema of a new Crossplane API: what fields it has, what types. Applying it makes Crossplane create the matching CRD | Cluster | Namespaced (v2 default), Cluster, or LegacyCluster, per the XRD's spec.scope |
| XR (Composite Resource) | An instance of an XRD. Apply one and the Composition runs | Per the XRD — Namespaced for the XHello you'll write | — |
| Composition | The recipe. "When someone creates this kind of XR, produce these resources" | Cluster | — |
| Composition function | Pluggable logic the Composition's pipeline runs to produce desired state. function-patch-and-transform is the one you'll install | Cluster (it's a Crossplane package) | — |
You're about to: install one composition function, define an API called XHello (the leading X is the Crossplane convention — see the cheatsheet), write a recipe that turns each XHello into one ConfigMap, and apply an instance.
4.2 Install the composition function ⏱️ 3m
A Composition is a pipeline of one or more composition functions. The function turns the user's input (the XR's spec) into the desired managed resources. You'll use function-patch-and-transform, which takes a YAML resources-and-patches shape — the easiest function to read for a first Composition.
kubectl apply -f - <<'EOF'
apiVersion: pkg.crossplane.io/v1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.crossplane.io/crossplane-contrib/function-patch-and-transform:v0.9.0
EOF
Wait for it to go healthy (usually ~30 seconds):
kubectl get function
Expected output:
NAME INSTALLED HEALTHY PACKAGE
function-patch-and-transform True True xpkg.crossplane.io/...:v0.9.0
4.3 A note on RBAC ⏱️ 1m
Before you write the Composition, one Kubernetes truth worth surfacing. Crossplane core itself is what applies the resources a Composition emits — like every controller, it can only create kinds it has RBAC for. The UXP chart's bundled crossplane ClusterRole already grants wildcard verbs on the kinds you'll use here (ConfigMap, Service, Deployment), so you don't have to touch RBAC for the basic flow:
kubectl auth can-i create configmaps \
--as=system:serviceaccount:crossplane-system:crossplane \
-n default
# → yes
Two reasons this matters anyway:
- Compose a different kind, you'll need to extend the grant. The pattern is an aggregated
ClusterRolelabelledrbac.crossplane.io/aggregate-to-crossplane: "true"— Kubernetes' RBAC aggregator merges its rules into the existingcrossplaneClusterRole at runtime. Module 5's Application stays within ConfigMap/Service/Deployment, so we don't need to. A future 2xx module that composes, say, aJobwould. - Production-grade RBAC is narrower. UXP's broad grant is fine for a workshop cluster; in production you'd reach for a namespace-scoped
RoleBindingwith explicit verbs. Crossplane's composition concepts link to the upstream guidance.
This is the trade-off providerless makes explicit. With provider-kubernetes, the equivalent grant lives on the provider's ServiceAccount via its ProviderConfig — same end state, hidden behind one more layer.
4.4 Define the API (XRD) ⏱️ 3m
The XRD declares your new API. XHello will be namespaced (v2 default) with one required field, message.
kubectl apply -f - <<'EOF'
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: xhellos.workshop.example.io
spec:
scope: Namespaced
group: workshop.example.io
names:
kind: XHello
plural: xhellos
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
message:
type: string
required:
- message
EOF
Verify:
kubectl get xrd xhellos.workshop.example.io
Expected output:
NAME ESTABLISHED OFFERED AGE
xhellos.workshop.example.io True 5s
ESTABLISHED=True means the XHello CRD is registered; you can now apply XHellos. The OFFERED column stays empty because v2 does not generate a claim kind — apply XRs directly.
4.5 Define the recipe (Composition) ⏱️ 3m
The Composition is the recipe. It says: "for every XHello that exists, produce one ConfigMap whose data.greeting contains that XHello's spec.message."
kubectl apply -f - <<'EOF'
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xhellos.workshop.example.io
spec:
compositeTypeRef:
apiVersion: workshop.example.io/v1alpha1
kind: XHello
mode: Pipeline
pipeline:
- step: render-configmap
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: greeting-configmap
base:
apiVersion: v1
kind: ConfigMap
metadata:
namespace: default
data:
greeting: "placeholder"
readinessChecks:
- type: None
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: metadata.name
- type: FromCompositeFieldPath
fromFieldPath: spec.message
toFieldPath: data.greeting
EOF
Four things worth pointing out:
- The composed
ConfigMapis naked —apiVersion: v1, kind: ConfigMapdirectly underbase:. There's no provider-package wrapper around it. Crossplane core itself reads the desired state out of the function's output and applies the ConfigMap. This is the v2 path that makesfunction-patch-and-transforma complete Composition runtime by itself. - The
Compositionisapiextensions.crossplane.io/v1even though the XRD is/v2. That's not a typo — the XRD API group bumped to/v2to express namespaced XRs, butCompositionstays on/v1. - The
ConfigMap'smetadata.namespaceis hardcoded todefault. v2 would default it to the XR's namespace if you left it off; we hardcode for the workshop so the validator always finds it in the same place. readinessChecks: [type: None]tellsfunction-patch-and-transformto mark this resource Ready as soon as Crossplane has observed it in the cluster. Without that, the function waits for aReadycondition on the resource, whichConfigMapnever publishes — and theXHelloXR would stayReady=Falseforever. For aDeploymentyou'd useMatchConditiononAvailable=Trueinstead; module 5's Composition does that.
4.6 Use it ⏱️ 2m
Apply an XHello XR:
kubectl apply -f - <<'EOF'
apiVersion: workshop.example.io/v1alpha1
kind: XHello
metadata:
name: hello-world
namespace: default
spec:
message: "Hello from my first Composition!"
EOF
Watch it reconcile:
kubectl get xhello -n default
Expected output:
NAME SYNCED READY COMPOSITION AGE
hello-world True True xhellos.workshop.example.io 10s
And the underlying ConfigMap exists, with the message you provided:
kubectl get configmap hello-world -n default -o jsonpath='{.data.greeting}'
Expected output:
Hello from my first Composition!
When the tile turns green, your first Composition is working end-to-end.
4.7 What just happened
You defined a new Kubernetes API — XHello — and taught Crossplane how to turn each XHello into a ConfigMap. From a participant's view, that's the entire Crossplane contract: declare a high-level kind, declare a recipe, apply instances.
The shape repeats in module 5 with a more interesting recipe: the next module's XApplication XR turns into a frontend, a backend, and the ConfigMap that wires them together — same XRD-and-Composition pattern, just five resources instead of one.
Try editing spec.message on the XHello XR (kubectl edit xhello hello-world -n default) and re-running the kubectl get configmap command. The ConfigMap's data.greeting updates within seconds. That's reconciliation in action.
To clean up:
kubectl delete xhello hello-world -n default
The ConfigMap goes with it (Crossplane sets owner refs on composed resources).
Go deeper
- Crossplane's own Get started with Composition — same pattern, more languages (Python, KCL, Go templating).
- function-patch-and-transform README — patch types and transforms.
- "What about
provider-kubernetes?" — still useful when you need to adopt an existing in-cluster resource, run server-side apply with explicit conflict handling, or reach across clusters. A future 2xx module covers those cases. For creating new resources, the providerless path you just used is the v2 idiom.