Platform API & Self-Service Labs
Reading about CustomResourceDefinition teaches you the words. Typing one, watching the API server reject your typo, and then writing the controller that makes it real — that teaches you the craft, and the craft is what the CNPE actually scores. These twelve labs walk the whole arc of the Platform APIs domain on a laptop-sized cluster: you’ll invent a new noun, defend it with a schema and CEL rules, give it a verb with Kubebuilder, prove that owner references, finalizers and status conditions do what the docs claim, compose real infrastructure with Crossplane, debug a claim that refuses to go Ready, and finally hand the whole thing to a developer through a Backstage template. Progress saves in this browser.
Kubernetes came with a box of Lego bricks. Today you invent your own brick — a “Database” brick — and teach the box what a good one looks like, so it refuses the broken ones. Then you build the little robot that sees your brick and quietly assembles the two hundred real pieces behind it. At the very end you put the brick in a vending machine so your friend can grab one without ever meeting you.
Before you start
Every lab here runs on a local, disposable cluster — nothing touches production and nothing costs money. You’ll want Docker (or Podman), kind or minikube, kubectl, helm, git, plus Go 1.22+ and the kubebuilder CLI from Lab 5 on, and Node 20+ for the capstone. Tool versions, flags and package tags drift constantly — Crossplane’s composition syntax and Backstage’s scaffolder in particular. Treat every version string below as an example, not gospel: follow each project’s current quickstart, and when a command errors, check the project’s docs before you assume you mistyped. Tear it all down at the end with kind delete cluster and nothing lingers.
Start the cluster and a scratch namespace before Lab 1: kind create cluster --name capi-lab then kubectl create namespace checkout. Confirm you’re pointed at the right place with kubectl config current-context — every single command below assumes your local cluster, never a shared one. Work the labs in order: each one builds on the object the previous one created, exactly like the concept lesson at Platform APIs, CRDs & Operators builds on itself. When something breaks — and it will — that is the lab. Keep the command reference and the troubleshooting playbook open in another tab.
The labs
# db-crd.yaml — Lab 1. A platform API with a schema, printer columns and /status.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: databases.platform.acme.io # MUST be <plural>.<group>
spec:
group: platform.acme.io
scope: Namespaced
names:
kind: Database
plural: databases
singular: database
shortNames: [db]
categories: [platform]
versions:
- name: v1alpha1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [engine, team]
properties:
engine: { type: string, enum: [postgres, mysql] }
version: { type: string, default: "16" }
size: { type: string, enum: [small, medium, large], default: small }
replicas: { type: integer, minimum: 1, maximum: 5, default: 1 }
team: { type: string, pattern: "^[a-z][a-z0-9-]{2,20}$" }
highAvailability: { type: boolean, default: false }
status:
type: object
properties:
phase: { type: string }
endpoint: { type: string }
observedGeneration: { type: integer }
conditions:
type: array
x-kubernetes-list-type: map
x-kubernetes-list-map-keys: [type]
items:
type: object
required: [type, status, lastTransitionTime, reason, message]
properties:
type: { type: string }
status: { type: string, enum: ["True", "False", "Unknown"] }
reason: { type: string }
message: { type: string }
observedGeneration: { type: integer }
lastTransitionTime: { type: string, format: date-time }
subresources:
status: {}
additionalPrinterColumns:
- { name: Engine, type: string, jsonPath: .spec.engine }
- { name: Size, type: string, jsonPath: .spec.size }
- { name: Team, type: string, jsonPath: .spec.team }
- { name: Phase, type: string, jsonPath: .status.phase }
- { name: Age, type: date, jsonPath: .metadata.creationTimestamp }db-crd.yaml and register it: kubectl apply -f db-crd.yaml. Now watch Kubernetes grow a new endpoint. Run kubectl api-resources --api-group=platform.acme.io — your kind is listed alongside Pods and Deployments. Read your own docs back with kubectl explain database.spec and kubectl explain database.spec.size. Finally check the CRD accepted itself: kubectl get crd databases.platform.acme.io -o jsonpath='{.status.conditions[?(@.type=="Established")].status}'.jsonpath prints True, kubectl api-resources --api-group=platform.acme.io lists databases with shortname db, and kubectl explain database.spec.engine shows your enum.# orders-db.yaml — Lab 2. Note what is NOT here: version, size, replicas, highAvailability. apiVersion: platform.acme.io/v1alpha1 kind: Database metadata: name: orders-db namespace: checkout spec: engine: postgres team: checkout
kubectl apply -f orders-db.yaml. Now enjoy the polish you built. kubectl get databases -n checkout prints your columns. kubectl get db -n checkout uses your shortname. kubectl get platform -n checkout lists everything in your category. Prove defaulting happened server-side: kubectl get db orders-db -n checkout -o jsonpath='{.spec.version}/{.spec.size}/{.spec.replicas}'. Then prove the /status subresource is real — kubectl patch db orders-db -n checkout --type=merge -p '{"status":{"phase":"Ready"}}' reports the object patched but .status.phase stays empty, while kubectl patch db orders-db -n checkout --subresource=status --type=merge -p '{"status":{"phase":"Ready"}}' sticks. (--subresource needs kubectl 1.24+.)kubectl get db -n checkout shows Engine/Size/Team/Phase columns, the jsonpath prints 16/small/1 even though you never wrote those fields, and Phase only becomes Ready via --subresource=status.# bad-db.yaml — Lab 3. Four deliberate crimes. Apply them ONE at a time.
apiVersion: platform.acme.io/v1alpha1
kind: Database
metadata: { name: bad-db, namespace: checkout }
spec:
engine: banana # 1. not in the enum -> rejected
team: Payments! # 2. fails the pattern -> rejected
replicas: 9 # 3. above maximum: 5 -> rejected
nickname: hunter2 # 4. undeclared field -> SILENTLY PRUNED, not rejectedkubectl apply -f bad-db.yaml. The API server validates the whole object and hands back every violation in one message, each one naming the exact field path — so fix them one at a time (engine: postgres, then team: payments, then replicas: 3), re-applying after each, and watch the list shrink by exactly one line. Then delete team entirely and watch the required rule fire on its own. When it finally applies, look for the fourth crime: kubectl get db bad-db -n checkout -o jsonpath='{.spec}'. nickname is gone — a structural schema prunes anything you didn’t declare, which is why every field your controller writes must appear in the schema. (Don’t grep the whole -o yaml for it: kubectl apply stores your raw submission in the kubectl.kubernetes.io/last-applied-configuration annotation, so the word still appears there. The stored object is what counts.)spec.<field>, and kubectl get db bad-db -n checkout -o jsonpath='{.spec}' prints a spec with no nickname key on the object that did apply.# Lab 4 — add to db-crd.yaml, then re-apply the CRD.
# (A) cross-field + object-level rules: put these on the spec object itself.
spec:
type: object
x-kubernetes-validations:
- rule: "!has(self.highAvailability) || !self.highAvailability || self.replicas >= 3"
message: "highAvailability requires replicas >= 3"
- rule: "self.size != 'small' || !self.highAvailability"
message: "small databases cannot be highly available — pick medium or large"
required: [engine, team]
properties:
# (B) a transition rule: oldSelf makes the field immutable after creation.
engine:
type: string
enum: [postgres, mysql]
x-kubernetes-validations:
- rule: "self == oldSelf"
message: "engine is immutable — create a new Database instead"x-kubernetes-validations blocks above into db-crd.yaml and re-apply the CRD. Now break each rule on purpose. Cross-field: apply a Database with highAvailability: true and replicas: 1 — rejected with your sentence, not a generic one. (You will see both messages: size defaults to small, so the second rule fires too. Every failing rule reports; they are not short-circuited.) Set size: large and replicas: 1 to isolate the first rule on its own. Immutability: kubectl patch db orders-db -n checkout --type=merge -p '{"spec":{"engine":"mysql"}}' — rejected, because oldSelf transition rules run on update only. Confirm the rule is genuinely server-side by trying the same patch with --dry-run=server: it fails there too, with no CRD change needed on the client.engine patch fails with the message “engine is immutable”, and an HA-with-one-replica resource is refused before it ever reaches etcd.# Lab 5 — scaffold the operator. The generated CRD REPLACES your hand-written one, # so clear the old registration first to avoid a confusing merge. kubectl delete crd databases.platform.acme.io mkdir -p ~/labs/db-operator && cd ~/labs/db-operator kubebuilder init --domain acme.io --repo acme.io/db-operator kubebuilder create api --group platform --version v1alpha1 --kind Database --resource --controller # --resource and --controller answer the two prompts up front; omit them and it asks y/n instead. # --domain acme.io + --group platform = the API group platform.acme.io from Lab 1. # edit api/v1alpha1/database_types.go and internal/controller/database_controller.go, # then regenerate + install + run the controller OUTSIDE the cluster: make manifests generate make install # applies config/crd/bases/*.yaml make run # leave this running in its own terminal
// api/v1alpha1/database_types.go — markers generate the schema you hand-wrote in Lab 1.
type DatabaseSpec struct {
// +kubebuilder:validation:Enum=postgres;mysql
// this marker is how your Lab 4 CEL survives regeneration — without it the
// generated CRD ships with no x-kubernetes-validations at all.
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="engine is immutable; create a new Database instead"
Engine string `json:"engine"`
// +kubebuilder:validation:Pattern=`^[a-z][a-z0-9-]{2,20}$`
Team string `json:"team"`
// +kubebuilder:default=small
// +kubebuilder:validation:Enum=small;medium;large
Size string `json:"size,omitempty"`
// +kubebuilder:default=1
// +kubebuilder:validation:Minimum=1
// +kubebuilder:validation:Maximum=5
Replicas int32 `json:"replicas,omitempty"`
}
type DatabaseStatus struct {
Phase string `json:"phase,omitempty"`
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
Conditions []metav1.Condition `json:"conditions,omitempty"`
}
// +kubebuilder:object:root=true
// +kubebuilder:subresource:status
// +kubebuilder:resource:shortName=db,categories=platform
// +kubebuilder:printcolumn:name="Engine",type=string,JSONPath=`.spec.engine`
// +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status`
// +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp`
type Database struct { /* ... generated ... */ }api/v1alpha1/database_types.go — including the XValidation one, because make manifests regenerates the CRD from markers alone and would otherwise throw away the CEL you wrote in Lab 4. In Reconcile, fetch the Database and create a child Deployment named <name>-<engine> in the same namespace (image postgres:16-alpine with POSTGRES_PASSWORD set from a literal — it’s a throwaway cluster) using controllerutil.CreateOrUpdate. Run make manifests generate, make install, then make run in its own terminal. Re-apply orders-db.yaml and watch the log line fly by. Then delete the child by hand — kubectl delete deploy orders-db-postgres -n checkout — and watch Recon put it straight back.make run logs a reconcile for orders-db, kubectl get deploy -n checkout shows a Deployment your controller created, and deleting that Deployment makes it reappear within one reconcile.// internal/controller/database_controller.go — Labs 6, 7 and 8 all live in this one func.
const dbFinalizer = "platform.acme.io/finalizer"
func (r *DatabaseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
var db platformv1alpha1.Database
if err := r.Get(ctx, req.NamespacedName, &db); err != nil {
return ctrl.Result{}, client.IgnoreNotFound(err) // gone; children GC'd via ownerRefs
}
// --- Lab 7: deletion path. Runs only once deletionTimestamp is set. ---
if !db.DeletionTimestamp.IsZero() {
if controllerutil.ContainsFinalizer(&db, dbFinalizer) {
// pretend-expensive cleanup: final backup, deprovision the external thing
log.FromContext(ctx).Info("running cleanup before delete", "db", db.Name)
controllerutil.RemoveFinalizer(&db, dbFinalizer)
if err := r.Update(ctx, &db); err != nil {
return ctrl.Result{}, err
}
}
return ctrl.Result{}, nil // object disappears now
}
if controllerutil.AddFinalizer(&db, dbFinalizer) {
if err := r.Update(ctx, &db); err != nil {
return ctrl.Result{}, err
}
}
// --- Lab 5 + 6: build the child and stamp ownership on it. ---
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{
Name: db.Name + "-" + db.Spec.Engine, Namespace: db.Namespace}}
if _, err := controllerutil.CreateOrUpdate(ctx, r.Client, dep, func() error {
dep.Spec = desiredPodSpec(&db) // your helper
return controllerutil.SetControllerReference(&db, dep, r.Scheme) // ownerRef
}); err != nil {
return ctrl.Result{}, err
}
// --- Lab 8: report back. status is the contract with your users. ---
meta.SetStatusCondition(&db.Status.Conditions, metav1.Condition{
Type: "Ready",
Status: metav1.ConditionTrue,
Reason: "Provisioned",
Message: "database workload is running",
ObservedGeneration: db.Generation,
})
db.Status.Phase = "Ready"
db.Status.ObservedGeneration = db.Generation // "I have seen this spec"
return ctrl.Result{}, r.Status().Update(ctx, &db)
}SetControllerReference call from the block above and restart make run. Inspect the stamp: kubectl get deploy orders-db-postgres -n checkout -o jsonpath='{.metadata.ownerReferences[0].kind}/{.metadata.ownerReferences[0].name}/{.metadata.ownerReferences[0].controller}'. Now the part that matters — stop the controller (Ctrl-C on make run) and delete the parent: kubectl delete db orders-db -n checkout. The child Deployment vanishes anyway, because cascading deletion is done by kube-controller-manager, not by your code. That’s the whole point: you set ownership, the platform does the cleanup.Database/orders-db/true, and with your controller stopped, kubectl get deploy -n checkout reports No resources found seconds after the Database is deleted.make run, and re-apply orders-db.yaml. Confirm the sticky note landed: kubectl get db orders-db -n checkout -o jsonpath='{.metadata.finalizers}'. Now stop the controller again and run kubectl delete db orders-db -n checkout — it hangs. In another terminal, prove the object is still there but marked for death: kubectl get db orders-db -n checkout -o jsonpath='{.metadata.deletionTimestamp}'. Restart make run and watch the delete complete the moment cleanup runs. Now learn the emergency escape hatch you’ll want at 2 a.m.: re-apply orders-db.yaml, stop the controller, run kubectl delete db orders-db -n checkout so it wedges again, then in a second terminal force it through with kubectl patch db orders-db -n checkout --type=merge -p '{"metadata":{"finalizers":[]}}' — the object vanishes the instant the last finalizer clears. That is the same trick that frees a namespace stuck in Terminating, and it skips the cleanup, which is exactly why it is a last resort.delete blocks and deletionTimestamp is set while the object still lists in kubectl get db; restarting the controller finishes the delete without you touching anything else.meta.SetStatusCondition block and the Ready printer column marker, then make manifests && make install && make run and re-apply orders-db.yaml (Lab 7 deleted it). Read the condition the standard way: kubectl get db orders-db -n checkout -o jsonpath='{.status.conditions[?(@.type=="Ready")].reason}', and then the way scripts and pipelines do it — kubectl wait --for=condition=Ready db/orders-db -n checkout --timeout=60s. Now demonstrate why observedGeneration exists: stop the controller, run kubectl patch db orders-db -n checkout --type=merge -p '{"spec":{"size":"large"}}', then compare .metadata.generation with .status.observedGeneration. They disagree — that gap is the machine-readable version of “I haven’t caught up yet.” Restart the controller and watch them converge.kubectl wait --for=condition=Ready exits 0, and after a spec edit with the controller stopped generation is exactly one ahead of observedGeneration until it reconciles.# Lab 9 — Crossplane + a provider that needs no cloud account.
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update
helm install crossplane crossplane-stable/crossplane \
--namespace crossplane-system --create-namespace --wait
kubectl get pods -n crossplane-system
# provider-kubernetes provisions objects INSIDE this cluster — perfect for a lab.
cat <<'EOF' | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: provider-kubernetes
spec:
package: xpkg.upbound.io/crossplane-contrib/provider-kubernetes:v0.14.0
EOF
kubectl get providers -w # wait for INSTALLED=True HEALTHY=True, then Ctrl-C
# which Object version did THIS build install? Use whatever this prints in Lab 10.
kubectl api-resources --api-group=kubernetes.crossplane.io
# the provider's ServiceAccount needs rights to create what it composes (lab-grade RBAC)
SA=$(kubectl -n crossplane-system get sa -o name | grep provider-kubernetes | sed 's|.*/||')
echo "provider SA: ${SA:-NONE — the provider is not healthy yet, wait and re-run}"
kubectl create clusterrolebinding provider-kubernetes-admin \
--clusterrole=cluster-admin --serviceaccount="crossplane-system:${SA}"
cat <<'EOF' | kubectl apply -f -
apiVersion: kubernetes.crossplane.io/v1alpha1
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: InjectedIdentity
EOFkubectl get crds | grep crossplane.io | head — a provider is essentially a bundle of CRDs plus the controller that serves them. Check health with kubectl get providers and, if it sulks, kubectl describe provider provider-kubernetes and kubectl get pods -n crossplane-system. The ProviderConfig named default is what compositions will reference; without it every managed resource fails with a credentials error. Note the kubectl api-resources --api-group=kubernetes.crossplane.io line: provider builds move Object between v1alpha1 and v1alpha2, and the Lab 10 composition must name the version your build actually serves.kubectl get providers shows INSTALLED=True and HEALTHY=True, kubectl get crd objects.kubernetes.crossplane.io resolves, and kubectl get providerconfig default returns an object rather than NotFound.# Lab 10 — xrd.yaml: the API and the recipe. The order form is a SECOND file,
# because the claim's CRD does not exist until the XRD below is Established.
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xappdatabases.platform.acme.io
spec:
group: platform.acme.io
names: { kind: XAppDatabase, plural: xappdatabases }
claimNames: { kind: AppDatabase, plural: appdatabases } # the namespaced front door
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [parameters]
properties:
parameters:
type: object
required: [size]
properties:
size: { type: string, enum: [small, large] }
---
# the composition pipeline needs a function; older Crossplane used mode: Resources instead
apiVersion: pkg.crossplane.io/v1beta1
kind: Function
metadata:
name: function-patch-and-transform
spec:
package: xpkg.upbound.io/crossplane-contrib/function-patch-and-transform:v0.7.0
---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: appdatabase.kubernetes
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: XAppDatabase
mode: Pipeline
pipeline:
- step: render
functionRef: { name: function-patch-and-transform }
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: config
base:
apiVersion: kubernetes.crossplane.io/v1alpha2 # match Lab 9's api-resources output
kind: Object
spec:
providerConfigRef: { name: default }
forProvider:
manifest:
apiVersion: v1
kind: ConfigMap
metadata:
namespace: checkout
name: placeholder # patched below
data:
size: placeholder
patches:
- type: FromCompositeFieldPath
fromFieldPath: metadata.name
toFieldPath: spec.forProvider.manifest.metadata.name
- type: FromCompositeFieldPath
fromFieldPath: spec.parameters.size
toFieldPath: spec.forProvider.manifest.data.size
# ---------------------------------------------------------------------------
# claim.yaml — a SEPARATE file. Apply it only after xrd.yaml is Established.
# ---------------------------------------------------------------------------
# apiVersion: platform.acme.io/v1alpha1
# kind: AppDatabase # Dot writes ONLY this
# metadata:
# name: orders
# namespace: checkout
# spec:
# parameters:
# size: smallkubectl apply -f xrd.yaml. Nothing works until both the offer and the function are live, so wait for them rather than guessing — kubectl wait --for=condition=Offered xrd/xappdatabases.platform.acme.io --timeout=120s and kubectl wait --for=condition=Healthy function/function-patch-and-transform --timeout=300s (the first pull is slow). Confirm with kubectl get xrd (expect ESTABLISHED=True OFFERED=True) and kubectl get functions. Now save the commented block at the bottom as claim.yaml, uncomment it, and kubectl apply -f claim.yaml — apply it any earlier and you get no matches for kind "AppDatabase", because its CRD is created by the XRD. Walk the three layers Crossplane just created for you: the claim (kubectl get appdatabase -n checkout), the cluster-scoped composite it spawned (kubectl get xappdatabase), and the managed resource underneath (kubectl get object). Then find the actual thing: kubectl get configmap orders-<suffix> -n checkout -o yaml. One nine-line claim, a whole chain of machinery.kubectl get appdatabase orders -n checkout shows SYNCED=True and READY=True, and the ConfigMap named after the composite exists with size: small in its data.# Lab 11 — break it, then walk the chain from the top down.
# Aim the composed ConfigMap at a namespace that does not exist. (Do NOT try to break it
# by deleting the ProviderConfig: it carries an in-use finalizer, so the delete just hangs
# and nothing actually fails — a good lesson in its own right.)
kubectl patch composition appdatabase.kubernetes --type=json \
-p '[{"op":"replace","path":"/spec/pipeline/0/input/resources/0/base/spec/forProvider/manifest/metadata/namespace","value":"nowhere"}]'
# nudge the claim so the composite re-renders with the poisoned base
kubectl patch appdatabase orders -n checkout --type=merge \
-p '{"spec":{"parameters":{"size":"large"}}}'
# 1. the claim: is it Synced? Ready? What do its events say?
kubectl describe appdatabase orders -n checkout
# 2. hop to the composite it points at
kubectl get appdatabase orders -n checkout -o jsonpath='{.spec.resourceRef.kind}/{.spec.resourceRef.name}'
kubectl describe xappdatabase <name-from-above>
# 3. hop to every managed resource the composite owns
kubectl get managed
kubectl describe object <name>
# 4. and when the object itself looks fine, ask the controller
kubectl -n crossplane-system logs -l pkg.crossplane.io/provider=provider-kubernetes --tail=60
kubectl get events -n checkout --sort-by=.lastTimestamp | tail -20
# 5. repair — put the namespace back and touch NOTHING else
kubectl patch composition appdatabase.kubernetes --type=json \
-p '[{"op":"replace","path":"/spec/pipeline/0/input/resources/0/base/spec/forProvider/manifest/metadata/namespace","value":"checkout"}]'
kubectl get appdatabase orders -n checkout -w # READY flips True on its own, then Ctrl-CStatus and Events at each hop. Write down, in one sentence, which layer failed and which line told you. Here the claim and the composite both render fine — it is the Object managed resource that fails, with a namespaces "nowhere" not found event, which is exactly why guessing from the top layer wastes time. (If you have the crossplane CLI installed, crossplane beta trace appdatabase/orders -n checkout renders the whole tree at once — it is plain crossplane trace on newer CLI builds — but do it by hand first, because the exam gives you kubectl and nothing else.) Then run step 5 to repair it and watch READY flip to True without you touching the claim.kubectl describe object shows the missing-namespace error while kubectl describe appdatabase orders -n checkout says only Ready=False — and after step 5 kubectl get appdatabase orders -n checkout returns to SYNCED=True READY=True on its own.# Capstone — template.yaml. Put it in a folder with skeleton/manifest.yaml alongside.
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: database-request
title: Request a Database
description: Generates a platform.acme.io Database manifest — no YAML required
spec:
owner: platform-team
type: resource
parameters:
- title: Your database
required: [name, team, engine]
properties:
name: { type: string, title: Name }
team: { type: string, title: Owning team }
engine: { type: string, title: Engine, enum: [postgres, mysql] }
size: { type: string, title: Size, enum: [small, medium, large], default: small }
steps:
- id: render
name: Render the manifest
action: fetch:template
input:
url: ./skeleton
values:
name: ${{ parameters.name }}
team: ${{ parameters.team }}
engine: ${{ parameters.engine }}
size: ${{ parameters.size }}
output:
text:
- title: Your Database manifest — copy this into manifest.yaml
content: |
apiVersion: platform.acme.io/v1alpha1
kind: Database
metadata:
name: ${{ parameters.name }}
namespace: checkout
spec:
engine: ${{ parameters.engine }}
team: ${{ parameters.team }}
size: ${{ parameters.size }}
- title: Next step
content: Commit that manifest — GitOps will apply it.
# skeleton/manifest.yaml — same content, rendered into the task workspace by fetch:template.
# (A real template would add a publish:* step to open a PR with this file; the output
# block above is the lab-sized substitute so you get something to paste.)
# apiVersion: platform.acme.io/v1alpha1
# kind: Database
# metadata:
# name: ${{ values.name }}
# namespace: checkout
# spec:
# engine: ${{ values.engine }}
# team: ${{ values.team }}
# size: ${{ values.size }}npx @backstage/create-app@latest --path backstage-lab, then cd backstage-lab and yarn dev (Node 20+; the first build is slow — grab a coffee). Save the template above as backstage-lab/platform-templates/template.yaml and the skeleton as backstage-lab/platform-templates/skeleton/manifest.yaml. Register it by adding a location to app-config.yaml — catalog.locations entries of type: file are resolved relative to packages/backend, and the default catalog.rules in a fresh app allow only Component, System, API, Resource, Location, so the per-location rules block is not optional: - type: file / target: ../../platform-templates/template.yaml / rules: [{allow: [Template]}]. Copy the shape from the examples/template/template.yaml entry create-app already put there. Restart yarn dev. Now be Dot: click Create, pick Request a Database, fill in four fields, run it, and copy the manifest out of the task’s output panel into manifest.yaml. Prove it is a real, valid platform request against the cluster you built: kubectl apply --dry-run=server -f manifest.yaml runs your Lab 1 schema and, if you kept the XValidation marker, your Lab 4 CEL — server-side, without creating anything. Then hand-edit engine: to banana and run the same command to watch it bounce.kubectl apply --dry-run=server -f manifest.yaml accepts, and the same file with engine: banana fails with the enum error from Lab 3. That round trip — form → schema → controller — is the golden path.“Run the capstone once as me. I filled in four boxes and got a database. I never learned what a StatefulSet is, I never opened a ticket, and — this is the bit that actually matters — when I typed my team name wrong, something told me immediately, in a sentence I understood. That’s not the portal being nice. That’s Lab 3 and Lab 4 doing their job three layers down.”
What you’ll have built
☺ Like you’re 10: By the end you’ve got your own Lego brick, a robot that builds things when it sees one, a vending machine full of bricks, and a form your friend can use without asking you anything.
Twelve labs, one coherent artefact: a platform API that a developer can use without understanding what’s behind it. Concretely, you now have a CRD with a structural schema, printer columns, shortnames, a /status subresource and CEL rules (Labs 1–4); a real controller scaffolded with Kubebuilder that reconciles your resource into live workloads, stamps owner references, holds deletes open with a finalizer, and reports back through standard Ready conditions and observedGeneration (Labs 5–8); a Crossplane control plane with a provider, a published XRD, a Composition and a working claim, plus the muscle memory to trace a broken one down four layers (Labs 9–11); and a Backstage Software Template that turns the whole stack into a four-field form (Capstone). That is the entire Platform APIs & Self-Service domain, built rather than read.
⚖ CNPA vs CNPE — These twelve labs are CNPE-specific — CNPA has no lab or task component at all; it’s a closed-book, multiple-choice exam with no hands-on portion whatsoever. But the concepts they build reps in (CRDs and schemas, controllers and reconciliation, owner references and finalizers, Crossplane compositions, self-service portals) are exactly what CNPA tests via closed-book recall, so working through these labs still strengthens CNPA prep — just not as a lab-for-lab match.
Where these labs show up on the exam
The CNPE is performance-based, so the questions look almost exactly like these tasks: “this CRD rejects a valid resource — fix the schema,” “this custom resource is stuck Terminating — explain why and unstick it,” “this claim never goes Ready — find the layer that failed.” Labs 3, 4 and 7 are the ones that pay for themselves under time pressure, because a stuck finalizer and a pruned status field both look like a broken controller and neither is. Drill the same skills against the clock in Practice: Platform APIs and Practice Tasks, keep the command reference at hand for the jsonpath forms, and when the whole platform is at stake rather than one API, run the full hands-on lab track. The exam guide maps every domain to its weight, know cold lists the handful of facts you should never have to look up, and if you are also sitting the associate-level exam the same ground is scoped down in CNPA: Platform APIs.
Comfortable? Push each layer one notch. Add a conversion webhook and graduate your CRD from v1alpha1 to v1 while both stay served. Add a validating admission policy (ValidatingAdmissionPolicy) so the rule lives outside the CRD and applies to resources you don’t own. Make the Lab 5 controller idempotent under chaos — delete its child in a loop with while true; do kubectl delete deploy orders-db-postgres -n checkout; sleep 2; done and confirm it never wedges. Publish a second Composition for the same XRD and select between them with a compositionSelector label. Then commit every manifest from these labs to a repo and let Argo CD apply them — at which point your platform API is itself under GitOps, which is exactly how a real one ships.
Foxy: My CRD applied first try. Do I really need Labs 5 through 8? The YAML works.
Mira: A CRD with no controller is a very expensive spreadsheet, Foxy. It stores your wish beautifully and grants none of it.
Recon: BEEP. I am the granting part. Observe, diff, act. Also: if you skip the owner reference, I leak a Deployment every time someone deletes a Database.
Gizmo: Just delete the finalizer whenever something’s stuck. Works every time! 😈
Timmy: It “works” the way cutting the seatbelt works, Gizmo. You skipped the cleanup — the external database is still running and still billing. Force-remove a finalizer only after you know what it was protecting.
Dot: All I’ll say is: Lab 3 saved me from myself twice this week, and I never even knew it was there.
1. In Lab 2 you patched status and nothing happened — why, and what made the second patch work? 2. In Lab 6 the child Deployment was deleted with your controller stopped. Who deleted it? 3. Your custom resource has been Terminating for ten minutes — what is the first thing you check, and what is the last-resort fix? 4. What does observedGeneration tell a user that a Ready condition alone does not? 5. A Crossplane claim shows SYNCED=False. Name the four objects you inspect, in order.
Check your answers
- The CRD declares the
statussubresource, so writes to the main resource endpoint ignore thestatusstanza entirely. Only--subresource=status(or a controller’sr.Status().Update()) reaches it. This is also why users editingspeccan never fake aReady. - Kubernetes’ own garbage collector in
kube-controller-manager, acting on theownerReferencesyour controller stamped. Cascading deletion is a platform feature, not controller code — which is exactly why forgetting the owner reference silently leaks children. - First:
kubectl get <res> <name> -o jsonpath='{.metadata.finalizers}'plus{.metadata.deletionTimestamp}— a set timestamp with a remaining finalizer means the object is waiting on a controller that is crashed, unhealthy, or gone. Fix the controller if you can. Last resort: patchmetadata.finalizersto[], accepting that the cleanup it guarded never ran. Readydescribes the last spec the controller saw.observedGenerationtells you whether that was your spec: if.metadata.generationis ahead of.status.observedGeneration, the controller hasn’t processed your latest edit yet and a staleReady: Trueis describing the old state.- The claim (
describe appdatabase) → the composite it references viaspec.resourceRef(describe xappdatabase) → the managed resources the composite owns (kubectl get managed, thendescribe) → the provider pod’s logs incrossplane-system. ReadStatusandEventsat every hop; the first layer that stops reporting healthy is where the real error lives.