Practice — Platform APIs & Self-Service
This page holds the seven performance-based tasks for the exam’s Platform APIs & Self-Service domain, which is worth 25% of the CNPE — a full quarter of your score, and the quarter people underestimate, because it asks you to author APIs rather than merely use them. Drill them the way the exam runs: cold, from an empty terminal with nothing left over from the last attempt; time-boxed to 5–7 minutes each with a real timer; with only the official project docs open; and open the worked solution only after you have genuinely attempted the task and run the “Done when” check. These seven are lifted unchanged from the full practice bank, which also carries the scoring rubric and the 120-minute mock exam plan.
Imagine your school has one very complicated photocopier, and only the caretaker knows how to work it. Instead of teaching everybody the twelve buttons, you build a little box with one big green button on the front that says “make 30 copies.” The box does the twelve buttons for you. That’s what these puzzles are about: taking something messy and putting a simple, safe button on the front of it — so other people can help themselves without breaking anything, and so the box politely says “no” if someone asks for something silly.
How to drill this domain
☺ Like you’re 10: Do each puzzle yourself with a timer running, then check. Reading the answer first feels like learning and isn’t.
The tasks below split into three sub-themes. Tasks P1, P2 and P6 are about authoring a Kubernetes API of your own — schema, defaults, printer columns, status, finalizers, CEL. Task P3 is about composing cloud infrastructure behind a claim with Crossplane. Tasks P4, P5 and P7 are about the self-service entry point — the template, the workflow and the chart that developers actually touch. If you are short on time, do one from each group rather than three from one, because the exam samples across the whole domain rather than drilling a single tool.
Two habits pay off disproportionately here. First, practise doc navigation, not memorisation — the official docs are open in the exam, so knowing which page holds the x-kubernetes-validations example and getting there in twenty seconds beats half-remembering the syntax. Second, verify with the command, not with your eyes: every task below ends in a “Done when” line that is an objective check, and the grader only rewards the objective result. Keep the command reference nearby while you work, and when something refuses to apply or refuses to delete, work it through the troubleshooting playbook rather than guessing.
Background reading for anything that stumps you: Platform APIs & CRDs, self-service, IaC & control planes and developer experience.
Sit all seven in one block, on a throwaway kind cluster, with a 6-minute timer per task and a 2-minute gap to write down the outcome — pass, pass-but-slow, or fail. Do not open a single solution until all seven timers have run. Then, and only then, read the answer keys for the ones you failed, re-do those two or three immediately, and put them on a list to re-do cold in 48 hours. One honest 45-minute sitting like this is worth more than an afternoon of reading YAML you nodded at.
Authoring a custom Kubernetes API
The core skill of the domain: turning an operational pattern into a first-class object with a schema, defaults, validation, a lifecycle and a status the rest of the ecosystem can read.
P1 · Author a CRD with a schema, printer columns and a status subresource
Your platform will offer managed caches. Rather than making teams copy a Redis StatefulSet, you want a first-class API object: kubectl get caches should show size and phase, and the controller should own status separately from spec.
Your task:
- Create a namespaced CRD
caches.platform.acme.io, versionv1alpha1, kindCache, short namech. - Give it a structural OpenAPI schema with
spec.sizeGB(integer, 1–64, default 4) andspec.engine(enumredis/valkey), plusstatus.phase. - Enable the
statussubresource and add printer columns for engine, size and phase. - Create a
Cacheand confirm defaults and validation work.
Done when: kubectl get caches prints the custom columns, a CR created without sizeGB shows 4, and one with sizeGB: 200 is rejected by the API server.
Show the worked solution
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: caches.platform.acme.io
spec:
group: platform.acme.io
scope: Namespaced
names:
plural: caches
singular: cache
kind: Cache
shortNames: [ch]
versions:
- name: v1alpha1
served: true
storage: true
subresources:
status: {} # spec and status get separate write paths
additionalPrinterColumns:
- name: Engine
type: string
jsonPath: .spec.engine
- name: Size
type: integer
jsonPath: .spec.sizeGB
- name: Phase
type: string
jsonPath: .status.phase
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
required: [engine]
properties:
engine:
type: string
enum: [redis, valkey]
sizeGB:
type: integer
minimum: 1
maximum: 64
default: 4
status:
type: object
properties:
phase:
type: string
conditions:
type: array
items:
type: object
properties:
type: { type: string }
status: { type: string }
reason: { type: string }
lastTransitionTime: { type: string, format: date-time }kubectl apply -f cache-crd.yaml
kubectl explain cache.spec # proves the schema landed
kubectl create -f - <<'EOF'
apiVersion: platform.acme.io/v1alpha1
kind: Cache
metadata: { name: sessions, namespace: payments }
spec: { engine: redis }
EOF
kubectl -n payments get caches # Size column shows the default 4Why: three details separate a toy CRD from a platform API. The structural schema lets the API server validate and default — rejection happens before anything reaches your controller. Printer columns are how the API becomes usable at the command line, which is most of the developer experience. The status subresource means users update spec and controllers update status without clobbering each other, and it gives you a separate RBAC surface.
P2 · Add a finalizer and a status condition
Your Cache controller provisions a cloud cache outside the cluster. Right now, deleting the CR orphans that cloud resource and quietly bills the company forever. You also want kubectl wait to work against the object.
Your task:
- Add the finalizer
platform.acme.io/cleanupto an existingCacheand show that deletion blocks. - Write a
Ready=Truecondition into.status.conditionsusing the status subresource. - Remove the finalizer and confirm the object is deleted.
Done when: after kubectl delete cache sessions the object still exists with a non-empty deletionTimestamp; kubectl wait --for=condition=Ready cache/sessions returns immediately; and after the finalizer is removed the object disappears.
Show the worked solution
# 1. add the finalizer
kubectl -n payments patch cache sessions --type=merge \
-p '{"metadata":{"finalizers":["platform.acme.io/cleanup"]}}'
# 2. write a status condition (note --subresource=status)
kubectl -n payments patch cache sessions --type=merge --subresource=status -p '{
"status": {
"phase": "Ready",
"conditions": [{
"type": "Ready", "status": "True", "reason": "Provisioned",
"message": "cache endpoint available",
"lastTransitionTime": "2026-01-01T00:00:00Z"
}]
}}'
kubectl -n payments wait --for=condition=Ready cache/sessions --timeout=10s
# 3. deletion blocks until the finalizer is cleared
kubectl -n payments delete cache sessions --wait=false
kubectl -n payments get cache sessions -o jsonpath='{.metadata.deletionTimestamp}' # set!
kubectl -n payments patch cache sessions --type=merge -p '{"metadata":{"finalizers":[]}}'
kubectl -n payments get cache sessions # goneWhy: a finalizer is a lease on deletion — the API server sets deletionTimestamp and then waits, giving your controller a window to tear down external resources before the object vanishes. Conditions are the standard Kubernetes vocabulary for “is this thing ready,” and populating them correctly is what makes kubectl wait --for=condition=…, Argo CD health checks and Flux’s wait: true work against your custom API. A stuck object with a finalizer whose controller is gone is also a classic troubleshooting scenario — see the troubleshooting playbook.
P6 · Add CEL validation rules to a custom API
Your Cache API keeps getting misused: teams request the archive tier with a two-gigabyte size (pointless and expensive), and some try to shrink an existing cache, which the controller cannot do safely.
Your task:
- Add a CEL validation rule so that when
engineisvalkey,sizeGBmust be at least 8. - Add a transition rule making
sizeGBimmutable-downward (a CR may grow but never shrink). - Prove both rules reject bad input with a clear message.
Done when: creating a valkey cache with sizeGB: 4 is rejected, and patching an existing cache from 16 to 8 is rejected — both with your custom messages, and no controller involved.
Show the worked solution
# inside the CRD's openAPIV3Schema
spec:
type: object
properties:
engine: { type: string, enum: [redis, valkey] }
sizeGB: { type: integer, minimum: 1, maximum: 64, default: 4 }
x-kubernetes-validations:
- rule: "self.engine != 'valkey' || self.sizeGB >= 8"
message: "valkey caches require sizeGB of at least 8"
- rule: "self.sizeGB >= oldSelf.sizeGB" # transition rule - runs on UPDATE only
message: "sizeGB may be increased but never decreased"kubectl apply -f cache-crd.yaml
kubectl create -f - <<'EOF'
apiVersion: platform.acme.io/v1alpha1
kind: Cache
metadata: { name: small, namespace: payments }
spec: { engine: valkey, sizeGB: 4 }
EOF
# The Cache "small" is invalid: spec: Invalid value: "object":
# valkey caches require sizeGB of at least 8
# grow to 16 - allowed
kubectl -n payments patch cache sessions --type=merge -p '{"spec":{"sizeGB":16}}'
# then try to shrink back to 8 - rejected by the transition rule
kubectl -n payments patch cache sessions --type=merge -p '{"spec":{"sizeGB":8}}'
# The Cache "sessions" is invalid: spec: Invalid value: "object":
# sizeGB may be increased but never decreasedWhy: x-kubernetes-validations pushes cross-field and transition rules into the API server itself, where they are enforced for every client, cannot be bypassed, and produce an error the developer sees immediately at kubectl apply time. Rules that reference oldSelf are transition rules and only run on update. Compare with the admission-controller approach in security & policy: CEL in the CRD is for your own API’s invariants; Kyverno or Gatekeeper is for policy across APIs you don’t own.
Composing infrastructure behind a claim
One step out from the cluster: the developer asks for a bucket, and a control plane decides region, encryption and tagging on their behalf.
P3 · Publish a Crossplane XRD and Composition, then claim it
App teams keep filing tickets for object storage buckets. You want them to request one declaratively, with the platform choosing region, encryption and tagging — so the developer writes six lines and gets a compliant bucket.
Your task:
- Define a
CompositeResourceDefinitionforXBucket/Bucketin groupplatform.acme.iowith aparameters.tierfield. - Write a
Compositionthat maps the claim to the provider’s managed resource, patching the name and setting platform-mandated defaults. - Create a claim in an app namespace and confirm it becomes
ReadyandSynced.
Done when: kubectl get bucket -n payments and kubectl get xbucket both show SYNCED=True READY=True, and kubectl get managed lists the underlying resource.
Show the worked solution
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: xbuckets.platform.acme.io
spec:
group: platform.acme.io
names: { kind: XBucket, plural: xbuckets }
claimNames: { kind: Bucket, plural: buckets } # namespaced developer-facing API
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
parameters:
type: object
properties:
tier:
type: string
enum: [standard, archive]
default: standard
---
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
name: xbuckets.aws
spec:
compositeTypeRef:
apiVersion: platform.acme.io/v1alpha1
kind: XBucket
mode: Pipeline
pipeline:
- step: patch-and-transform
functionRef: { name: function-patch-and-transform }
input:
apiVersion: pt.fn.crossplane.io/v1beta1
kind: Resources
resources:
- name: bucket
base:
apiVersion: s3.aws.upbound.io/v1beta1
kind: Bucket
spec:
forProvider:
region: eu-west-1 # platform decides, not the developer
tags: { managed-by: crossplane, cost-center: platform }
patches:
- fromFieldPath: metadata.name
toFieldPath: metadata.annotations[crossplane.io/external-name]kubectl apply -f xrd.yaml -f composition.yaml
kubectl get xrd # ESTABLISHED=True, OFFERED=True
kubectl apply -f - <<'EOF'
apiVersion: platform.acme.io/v1alpha1
kind: Bucket
metadata: { name: receipts, namespace: payments }
spec:
parameters: { tier: standard }
compositionRef: { name: xbuckets.aws }
EOF
kubectl -n payments get bucket receipts
kubectl get managed
kubectl -n payments describe bucket receipts # events explain any composition failureWhy: the XRD is the contract (what a developer may ask for) and the Composition is the implementation (how the platform satisfies it) — that separation is the entire Crossplane idea, and it means you can swap cloud providers without changing a single claim. Two hedges worth knowing: Crossplane moved patch-and-transform out of the core into a function (hence mode: Pipeline), and Crossplane v2 reworked the claim model toward namespaced composite resources. Check which major version the exam environment has installed with kubectl get deploy -n crossplane-system before you commit to a syntax.
Golden paths and self-service entry points
The surface developers actually touch: a scaffolder form, a parameterised workflow, and a chart whose values file is a validated contract.
P4 · Scaffold a Backstage software template
Every new service at Acme takes three days to reach “deployed in dev,” mostly spent copying another repo. You want a Backstage golden path: pick a name, get a repo with CI, manifests and a catalog entry, all pre-wired.
Your task:
- Write a
scaffolder.backstage.io/v1beta3Templatenamedgo-servicewith parameters for service name and owner. - Add steps that fetch a skeleton, publish a GitHub repo, and register the resulting
catalog-info.yaml. - Add the
catalog-info.yamlthat registers the produced service as a Component.
Done when: the template appears under Create in the Backstage UI, running it produces a repo containing the skeleton, and the new Component shows up in the catalog with the right owner.
Show the worked solution
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
name: go-service
title: Go service (golden path)
description: Repo + CI + manifests + catalog entry, pre-wired to the platform
tags: [recommended, go]
spec:
owner: group:platform
type: service
parameters:
- title: Service details
required: [name, owner]
properties:
name:
title: Service name
type: string
pattern: '^[a-z][a-z0-9-]{2,29}$'
owner:
title: Owning team
type: string
ui:field: OwnerPicker
ui:options: { catalogFilter: { kind: Group } }
steps:
- id: fetch
name: Fetch skeleton
action: fetch:template
input:
url: ./skeleton
values: { name: '${{ parameters.name }}', owner: '${{ parameters.owner }}' }
- id: publish
name: Publish to GitHub
action: publish:github
input:
repoUrl: 'github.com?owner=acme&repo=${{ parameters.name }}'
description: '${{ parameters.name }} - created from the golden path'
- id: register
name: Register in catalog
action: catalog:register
input:
repoContentsUrl: '${{ steps.publish.output.repoContentsUrl }}'
catalogInfoPath: /catalog-info.yaml
output:
links:
- title: Repository
url: '${{ steps.publish.output.remoteUrl }}'
---
# skeleton/catalog-info.yaml
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
name: ${{ values.name }}
annotations:
argocd/app-name: ${{ values.name }}
spec:
type: service
lifecycle: production
owner: ${{ values.owner }}Why: a scaffolder template is three things bolted together — a parameter form (JSON Schema, so you get validation and pickers for free), an ordered list of actions, and an output. The catalog:register step is the one people forget, and it is the difference between “created a repo” and “created a service the platform knows about.” More on this pattern in self-service and developer experience.
P5 · Expose namespace provisioning as a self-service workflow
Onboarding a new team means creating a namespace, a ResourceQuota and a team RoleBinding. Today a platform engineer does it by hand from a wiki page, and the three objects regularly drift apart.
Your task:
- Write an Argo
WorkflowTemplatenamedprovision-namespacetakingteamandquotaCPUparameters. - Have it apply the namespace plus a
ResourceQuotaand aRoleBindingusing aresourcetemplate. - Submit it for team
researchand verify all objects exist and are labelled with the owner.
Done when: argo submit -n argo --from workflowtemplate/provision-namespace -p team=research --wait succeeds, and all three objects exist with the owner label — kubectl get ns research --show-labels, kubectl -n research get resourcequota quota, and kubectl -n research get rolebinding research-admins.
Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
name: provision-namespace
namespace: argo
spec:
serviceAccountName: namespace-provisioner # needs RBAC to create ns/quota/rolebinding
entrypoint: main
arguments:
parameters:
- name: team
- name: quotaCPU
value: "8"
templates:
- name: main
inputs:
parameters: [{ name: team }, { name: quotaCPU }]
steps:
- - name: create-ns
template: apply-ns
arguments:
parameters:
- { name: team, value: "{{inputs.parameters.team}}" }
- - name: create-quota
template: apply-quota
arguments:
parameters:
- { name: team, value: "{{inputs.parameters.team}}" }
- { name: quotaCPU, value: "{{inputs.parameters.quotaCPU}}" }
- name: create-rolebinding
template: apply-rolebinding
arguments:
parameters:
- { name: team, value: "{{inputs.parameters.team}}" }
- name: apply-ns
inputs: { parameters: [{ name: team }] }
resource:
action: apply
manifest: |
apiVersion: v1
kind: Namespace
metadata:
name: "{{inputs.parameters.team}}"
labels:
platform.acme.io/owner: "{{inputs.parameters.team}}"
pod-security.kubernetes.io/enforce: restricted
- name: apply-quota
inputs: { parameters: [{ name: team }, { name: quotaCPU }] }
resource:
action: apply
manifest: |
apiVersion: v1
kind: ResourceQuota
metadata:
name: quota
namespace: "{{inputs.parameters.team}}"
labels:
platform.acme.io/owner: "{{inputs.parameters.team}}"
spec:
hard:
requests.cpu: "{{inputs.parameters.quotaCPU}}"
requests.memory: 16Gi
pods: "50"
- name: apply-rolebinding
inputs: { parameters: [{ name: team }] }
resource:
action: apply
manifest: |
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: "{{inputs.parameters.team}}-admins"
namespace: "{{inputs.parameters.team}}"
labels:
platform.acme.io/owner: "{{inputs.parameters.team}}"
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: edit
subjects:
- kind: Group
apiGroup: rbac.authorization.k8s.io
name: "{{inputs.parameters.team}}"argo submit -n argo --from workflowtemplate/provision-namespace \
-p team=research -p quotaCPU=4 --wait
kubectl get ns research --show-labels
kubectl -n research get resourcequota quota -o jsonpath='{.spec.hard}'
kubectl -n research get rolebinding research-admins -o wideWhy: a WorkflowTemplate is a reusable, parameterised workflow, which is exactly the shape of a self-service action — a portal button, a ChatOps command or a CI job can all invoke the same one. The resource template type applies manifests using the workflow’s ServiceAccount, so the developer never needs the permissions themselves: the platform holds them and exposes a narrow, audited door. That indirection is the core self-service pattern.
P7 · Package a golden path as a Helm chart with a values schema
Twelve teams deploy “a web service” and every one of them wrote their own Deployment, Service, Ingress and HPA. You want one platform-maintained chart where a team supplies six values and gets the platform’s opinions for free — and where a typo in values is caught before anything reaches the cluster.
Your task:
- Create a chart
web-servicewith templated Deployment and Service, and sensible platform defaults invalues.yaml. - Add a
values.schema.jsonthat requiresimage.repositoryand constrainsreplicaCountto 1–20. - Lint, render, and install it — then prove an invalid value is rejected.
Done when: helm lint passes, helm template renders valid manifests, helm upgrade --install reports deployed, and helm template --set replicaCount=99 fails with a schema error.
Show the worked solution
{
"$schema": "https://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["image"],
"properties": {
"replicaCount": { "type": "integer", "minimum": 1, "maximum": 20 },
"image": {
"type": "object",
"required": ["repository"],
"properties": {
"repository": { "type": "string" },
"tag": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$" }
}
}
}
}helm create web-service # then trim the generated templates helm lint web-service helm template web-service ./web-service --set image.repository=ghcr.io/acme/api helm upgrade --install api ./web-service -n payments --create-namespace \ --set image.repository=ghcr.io/acme/api --set image.tag=1.2.0 --wait helm template ./web-service --set replicaCount=99 --set image.repository=x # Error: values don't meet the specifications of the schema(s) in the following chart(s): # web-service: # - replicaCount: Must be less than or equal to 20 helm history api -n payments # revisions - and 'helm rollback api 1' to revert
Why: values.schema.json is Helm’s answer to “the chart is the API” — it turns free-form YAML into a validated contract, so a developer gets a precise error instead of a mysteriously broken Deployment. Chart-plus-schema is the lowest-effort golden path there is: no controllers, no CRDs, works with both Argo CD and Flux, and helm rollback gives you a revert story on day one.
Every task in this domain is the same move at a different altitude: take something complicated and expose a small, validated, self-service surface over it. CRD, XRD, Helm values schema, scaffolder form, workflow parameters — five mechanisms, one idea. If a task confuses you, ask “what is the narrow thing the developer should be allowed to say?” and build backwards from that.
Where to go next
When all seven are green from cold, fold them back into a mixed sitting: this domain is 25% of the paper, so roughly four of a seventeen-task exam will come from here, and the value now is in switching between domains under time pressure rather than in repeating these alone. Head back to the full practice bank for the other four domains, the scoring rubric and the 120-minute mock exam plan, then read the exam guide for the logistics of the sitting itself. If any single mechanism still feels shaky, the lesson pages are the place to repair it: Platform APIs & CRDs for schemas and controllers, IaC & control planes for Crossplane, and self-service plus developer experience for golden paths.
1. Which two things does enabling the status subresource change about how a custom resource is written? 2. What exactly does a finalizer do to a delete request, and how do you free an object whose controller no longer exists? 3. In a CEL rule, what does oldSelf mean and on which operations does such a rule run? 4. In Crossplane, which object is the contract and which is the implementation? 5. Which Backstage scaffolder step turns “a new repo” into “a service the platform knows about”? 6. Where does a Helm chart declare that replicaCount must be between 1 and 20, and at what moment is that enforced?
Check your answers
- It gives spec and status separate write paths — a normal update to the object ignores changes to
status, and status is only writable through--subresource=status— and it creates a separate RBAC surface so controllers can be granted status writes without spec writes. - The API server sets
metadata.deletionTimestampand then waits instead of deleting, giving the controller a window to tear down external resources. You free a stuck object by patching the finalizer list to empty:kubectl patch … --type=merge -p '{"metadata":{"finalizers":[]}}'. oldSelfis the previous value of the field being validated; a rule that references it is a transition rule and runs on UPDATE only, never on create.- The
CompositeResourceDefinition(XRD) is the contract — what a developer may ask for; theCompositionis the implementation — how the platform satisfies it. Swapping the Composition swaps clouds without touching a single claim. - The
catalog:registerstep, which registers the producedcatalog-info.yamlas a Component. - In
values.schema.json, and it is enforced client-side by Helm attemplate,installandupgradetime — before any manifest reaches the cluster.