Skip to main content

Define an Application ⏱️ 30m

Your pair:
Running solo locally?

Same cluster, same commands. Your tile at /team/local/ will light up once the check passes. See Solo local setup (k3d).

5.1 Before you start ⏱️ 3m

This is the module where the workshop clicks together. You met the XRD-Composition-XR shape in module 4 on a tiny XHello example — one XR fanned out into one ConfigMap. This module uses the same shape on something useful: a high-level XApplication kind that fans out into a frontend, a backend, and the ConfigMap that wires them together. (X prefix is the convention from the cheatsheet.)

As a refresher: an XR triggers a Composition; the Composition's pipeline runs a function (function-patch-and-transform here, installed in module 4); the function's output is the desired-state YAML Crossplane then applies to the cluster.

The composition function from module 4 (function-patch-and-transform) is already installed. No new core-object types this module — but the Composition itself uses three new pipeline mechanics. Here's the shape, abbreviated:

# inside spec.pipeline[0].input.resources, an array of any length:
resources:
- name: backend-deployment # internal label for this composed resource
base: # the resource template — naked native kind
apiVersion: apps/v1
kind: Deployment
...
patches: # connect XR fields → this resource's fields
- type: CombineFromComposite # combine multiple XR fields via a format string
combine: { ... }
toFieldPath: data.message # patches the backend ConfigMap, not the Deployment
readinessChecks: # when does Crossplane mark this Ready?
- type: MatchCondition # waits for Available=True (Deployments do this)
matchCondition: { ... }
- name: frontend-configmap
base: { ... }
readinessChecks:
- type: None # "Ready as soon as observed" (ConfigMaps, Services)

You met resources[] (with one entry), FromCompositeFieldPath patches, and readinessChecks: [- type: None] in module 4. New here: multiple resources[] entries, the CombineFromComposite patch type (formats multiple XR fields into one composed-resource field with a printf-style template), and MatchCondition readinessChecks (wait for a specific status condition rather than treating "observed" as Ready).

You're about to: apply an XRD, apply a Composition that uses all three of these mechanics, apply one XR, and watch a working two-tier app materialize on your tile.

5.2 Build the Application API ⏱️ 22m

1. Apply the XRD

The XRD declares your new API. XApplication will be namespaced (v2 default) with two fields: a message (required) and a color (optional, defaulted).

kubectl apply -f - <<'EOF'
apiVersion: apiextensions.crossplane.io/v2
kind: CompositeResourceDefinition
metadata:
name: xapplications.workshop.example.io
spec:
scope: Namespaced
group: workshop.example.io
names:
kind: XApplication
plural: xapplications
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
message:
type: string
color:
type: string
default: "#2563eb"
required:
- message
EOF

Verify:

kubectl get xrd xapplications.workshop.example.io

Expected output:

NAME                                ESTABLISHED   OFFERED   AGE
xapplications.workshop.example.io True 5s

ESTABLISHED=True means the XApplication CRD is registered; you can now apply XRs. The OFFERED column stays empty because v2 does not generate a claim kind — in v1 it would show True if the XRD declared claimNames, but v2 lets you apply the XR directly so there's nothing to offer.

2. Apply the Composition

The Composition is the recipe. It creates six resources when an XApplication exists. The picture, then the table, then the YAML.

The fan-out, in one picture

The dashed arrow from the XR is the patch — the only place the XR's message and color fields actually flow into one of the composed resources. Everything else in the Composition is static template content. The two volumeMounts arrows are plain Kubernetes references (each Deployment mounts its ConfigMap by name) — nothing Crossplane-specific.

What this Composition produces

Composed resource (resources[].name)Base kindWhy it's therePatches appliedReadiness check
frontend-configmapConfigMapHolds the static HTML the frontend nginx serves. Ships an inline <script> that fetches ./api/message and renders the JSON as the tile contentnone — every XApplication gets the same HTMLtype: None — ConfigMaps don't expose a Ready condition; treat as Ready on observation
frontend-deploymentDeploymentRuns nginx:alpine, mounts the ConfigMap above at /usr/share/nginx/htmlnone — the deployment shape is identical for every XApplicationtype: MatchCondition waiting for Available=True
frontend-serviceServiceExposes the frontend on port 80; selected by app: frontend. The wall iframe targets /team/<pair>/ which the workshop's HTTPRoute resolves to this Servicenonetype: None
backend-configmapConfigMapHolds the JSON body the frontend's JS fetches from /api/message. The XR's message and color flow into this ConfigMap's data.message field via the patchoneCombineFromComposite writes a JSON-formatted string into data.messagetype: None
backend-deploymentDeploymentRuns nginx:alpine, mounts backend-configmap at /usr/share/nginx/html/api/ so requests to /api/message return the patched ConfigMap entry directlynone — the deployment shape is identical for every XApplicationtype: MatchCondition waiting for Available=True
backend-serviceServiceExposes the backend on port 5678 externally; forwards to nginx on targetPort: 80. The HTTPRoute resolves /team/<pair>/api/ to this Servicenonetype: None

The interesting wiring is the patch on backend-configmap. The XR ships two scalar fields:

spec:
message: "hello"
color: "#10b981"

