Skip to main content

Module 3 — Define an Application XRD + Composition

Your pair:

This is the module where the whole workshop clicks together. You'll define:

  • A Composite Resource Definition (XRD) that declares a new API called Application, with two fields: message and color.
  • A Composition that says: "when someone creates an Application claim, materialize a frontend (nginx serving a static page) and a backend (http-echo returning the claim's fields), each with their own Deployment and Service."
  • An Application claim that triggers the composition and lights up your tile on the workshop wall.

Every command here runs inside your vcluster. Reconnect if needed:

vcluster connect <pair-id> -n participant-<pair-id>

3.1 Apply the XRD

A CompositeResourceDefinition is how you extend Crossplane with a new API. It defines a composite kind (cluster-scoped, internal) and a claim kind (namespaced, what users apply). Here, XApplication is the composite and Application is the claim:

kubectl apply -f - <<'EOF'
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xapplications.workshop.example.io
spec:
group: workshop.example.io
names:
kind: XApplication
plural: xapplications
claimNames:
kind: Application
plural: applications
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

Check that Crossplane accepted it:

kubectl get xrd xapplications.workshop.example.io
# NAME ESTABLISHED OFFERED AGE
# xapplications.workshop.example.io True True 5s

ESTABLISHED=True means the composite CRD exists, and OFFERED=True means the claim CRD exists. Both need to be True before you can apply a claim.

3.2 Apply the Composition

The Composition is where the interesting wiring lives. In Crossplane v2, Compositions are a pipeline of one or more functions. Each function gets the previous function's output plus its own input, and the last function's output is what Crossplane reconciles into managed resources. You installed function-patch-and-transform in module 2; this Composition uses it as a single-step pipeline that takes a list of resources and patches — the same shape Crossplane v1 used inline — and emits the five Objects that provider-kubernetes reconciles: a ConfigMap with the HTML, a frontend Deployment + Service, and a backend Deployment + Service. The message and color fields from the claim are patched into the http-echo container args at reconcile time.

apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: applications.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: kubernetes.crossplane.io/v1alpha2
kind: Object
spec:
forProvider:
manifest:
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>
- name: frontend-deployment
base:
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
spec:
forProvider:
manifest:
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: nginx:alpine
ports:
- containerPort: 80
volumeMounts:
- name: html
mountPath: /usr/share/nginx/html
volumes:
- name: html
configMap:
name: frontend
- name: frontend-service
base:
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
spec:
forProvider:
manifest:
apiVersion: v1
kind: Service
metadata:
name: frontend
namespace: default
spec:
selector: { app: frontend }
ports:
- port: 80
targetPort: 80
- name: backend-deployment
base:
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
spec:
forProvider:
manifest:
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: http-echo
image: hashicorp/http-echo:1.0.0
args:
- '-listen=:5678'
- '-text={"message":"placeholder","color":"#2563eb"}'
ports:
- containerPort: 5678
patches:
- type: CombineFromComposite
combine:
variables:
- fromFieldPath: spec.message
- fromFieldPath: spec.color
strategy: string
string:
fmt: '-text={"message":"%s","color":"%s"}'
toFieldPath: spec.forProvider.manifest.spec.template.spec.containers[0].args[1]
- name: backend-service
base:
apiVersion: kubernetes.crossplane.io/v1alpha2
kind: Object
spec:
forProvider:
manifest:
apiVersion: v1
kind: Service
metadata:
name: backend
namespace: default
spec:
selector: { app: backend }
ports:
- port: 5678
targetPort: 5678

Save that to a file and apply it:

kubectl apply -f composition.yaml

Or paste it inline via kubectl apply -f - <<'EOF' ... EOF.

3.3 Apply your claim

Now that the API exists, you create an Application claim the same way you'd create any other Kubernetes object. Change message to whatever you want — it's the text that will show up on your tile on the wall.

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

Watch everything reconcile:

kubectl get application wall-tile
# NAME SYNCED READY COMPOSITION AGE
# wall-tile True True applications.workshop.example.io 20s

kubectl get deploy,svc,cm
# NAME READY UP-TO-DATE AVAILABLE AGE
# deployment.apps/backend 1/1 1 1 18s
# deployment.apps/frontend 1/1 1 1 18s
#
# NAME TYPE CLUSTER-IP PORT(S) AGE
# service/backend ClusterIP 10.x.y.z 5678/TCP 18s
# service/frontend ClusterIP 10.a.b.c 80/TCP 18s
#
# NAME DATA AGE
# configmap/frontend 1 18s

3.4 Verify and see your tile on the wall

When the check turns green, open the workshop wall and click Refresh. Your tile — labeled with your pair ID — should light up with the colored message you put in the claim.

One origin, no CORS

Notice the frontend's HTML does fetch('./api/message') with a relative URL. Inside the iframe at /team/<pair>/, that resolves to /team/<pair>/api/message — the same host the iframe itself is loaded from, so no CORS dance. The management cluster's Ingress routes /team/<pair>/api/* to your backend Service and /team/<pair>/* to your frontend Service.

Troubleshooting

Check says "no Application claims found" : The claim applied to the wrong cluster, or to the wrong namespace. Confirm you're on the vcluster context and that kubectl get application -A lists it.

Claim exists but READY=False : Run kubectl describe application wall-tile and look at the Events section. Common causes: Composition not yet applied, provider-kubernetes not healthy (module 2), ProviderConfig/default missing, or an RBAC error on the provider SA (module 2.3).

Deployments exist but the tile shows "Error" or "Bad response" : The backend's -text arg didn't get patched, or http-echo is returning something the frontend can't parse. kubectl logs deploy/backend will show the value it was started with; it should look like -text={"message":"...","color":"..."}.