Skip to main content

Your first Composition ⏱️ 12m

Your pair:
Running solo locally?

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.

ComponentWhat it isObject scopeScope 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 XRDClusterNamespaced 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 CRDClusterNamespaced (v2 default), Cluster, or LegacyCluster, per the XRD's spec.scope
XR (Composite Resource)An instance of an XRD. Apply one and the Composition runsPer the XRD — Namespaced for the XHello you'll write
CompositionThe recipe. "When someone creates this kind of XR, produce these resources"Cluster
Composition functionPluggable logic the Composition's pipeline runs to produce desired state. function-patch-and-transform is the one you'll installCluster (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 ClusterRole labelled rbac.crossplane.io/aggregate-to-crossplane: "true" — Kubernetes' RBAC aggregator merges its rules into the existing crossplane ClusterRole at runtime. Module 5's Application stays within ConfigMap/Service/Deployment, so we don't need to. A future 2xx module that composes, say, a Job would.
  • Production-grade RBAC is narrower. UXP's broad grant is fine for a workshop cluster; in production you'd reach for a namespace-scoped RoleBinding with 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 ConfigMap is nakedapiVersion: v1, kind: ConfigMap directly under base:. 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 makes function-patch-and-transform a complete Composition runtime by itself.
  • The Composition is apiextensions.crossplane.io/v1 even though the XRD is /v2. That's not a typo — the XRD API group bumped to /v2 to express namespaced XRs, but Composition stays on /v1.
  • The ConfigMap's metadata.namespace is hardcoded to default. 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] tells function-patch-and-transform to mark this resource Ready as soon as Crossplane has observed it in the cluster. Without that, the function waits for a Ready condition on the resource, which ConfigMap never publishes — and the XHello XR would stay Ready=False forever. For a Deployment you'd use MatchCondition on Available=True instead; 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.