The frontend's JS fetches ./api/message and expects a JSON body shaped like {"message":"…","color":"…"}. We don't need a server to compute that response — the backend Deployment is plain nginx:alpine and mounts backend-configmap at /usr/share/nginx/html/api/, so the file at /api/message is the ConfigMap's data.message entry. CombineFromComposite writes a JSON string into that entry with a printf-style template:

patches:
- type: CombineFromComposite
combine:
variables:
- fromFieldPath: spec.message
- fromFieldPath: spec.color
strategy: string
string:
fmt: '{"message":"%s","color":"%s"}'
toFieldPath: data.message

toFieldPath is the path inside the naked ConfigMap (compare to module 4's Object-MR shape, which would have prefixed it with spec.forProvider.manifest.data.message). At reconcile time, Crossplane substitutes the variables into the template and writes the result into data.message. nginx serves whatever's there.

Apply the YAML

kubectl apply -f - <<'EOF'
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xapplications.workshop.example.io
spec:
compositeTypeRef:
apiVersion: workshop.example.io/v1alpha1
kind: XApplication
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef:
name: function-patch-and-transform
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: frontend-configmap
base:
apiVersion: v1
kind: ConfigMap
metadata:
name: frontend
namespace: default
data:
index.html: |
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Tile</title>
<style>
body { font-family: sans-serif; margin: 0; padding: 20px; text-align: center; }
#tile { font-size: 2rem; font-weight: 700; }
</style>
</head><body>
<div id="tile">Loading...</div>
<script>
fetch('./api/message')
.then(r => r.text())
.then(txt => {
try {
const d = JSON.parse(txt);
const el = document.getElementById('tile');
el.innerText = d.message || '(no message)';
if (d.color) el.style.color = d.color;
} catch (e) {
document.getElementById('tile').innerText = 'Bad response: ' + txt;
}
})
.catch(e => {
document.getElementById('tile').innerText = 'Error: ' + e.message;
});
</script>
</body></html>
readinessChecks:
- type: None
- name: frontend-deployment
base:
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontend
namespace: default
spec:
replicas: 1
selector:
matchLabels: { app: frontend }
template:
metadata:
labels: { app: frontend }
spec:
containers:
- name: nginx
image: public.ecr.aws/docker/library/nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
volumes:
- name: html
configMap:
name: frontend
readinessChecks:
- type: MatchCondition
matchCondition:
type: Available
status: "True"
- name: frontend-service
base:
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: default
spec:
selector: { app: frontend }
ports:
- port: 80
targetPort: 80
readinessChecks:
- type: None
- name: backend-configmap
base:
apiVersion: v1
kind: ConfigMap
metadata:
name: backend
namespace: default
data:
message: '{"message":"placeholder","color":"#2563eb"}'
readinessChecks:
- type: None
patches:
- type: CombineFromComposite
combine:
variables:
- fromFieldPath: spec.message
- fromFieldPath: spec.color
strategy: string
string:
fmt: '{"message":"%s","color":"%s"}'
toFieldPath: data.message
- name: backend-deployment
base:
apiVersion: apps/v1
kind: Deployment
metadata:
name: backend
namespace: default
spec:
replicas: 1
selector:
matchLabels: { app: backend }
template:
metadata:
labels: { app: backend }
spec:
containers:
- name: nginx
image: public.ecr.aws/docker/library/nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: api
mountPath: /usr/share/nginx/html/api
volumes:
- name: api
configMap:
name: backend
readinessChecks:
- type: MatchCondition
matchCondition:
type: Available
status: "True"
- name: backend-service
base:
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: default
spec:
selector: { app: backend }
ports:
- port: 5678
targetPort: 80
readinessChecks:
- type: None
EOF

One v2 quirk worth pointing out before you move on: the CompositeResourceDefinition above is apiextensions.crossplane.io/v2, but the Composition here is still apiextensions.crossplane.io/v1. That is not a typo — in Crossplane v2 the XRD API group bumped to /v2 to express namespaced XRs, but Composition stays on /v1. Mixing them is expected.

3. Apply your XApplication XR

Now that the API exists, apply an instance. Change message to whatever you want — that's the text your tile will show.

kubectl apply -f - <<'EOF'
apiVersion: workshop.example.io/v1alpha1
kind: XApplication
metadata:
name: wall-tile
namespace: default
spec:
message: "hello from <your pair>"
color: "#10b981"
EOF

Watch everything materialize:

kubectl get xapplication wall-tile -n default
kubectl get deploy,svc,cm -n default

Expected output (abridged):

NAME        SYNCED   READY   COMPOSITION                          AGE
wall-tile True True xapplications.workshop.example.io 30s

NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/backend 1/1 1 1 25s
deployment.apps/frontend 1/1 1 1 25s

When the tile turns green, open the workshop wall and click Refresh — your tile should light up with the colored message you put in the XR.

5.3 What just happened

You extended Kubernetes with an XApplication kind. One kubectl apply now fans out into six resources Crossplane composes directly: two Deployments, two Services, and two ConfigMaps. The XR's message and color fields flow through the Composition's patch into the backend ConfigMap's data.message, which nginx serves verbatim — so you have a typed, field-driven interface to a multi-resource stack, and you've stayed in the providerless v2 path you used in module 4.

That's Crossplane's central value: compose your own APIs, and let Kubernetes and providers do the rest.

Go deeper