Exam Prep · Practice · Platform APIs & Self-Service

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.

☺ Explain it like I’m 10

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.

🦋Your host for this topic: Mira the Butterfly — Mira is the shape-shifter of the cast. She takes a tangle of caterpillar-grade infrastructure and turns it into something small and elegant a developer can actually hold, and she will keep asking you one question: “what is the narrow thing the developer should be allowed to say?”

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.

🦋 Mira’s drill · 45 min

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:

  1. Create a namespaced CRD caches.platform.acme.io, version v1alpha1, kind Cache, short name ch.
  2. Give it a structural OpenAPI schema with spec.sizeGB (integer, 1–64, default 4) and spec.engine (enum redis/valkey), plus status.phase.
  3. Enable the status subresource and add printer columns for engine, size and phase.
  4. Create a Cache and 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 4

Why: 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:

  1. Add the finalizer platform.acme.io/cleanup to an existing Cache and show that deletion blocks.
  2. Write a Ready=True condition into .status.conditions using the status subresource.
  3. 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                                              # gone

Why: 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:

  1. Add a CEL validation rule so that when engine is valkey, sizeGB must be at least 8.
  2. Add a transition rule making sizeGB immutable-downward (a CR may grow but never shrink).
  3. 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 decreased

Why: 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:

  1. Define a CompositeResourceDefinition for XBucket/Bucket in group platform.acme.io with a parameters.tier field.
  2. Write a Composition that maps the claim to the provider’s managed resource, patching the name and setting platform-mandated defaults.
  3. Create a claim in an app namespace and confirm it becomes Ready and Synced.

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 failure

Why: 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:

  1. Write a scaffolder.backstage.io/v1beta3 Template named go-service with parameters for service name and owner.
  2. Add steps that fetch a skeleton, publish a GitHub repo, and register the resulting catalog-info.yaml.
  3. Add the catalog-info.yaml that 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:

  1. Write an Argo WorkflowTemplate named provision-namespace taking team and quotaCPU parameters.
  2. Have it apply the namespace plus a ResourceQuota and a RoleBinding using a resource template.
  3. Submit it for team research and 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 wide

Why: 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:

  1. Create a chart web-service with templated Deployment and Service, and sensible platform defaults in values.yaml.
  2. Add a values.schema.json that requires image.repository and constrains replicaCount to 1–20.
  3. 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.

◆ Key idea

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.

🦋 Mira’s checkpoint

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
  1. 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.
  2. The API server sets metadata.deletionTimestamp and 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":[]}}'.
  3. oldSelf is 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.
  4. The CompositeResourceDefinition (XRD) is the contract — what a developer may ask for; the Composition is the implementation — how the platform satisfies it. Swapping the Composition swaps clouds without touching a single claim.
  5. The catalog:register step, which registers the produced catalog-info.yaml as a Component.
  6. In values.schema.json, and it is enforced client-side by Helm at template, install and upgrade time — before any manifest reaches the cluster.