Exam Prep · Mock Exam · Set 6

Mock Exam · Set 6

This is the hardest paper on the site, and it is meant to be your final dress rehearsal — the one you sit a few days before the real thing, when you already know the material and what you need is a stress test rather than a lesson. Eighteen all-new tasks, weighted exactly to the CNPE blueprint, in one unbroken 120-minute block. What makes Set 6 harder than Set 2 is not obscurity — nothing here is off-blueprint — it is chaining. Most tasks join two capabilities together: a Rollout whose analysis needs a Prometheus rule that does not exist yet, a Crossplane claim that has to survive a Kyverno gate, a Backstage golden path backed by an admission policy that enforces the same rule server-side. And seven of the eighteen hand you something that is already broken: you cannot finish the task until you have diagnosed it from evidence. Every task has an objective done when check, a point value, and a worked solution folded away so you can sit it honestly and mark it afterwards. Total the sheet, compare against 64%, and let the misses choose your last few days.

☺ Explain it like I’m 10

Up to now the tests have been like being asked to tie a knot, then separately asked to climb a rope. Today someone hands you a rope that is already tangled, on a windy day, and says: untangle it, tie the knot, then climb. Same skills — but now they arrive joined together and slightly wrong, which is exactly how real problems show up. You will be slower than you were on the drills. That is not a sign you have got worse; it is the whole point of a dress rehearsal.

🐢🦫Your hosts for this topic: Timmy the Turtle & Benny the Beaver — Timmy runs the clock and will not stop it for a tangled rope, and Benny refuses to award a single point until the done-when command actually prints the right thing on a real cluster. “Looks right” is worth nothing to either of them.

Before you start — exam conditions

☺ Like you’re 10: A rehearsal only helps if you run it like the real show — same clock, same rules, no help from the wings.

Sitting a hard paper comfortably is worse than not sitting it at all: you get a flattering number and no diagnosis. Set the conditions before you start the timer, not after you hit the first task that hurts.

One — build the cluster the night before, and check it twice. A throwaway kind or minikube cluster is enough, but Set 6 touches more components than the earlier papers. You want Argo CD, Argo Rollouts, Flux (including the image-reflector and image-automation controllers, which the default install leaves out), Tekton Pipelines, the kube-prometheus-stack, an OpenTelemetry Collector, Loki, Crossplane with the patch-and-transform function, Kyverno with policy exceptions enabled, Velero, and a policy-capable CNI. Confirm every one of them is healthy before the timer starts. If something turns out to be missing when you reach its task, skip the task and move on — never stop the clock to install things, because on the day you will not be allowed to.

# --- the night before -------------------------------------------------
kind create cluster --name cnpe-mock6
flux install --components-extra=image-reflector-controller,image-automation-controller
kubectl get pods -A | grep -vE 'Running|Completed'    # empty, or it is a task you will skip

# --- ninety seconds before the clock starts ---------------------------
kubectl config use-context kind-cnpe-mock6
kubectl config set-context --current --namespace=default
kubectl config view --minify -o jsonpath='{..namespace}{"\n"}'

alias k=kubectl
export do="--dry-run=client -o yaml"
export now="--grace-period=0 --force"

Two — one timer, started once. 120 minutes, no pausing for coffee, a CRD that will not become Established, or a laptop that decides to update itself. If you lose nine minutes to your own machine, that is data rather than an excuse. Sit it at roughly the hour your real exam is booked; your 9 a.m. brain and your 9 p.m. brain are different candidates.

Three — only the permitted documentation, open before you start. The allowlist is narrower than most candidates expect: kubernetes.io/docs, kubernetes.io/blog, any task-specific documentation the exam links from its own Quick Reference box, and the local documentation on the exam desktopman pages and whatever ships under /usr/share. That is the entire list. Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm documentation are not permitted, and neither is this site, your notes, a search engine or an AI assistant.

Four — no copy-paste from the solutions, and no AI assistance of any kind. On a chained task the temptation is worse than usual, because the first half often works and the second half does not. Spend the budget, fail the task, read the solution after the sitting.

⚠ The allowlist is narrow — and everything here can change

For the tools whose own documentation is not allowed, the resource shapes have to be in your head or reachable from what to know cold — you cannot look up an AnalysisTemplate, an ImagePolicy, a ValidatingAdmissionPolicy or a PolicyException mid-exam. What you can always do is interrogate the cluster: kubectl explain <kind> --recursive, kubectl api-resources and kubectl get <kind> <existing> -o yaml are not documentation, they are the live API, and they are your real reference. This matters more on Set 6 than anywhere else, because several tasks use APIs whose version has moved recently — Flux image automation and Kyverno policy exceptions in particular. Check the group/version on the cluster in front of you rather than trusting any page, including this one. Separately: exam specifics move. Task counts, the 120-minute duration, the 64% pass mark, domain weights, the permitted-resource list and the Kubernetes version on the desktop have all changed before and will change again. Everything here is a study aid written at a point in time — the official CNCF certification pages and Linux Foundation training site are the only authority. Confirm before you book.

Five — read the whole paper first, then choose your order. Five minutes spent reading all eighteen briefs is the highest-return five minutes of the sitting, and on this paper it is worth more than usual: the chained tasks are not uniformly expensive, and the two Security tasks at the bottom are worth as much as the whole Architecture block, and more per task than anything else on the paper. Bank the cheap points first. Sequential order is a trap in a weighted, partial-credit exam.

Your time budget

☺ Like you’re 10: Give every question a small share of the two hours, and when a question runs over its share, put a sticky note on it and move on. You can always come back.

Eighteen tasks in 120 minutes averages just under seven minutes each — six, once you subtract the reading and verification time — but on a chained paper the averages lie badly. Budget deliberately: five minutes to read everything, about 108 minutes of task work, and seven minutes at the end to re-run your done-when checks. On Set 6 that final sweep matters more than on any other paper, because a chained task can look finished while its second half silently never fired.

BlockTasksBudgetRunning totalNote
Read the whole paper5 min5Mark each task cheap / medium / expensive, and mark the seven that are already broken.
GitOps & Continuous DeliveryX1–X57 + 5 + 6 + 6 + 6 = 30 min35X1 is the expensive one and it is first. Do not let position dictate order.
Platform APIs & Self-ServiceX6–X107 + 6 + 6 + 6 + 4 = 29 min64X10 is the shortest task on the paper — four minutes, four points. Take it early.
Observability & OperationsX11–X138 + 8 + 6 = 22 min86Twenty points across three tasks. X11 is triage and either resolves fast or eats your slack.
Platform Architecture & InfrastructureX14–X166 + 5 + 5 = 16 min102Three self-contained tasks — the best points-per-minute on the paper after the Security pair.
Security & Policy EnforcementX17–X186 + 5 = 11 min113Two tasks, fifteen points. X17 alone is worth eight. Never leave these unattempted.
Verify & sweep flagged tasks7 min120Re-run every done-when. Finish the nearly-done before starting anything new.

The flag-and-move rule, with a Set 6 amendment. When a task passes roughly six minutes without a working done-when check, stop, write one line about where you stalled, leave whatever you applied in place — partial credit is real — and go to the next task. The amendment for a chained paper: if you have completed the first capability of a chain and the second is fighting you, that is still a flag. Half a chained task is often most of its marks, and a fresh five-pointer elsewhere is always worth more than the last two marks of the one in front of you. Timmy’s version: you are not paid to finish tasks, you are paid to bank points.

120 minutes · 18 chained tasks · 100 points GitOps 30 min · 5 tasks · 25 pts Platform APIs 29 min · 5 tasks · 25 pts Observability 22 min · 3 · 20 Arch 16 · 3 · 15 Sec 11 · 2 · 15 read 5m verify 7m Points follow the blueprint, not the clock GitOps 25 · Platform APIs 25 · Observability 20 · Architecture 15 · Security 15 · seven tasks arrive already broken Flag-and-move rule · chained amendment Past ~6 minutes with no passing check: flag it — even when the first half of the chain already works.
◆ Key idea

Points, not tasks, are the unit of this exam. With a 64% bar you can fail four or five tasks outright and still pass comfortably — but only if you actually attempted the cheap ones. On a chained paper there is a second trap on top of that: the sunk-cost pull of a task where half your work is already applied. That half is banked whether you continue or not. Walk away from it exactly as readily as you would from a task you had not started.

GitOps & Continuous Delivery — X1 to X5 (25 points)

☺ Like you’re 10: These five are about the robot that reads your instructions from a folder — but this time the instructions point at things that do not exist yet, or point at them wrongly, and you have to find out which.

The biggest slice of the blueprint. Two of these five — X1 and X5 — hand you something that is already broken. Background reading lives in GitOps workflows, CI/CD & progressive delivery and release engineering, with tool-level detail in Argo Rollouts, Flux and Tekton — but read those after the sitting, not during it.

X1 · Rescue a canary whose analysis never returns data

The checkout Rollout is delivered by Argo CD and has been stuck at 20% weight for forty minutes. The AnalysisRun shows Inconclusive, then Error, and the platform channel has two engineers arguing about whether to abort. The AnalysisTemplate queries a recorded series called job:checkout_success_rate:ratio_rate5m — and nobody has ever created the recording rule that produces it. Fixing the template alone will not help; you have to build the metric it depends on, in the namespace and with the labels Prometheus actually discovers.

Your task:

  1. From evidence, establish why the analysis returns no data — do not guess, and do not abort the Rollout.
  2. Create the PrometheusRule that produces job:checkout_success_rate:ratio_rate5m, labelled so the running Prometheus actually loads it, and confirm the series exists.
  3. Fix the AnalysisTemplate so an empty result cannot be read as a pass, and pass the service and namespace in as arguments from the Rollout.
  4. Re-trigger the canary and let the analysis complete on its own.

Done when: kubectl -n checkout get analysisrun --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].status.phase}' prints Successful and kubectl argo rollouts status checkout -n checkout prints Healthy. (7 points)

Show the worked solution
# 1. evidence order — the AnalysisRun tells you what it asked and what came back
kubectl -n checkout get analysisrun -o wide
kubectl -n checkout describe analysisrun $(kubectl -n checkout get analysisrun \
  --sort-by=.metadata.creationTimestamp -o name | tail -1)
# look at status.metricResults[].measurements[].message

# 2. ask Prometheus the same question yourself
kubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090 &
curl -sG http://localhost:9090/api/v1/query \
  --data-urlencode 'query=job:checkout_success_rate:ratio_rate5m'
# {"status":"success","data":{"resultType":"vector","result":[]}}  <- empty: the rule does not exist

# 3. which rules did Prometheus actually load, and which does it select?
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.ruleSelector}{"\n"}'
curl -s http://localhost:9090/api/v1/rules | grep -o 'checkout' | head
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: checkout-slo
  namespace: monitoring
  labels:
    release: kube-prometheus-stack   # MUST match spec.ruleSelector on the Prometheus CR
spec:
  groups:
    - name: checkout.rules
      interval: 30s
      rules:
        - record: job:checkout_success_rate:ratio_rate5m
          expr: |
            sum by (service, namespace) (rate(http_requests_total{job="checkout", code!~"5.."}[5m]))
            /
            sum by (service, namespace) (rate(http_requests_total{job="checkout"}[5m]))
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
  name: success-rate
  namespace: checkout
spec:
  args:
    - name: service
    - name: namespace
  metrics:
    - name: success-rate
      initialDelay: 60s          # let the 5m rate window fill before judging
      interval: 30s
      count: 4
      failureLimit: 1
      successCondition: len(result) > 0 && result[0] >= 0.95
      provider:
        prometheus:
          address: http://prometheus-operated.monitoring.svc:9090
          query: |
            job:checkout_success_rate:ratio_rate5m{service="{{args.service}}", namespace="{{args.namespace}}"}
---
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
  name: checkout
  namespace: checkout
spec:
  replicas: 4
  selector:
    matchLabels:
      app: checkout
  template:
    metadata:
      labels:
        app: checkout
    spec:
      containers:
        - name: checkout
          image: ghcr.io/acme/checkout:3.4.0
          ports:
            - name: http
              containerPort: 8080
  strategy:
    canary:
      steps:
        - setWeight: 20
        - pause: { duration: 60s }
        - analysis:
            templates:
              - templateName: success-rate
            args:
              - name: service
                value: checkout
              - name: namespace
                valueFrom:
                  fieldRef:
                    fieldPath: metadata.namespace
        - setWeight: 60
        - pause: { duration: 60s }
kubectl apply -f prometheusrule.yaml
sleep 60                                     # one evaluation interval, then the series exists
curl -sG http://localhost:9090/api/v1/query \
  --data-urlencode 'query=job:checkout_success_rate:ratio_rate5m'

kubectl apply -f analysistemplate.yaml -f rollout.yaml
kubectl argo rollouts retry rollout checkout -n checkout
kubectl argo rollouts get rollout checkout -n checkout --watch
kubectl -n checkout get analysisrun --sort-by=.metadata.creationTimestamp \
  -o jsonpath='{.items[-1:].status.phase}'   # Successful
kubectl argo rollouts status checkout -n checkout

Why: two separate bugs are stacked, which is what makes this a chain. The first is discovery: a PrometheusRule is only loaded if its labels match the Prometheus CR’s ruleSelector, and the kube-prometheus-stack ships a selector on release. A rule with the wrong label applies cleanly, shows no error anywhere, and simply never evaluates — which is why you check /api/v1/rules rather than trusting kubectl get prometheusrule. The second is the analysis contract: without len(result) > 0, an empty vector makes result[0] blow up and the measurement reports Error; on some expressions an empty result silently evaluates falsy and a broken metric reads as a failing canary, which is worse — it aborts good releases. Guard the emptiness explicitly, and give the rate window time to fill with initialDelay or your first measurement judges a metric that has barely any samples in it. More on analysis shapes in Argo Rollouts and on recording rules in Prometheus.

X2 · Fence a tenant with an AppProject and a sync window

The payments team has their own Argo CD Applications, and last month one of them was pointed at a fork of the config repo and quietly deployed a ClusterRoleBinding into kube-system. Separately, change management has asked that automated syncs stop during the trading day — but a human must still be able to sync in an emergency.

Your task:

  1. Create an AppProject that permits exactly one source repository, restricts destinations to payments-* namespaces on the in-cluster server, and forbids all cluster-scoped resources.
  2. Add a deny sync window covering 09:00–17:00 Monday to Friday that still allows manual syncs.
  3. Prove the fence works by creating an Application in that project that violates it.

Done when: argocd proj windows list tenant-payments lists the deny window, and an Application in the project whose destination namespace is kube-system reports a spec error — kubectl -n argocd get app rogue -o jsonpath='{.status.conditions[*].type}' prints InvalidSpecError. (4 points)

Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: tenant-payments
  namespace: argocd
spec:
  description: Payments tenant — one repo, their own namespaces, no cluster scope.
  sourceRepos:
    - https://github.com/acme/platform-config.git
  destinations:
    - server: https://kubernetes.default.svc
      namespace: 'payments-*'
  clusterResourceWhitelist: []          # empty list = no cluster-scoped kinds at all
  namespaceResourceBlacklist:
    - group: ''
      kind: ResourceQuota
    - group: ''
      kind: LimitRange
  syncWindows:
    - kind: deny
      schedule: '0 9 * * 1-5'
      duration: 8h
      timeZone: Europe/London
      applications:
        - '*'
      manualSync: true                  # a human can still sync during the window
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: rogue
  namespace: argocd
spec:
  project: tenant-payments
  source:
    repoURL: https://github.com/acme/platform-config.git
    targetRevision: main
    path: apps/payments
  destination:
    server: https://kubernetes.default.svc
    namespace: kube-system              # not matched by payments-* — rejected
kubectl apply -f appproject.yaml
argocd proj get tenant-payments
argocd proj windows list tenant-payments

kubectl apply -f rogue-app.yaml
kubectl -n argocd get app rogue -o jsonpath='{.status.conditions[*].type}'
kubectl -n argocd get app rogue -o jsonpath='{.status.conditions[*].message}'
# application destination is not permitted in project 'tenant-payments'

argocd app sync payments-api            # inside the window: blocked unless manual is allowed

Why: an AppProject is Argo CD’s tenancy boundary, and the three lists do different jobs: sourceRepos stops someone deploying from a fork, destinations stops a tenant writing outside their own namespaces, and clusterResourceWhitelistempty, not absent — stops them creating ClusterRoles, CRDs and webhooks. Leaving the whitelist out entirely is the trap: absent means “inherit the default”, an empty list means “none”. The violation surfaces as an InvalidSpecError condition on the Application rather than a rejected apply, so the fence is only visible if you look at status. Sync windows are the change-management half: manualSync: true keeps the emergency door open, which is what makes a deny window acceptable to an on-call team at all. See Argo CD and governance & compliance.

X3 · Close the loop with Flux image automation writing back to Git

Every patch release of the podinfo platform component currently means a human editing a tag in the config repo. It gets forgotten, and the running version drifts weeks behind the registry. You are automating the whole loop: scan the registry, pick the newest version inside a semver range, and commit the change back to Git so the reconciler does the deployment as usual.

Your task:

  1. Create an ImageRepository that scans the registry and an ImagePolicy that selects the newest 6.x release.
  2. Mark the image field in the Deployment manifest with the setter comment the automation looks for.
  3. Create an ImageUpdateAutomation that commits and pushes the update to the same branch Flux reconciles from.

Done when: flux get images policy -A shows the policy with a resolved LATEST IMAGE, git log --oneline -1 origin/main shows a commit authored by the automation, and kubectl -n podinfo get deploy podinfo -o jsonpath='{.spec.template.spec.containers[0].image}' matches the policy’s latest image. (5 points)

Show the worked solution
# the two image controllers are NOT part of a default `flux install`
flux install --components-extra=image-reflector-controller,image-automation-controller
kubectl api-resources | grep image.toolkit     # confirm the served version before you write YAML
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: podinfo
  namespace: flux-system
spec:
  image: ghcr.io/stefanprodan/podinfo
  interval: 5m
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: podinfo
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: podinfo
  policy:
    semver:
      range: 6.x
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageUpdateAutomation
metadata:
  name: podinfo
  namespace: flux-system
spec:
  interval: 5m
  sourceRef:
    kind: GitRepository
    name: flux-system
  git:
    checkout:
      ref:
        branch: main
    commit:
      author:
        name: fluxcdbot
        email: fluxcdbot@users.noreply.github.com
      messageTemplate: |
        chore(images): {{range .Changed.Changes}}{{print .OldValue}} -> {{println .NewValue}}{{end}}
    push:
      branch: main
  update:
    path: ./apps/podinfo
    strategy: Setters
# apps/podinfo/deployment.yaml — the marker comment is the whole mechanism
spec:
  template:
    spec:
      containers:
        - name: podinfo
          image: ghcr.io/stefanprodan/podinfo:6.5.4 # {"$imagepolicy": "flux-system:podinfo"}
kubectl apply -f image-automation.yaml
flux get images repository -A
flux get images policy -A                  # LATEST IMAGE resolves within one interval
flux reconcile image repository podinfo -n flux-system
flux reconcile image update podinfo -n flux-system

git fetch origin && git log --oneline -1 origin/main
kubectl -n podinfo get deploy podinfo -o jsonpath='{.spec.template.spec.containers[0].image}'

Why: this is the one piece of GitOps that legitimately writes to the repo rather than only reading from it, and it stays inside the four principles because the commit is still the source of truth — the automation is just a very reliable colleague raising the same one-line change you kept forgetting. Three details decide whether it works. The image controllers are optional components and a default flux install leaves them out, so the CRDs are simply absent and your manifests fail with “no matches for kind”. The Setters strategy does nothing without the inline $imagepolicy marker comment — that comment, not the field path, is what the updater matches on. And the automation’s credentials must be able to push: a read-only deploy key reconciles fine and then fails silently at the push step, visible only in kubectl -n flux-system logs deploy/image-automation-controller. Compare with Argo CD Image Updater in Flux and release engineering.

X4 · Fan a Tekton pipeline out with a matrix, gate it, and always clean up

The library build currently runs its test suite three times in three near-identical Tasks, promotes unconditionally whether the target is staging or production, and leaves its scratch PVC behind whenever anything fails. You are rewriting the Pipeline to fan the tests out from one definition, promote only for production, and clean up on every path including failure.

Your task:

  1. Replace the three test Tasks with one matrixed PipelineTask that fans out across three runtime versions.
  2. Guard the promote task with a when expression so it only runs when the environment parameter is prod.
  3. Add a finally section that always cleans up, plus a notification that fires only when the pipeline failed.

Done when: for a run with environment=staging, kubectl get taskrun -l tekton.dev/pipelineRun=<run>,tekton.dev/pipelineTask=unit-tests -o name | wc -l prints 3, kubectl get pipelinerun <run> -o jsonpath='{.status.skippedTasks[*].name}' prints promote, and tkn pipelinerun describe <run> shows the cleanup finally task Succeeded. (5 points)

Show the worked solution
apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: pytest
spec:
  params:
    - name: version
      type: string
  steps:
    - name: test
      image: python:$(params.version)-alpine
      script: |
        #!/bin/sh
        set -eu
        python --version
        echo "running the suite on $(params.version)"
---
apiVersion: tekton.dev/v1
kind: Task
metadata:
  name: cleanup
spec:
  steps:
    - name: cleanup
      image: alpine:3.20
      script: |
        #!/bin/sh
        echo "removing scratch data"
---
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: verify-and-ship
spec:
  params:
    - name: environment
      type: string
      default: staging
  tasks:
    - name: unit-tests
      taskRef:
        name: pytest
      matrix:
        params:
          - name: version
            value: ["3.11", "3.12", "3.13"]     # one PipelineTask, three TaskRuns
    - name: promote
      runAfter: [unit-tests]
      when:
        - input: $(params.environment)
          operator: in
          values: ["prod"]
      taskRef:
        name: cleanup                            # stand-in for the real promote Task
  finally:
    - name: cleanup
      taskRef:
        name: cleanup
    - name: notify-on-failure
      when:
        - input: $(tasks.status)
          operator: in
          values: ["Failed"]
      taskRef:
        name: cleanup
kubectl apply -f pipeline.yaml
tkn pipeline start verify-and-ship --param environment=staging --showlog

RUN=$(kubectl get pipelinerun --sort-by=.metadata.creationTimestamp -o name | tail -1)
NAME=${RUN#pipelinerun.tekton.dev/}

# scope the label selector to THIS run, or a second sitting counts six TaskRuns
kubectl get taskrun -l tekton.dev/pipelineRun=$NAME,tekton.dev/pipelineTask=unit-tests \
  -o name | wc -l                                                           # 3
kubectl get $RUN -o jsonpath='{.status.skippedTasks[*].name}'               # promote
kubectl get $RUN -o jsonpath='{.status.skippedTasks[*].reason}'
# When Expressions evaluated to false
tkn pipelinerun describe $NAME

Why: the three features cover three different axes and the exam likes them together. Matrix is fan-out: the parameter listed under matrix.params is not also listed under params, and Tekton produces the cartesian product as separate TaskRuns you can find by the tekton.dev/pipelineTask label. when is conditional execution, and the important consequence is that a skipped task’s dependents are skipped too — which is why a when on a mid-pipeline task can quietly prune half your graph. finally runs regardless of outcome, which is where cleanup belongs; inside it, $(tasks.status) gives you the aggregate result (Succeeded, Completed, Failed, None) so a notification can fire on failures only. Note that a skipped task counts as neither success nor failure, so a pipeline whose only real work was skipped still reports Completed. More in Tekton.

X5 · Teach Argo CD what “healthy” means for your own API

Your platform ships an Environment custom resource, and an App-of-Apps delivers it in sync wave 0 with the tenant’s workloads in wave 1. The problem: Argo CD reports the parent Application Healthy the instant the Environment object is created, so wave 1 starts while the environment is still provisioning and the workloads crash-loop against a database that does not exist yet. Nobody has changed a manifest — Argo CD simply has no opinion about a kind it has never seen.

Your task:

  1. Confirm from evidence that Argo CD is assessing the Environment as healthy without evaluating anything.
  2. Add a custom Lua health check for platform.acme.io/Environment that maps its Ready condition onto Progressing, Healthy and Degraded.
  3. Test the script against a real object before you rely on it, then apply it and show the parent Application follows the condition.

Done when: with the Environment’s Ready condition False, kubectl -n argocd get app platform -o jsonpath='{.status.health.status}' prints Degraded; once the controller sets Ready=True the same command prints Healthy and the wave-1 Application begins syncing. (4 points)

Show the worked solution
# 1. evidence: the resource tree shows Healthy with no message at all
argocd app get platform --show-operation
kubectl -n argocd get app platform -o jsonpath='{range .status.resources[*]}{.kind}{" "}{.health.status}{"\n"}{end}'
# Environment Healthy      <- with no health check registered, unknown kinds default to Healthy
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-cm
  namespace: argocd
  labels:
    app.kubernetes.io/name: argocd-cm
    app.kubernetes.io/part-of: argocd
data:
  resource.customizations.health.platform.acme.io_Environment: |
    local hs = {}
    hs.status = "Progressing"
    hs.message = "waiting for the Environment controller"
    if obj.status ~= nil and obj.status.conditions ~= nil then
      for i, condition in ipairs(obj.status.conditions) do
        if condition.type == "Ready" and condition.status == "False" then
          hs.status = "Degraded"
          hs.message = condition.message
          return hs
        end
        if condition.type == "Ready" and condition.status == "True" then
          hs.status = "Healthy"
          hs.message = condition.message
          return hs
        end
      end
    end
    return hs
# 2. test the Lua against a real object BEFORE trusting it in a sync wave
kubectl -n demo get environment demo -o yaml > /tmp/env.yaml
argocd admin settings resource-overrides health /tmp/env.yaml --argocd-cm-path ./argocd-cm.yaml

# merge, never plain apply: argocd-cm holds url, dex and exclusions too, and a
# partial apply would drop every key you left out of your file
kubectl -n argocd patch cm argocd-cm --type merge --patch-file argocd-cm.yaml
kubectl -n argocd rollout restart deploy/argocd-server statefulset/argocd-application-controller

kubectl -n argocd get app platform -o jsonpath='{.status.health.status}'    # Degraded
kubectl -n demo patch environment demo --subresource=status --type=merge \
  -p '{"status":{"conditions":[{"type":"Ready","status":"True","reason":"Provisioned","message":"ready","lastTransitionTime":"2026-07-21T09:00:00Z"}]}}'
kubectl -n argocd get app platform -o jsonpath='{.status.health.status}'    # Healthy

Why: Argo CD ships built-in health assessments for the workload kinds it knows and, for everything else, reports Healthy by default — a deliberate choice that keeps unknown CRs from blocking syncs forever, and a landmine the moment you use sync waves. Waves advance when the current wave is healthy, so a kind with no opinion makes the wave boundary meaningless, which is exactly the failure in front of you. The fix is a Lua script registered under the key resource.customizations.health.<group>_<kind> — note the underscore between group and kind — returning a table with status and message. Two habits matter: return Progressing rather than Healthy as the fallback, so a missing status blocks the wave instead of waving it through; and test the script with argocd admin settings resource-overrides health against a real object first, because a Lua error is reported as Unknown and is painful to debug through a controller restart. Background in Argo CD and platform APIs & operators.

🦆 Dot’s-eye view

“Three of those five were somebody else’s half-finished work, and that is exactly what my week looks like. The canary that never decides, the app that says Healthy while my pods are on fire, the tag nobody remembered to bump — I have filed tickets for all three. What I notice is that none of the fixes were clever. They were all ‘the thing you configured is not the thing that is running, go and look at what is actually loaded’.”

Platform APIs & Self-Service — X6 to X10 (25 points)

☺ Like you’re 10: These five are about inventing your own kind of Kubernetes object — and then making sure nobody can use it wrongly, that it cleans up after itself, and that two robots do not fight over it.

The other 25% domain, and the one where Set 6 leans hardest on chaining: a custom API is only useful when the guardrails, the permissions and the quotas around it work too. Background: Platform APIs & operators, self-service & golden paths, IaC & control planes and developer experience.

X6 · Publish a claimable API and gate it with policy

Teams have been asking for self-service Postgres. You have a working Crossplane Composition already; what is missing is the public API and the rules around it. Finance wants every database attributable to a cost centre, and the DBAs want the large size to require a named approver — but neither wants to review tickets, so both rules have to be enforced at the moment of the claim.

Your task:

  1. Publish a CompositeResourceDefinition exposing a namespaced PostgresInstance claim with a required size (enum) and team.
  2. Write a Kyverno ClusterPolicy that rejects a claim without a platform.acme.io/cost-centre annotation, and rejects size: large unless an approver label is present.
  3. Show one rejected claim and one accepted claim that goes on to compose real resources.

Done when: kubectl -n team-a apply -f claim-bad.yaml fails with the policy message, kubectl -n team-a apply -f claim-good.yaml succeeds, and kubectl -n team-a get postgresinstance demo -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' eventually prints True with kubectl get managed listing the composed resource. (6 points)

Show the worked solution
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresinstances.platform.acme.io
spec:
  group: platform.acme.io
  names:
    kind: XPostgresInstance
    plural: xpostgresinstances
  claimNames:
    kind: PostgresInstance
    plural: postgresinstances
  defaultCompositionRef:
    name: postgres-standard
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [parameters]
              properties:
                parameters:
                  type: object
                  required: [size, team]
                  properties:
                    size:
                      type: string
                      enum: [small, medium, large]
                    team:
                      type: string
                      pattern: '^[a-z][a-z0-9-]{1,29}$'
      additionalPrinterColumns:
        - name: Size
          type: string
          jsonPath: .spec.parameters.size
        - name: Team
          type: string
          jsonPath: .spec.parameters.team
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: postgres-claim-guardrails
spec:
  validationFailureAction: Enforce      # per-rule validate.failureAction in newer Kyverno
  background: false
  rules:
    - name: require-cost-centre
      match:
        any:
          - resources:
              kinds:
                - platform.acme.io/v1alpha1/PostgresInstance
      validate:
        message: "a PostgresInstance needs a platform.acme.io/cost-centre annotation like cc-4021"
        pattern:
          metadata:
            annotations:
              platform.acme.io/cost-centre: "cc-?*"

    - name: large-needs-approval
      match:
        any:
          - resources:
              kinds:
                - platform.acme.io/v1alpha1/PostgresInstance
      preconditions:
        all:
          - key: "{{ request.object.spec.parameters.size }}"
            operator: Equals
            value: large
      validate:
        message: "size=large requires a platform.acme.io/approved-by label naming the DBA who signed it off"
        pattern:
          metadata:
            labels:
              platform.acme.io/approved-by: "?*"
# claim-bad.yaml — no cost centre, and large without an approver
apiVersion: platform.acme.io/v1alpha1
kind: PostgresInstance
metadata:
  name: demo
  namespace: team-a
spec:
  parameters:
    size: large
    team: team-a
---
# claim-good.yaml
apiVersion: platform.acme.io/v1alpha1
kind: PostgresInstance
metadata:
  name: demo
  namespace: team-a
  annotations:
    platform.acme.io/cost-centre: cc-4021
  labels:
    platform.acme.io/approved-by: r.okonjo
spec:
  parameters:
    size: large
    team: team-a
  writeConnectionSecretToRef:
    name: demo-postgres
kubectl apply -f xrd.yaml
kubectl get xrd xpostgresinstances.platform.acme.io      # ESTABLISHED and OFFERED both True
kubectl api-resources | grep postgresinstances           # the claim CRD now exists

kubectl apply -f policy.yaml
kubectl get clusterpolicy postgres-claim-guardrails      # READY True before you test

kubectl -n team-a apply -f claim-bad.yaml                # admission webhook denies it
kubectl -n team-a apply -f claim-good.yaml
kubectl -n team-a get postgresinstance demo
kubectl get xpostgresinstance,managed
kubectl -n team-a describe postgresinstance demo         # events explain a stuck composition

Why: the XRD and the policy answer two different questions and you need both. The XRD’s OpenAPI schema enforces shape — required fields, an enum, a name pattern — and the API server rejects anything malformed before Crossplane ever sees it. But schema cannot express “must reference something that exists elsewhere” or “this value needs a human’s approval”, which is where an admission policy engine belongs. Two mechanics decide whether this works: Kyverno matches custom kinds by fully qualified group/version/Kind, so a bare PostgresInstance can silently match nothing when another API has the same kind name; and preconditions are what let a single policy apply a rule to only some objects, evaluated before the pattern so the cheap common case is not penalised. Remember the claim is namespaced while the composite it creates is cluster-scoped — policy on the claim is policy where the developer actually is. See Crossplane, Kyverno and self-service & golden paths.

X7 · Back a Backstage golden path with an admission policy that agrees with it

Your Backstage template scaffolds new services and stamps a cost-centre label into every pod template. It works — for services created through the template. Roughly a third of the fleet was created before the template existed or by someone running kubectl create deployment, and the FinOps report has a large “unattributed” bucket. The template is a convention; you are going to make the cluster enforce the same rule so the two cannot drift apart.

Your task:

  1. Give the Backstage Template a required costCentre parameter constrained by a regular expression, and have the scaffolder pass it into the skeleton.
  2. Write a ValidatingAdmissionPolicy whose CEL expression enforces exactly the same regular expression on every Deployment pod template, bound only to tenant namespaces.
  3. Roll the binding out in warn mode first, then switch it to deny, and prove both outcomes.

Done when: kubectl -n tenant-a create deployment nope --image=nginx is rejected quoting the policy message, while a Deployment carrying platform.acme.io/cost-centre: cc-4021 in its pod template is admitted, and the same ^cc-[0-9]{4}$ pattern appears in the template’s parameter schema. (6 points)

Show the worked solution
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: golden-service
  title: Golden path service
  description: A service that is attributable, observable and admissible on day one.
  tags: [recommended, golden-path]
spec:
  owner: group:platform
  type: service
  parameters:
    - title: Service details
      required: [name, costCentre, owner]
      properties:
        name:
          title: Name
          type: string
          pattern: '^[a-z][a-z0-9-]{2,29}$'
        costCentre:
          title: Cost centre
          type: string
          description: The finance code this service is billed to.
          pattern: '^cc-[0-9]{4}$'
        owner:
          title: Owner
          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 }}
          costCentre: ${{ parameters.costCentre }}
          owner: ${{ parameters.owner }}
    - id: publish
      name: Publish the repository
      action: publish:github
      input:
        repoUrl: github.com?owner=acme&repo=${{ parameters.name }}
        description: Scaffolded by the golden path
    - id: register
      name: Register in the catalog
      action: catalog:register
      input:
        repoContentsUrl: ${{ steps.publish.output.repoContentsUrl }}
        catalogInfoPath: /catalog-info.yaml
  output:
    links:
      - title: Repository
        url: ${{ steps.publish.output.remoteUrl }}
      - title: Open in the catalog
        icon: catalog
        entityRef: ${{ steps.register.output.entityRef }}
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-cost-centre
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups:   ["apps"]
        apiVersions: ["v1"]
        operations:  ["CREATE", "UPDATE"]
        resources:   ["deployments"]
  validations:
    - expression: |
        has(object.spec.template.metadata) &&
        has(object.spec.template.metadata.labels) &&
        'platform.acme.io/cost-centre' in object.spec.template.metadata.labels &&
        object.spec.template.metadata.labels['platform.acme.io/cost-centre'].matches('^cc-[0-9]{4}$')
      message: "every pod template needs a platform.acme.io/cost-centre label of the form cc-NNNN — scaffold from the golden path template"
      reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: require-cost-centre
spec:
  policyName: require-cost-centre
  validationActions: [Deny, Audit]     # start with [Warn, Audit], then switch
  matchResources:
    namespaceSelector:
      matchLabels:
        platform.acme.io/tenant: "true"
kubectl label ns tenant-a platform.acme.io/tenant=true
kubectl apply -f vap.yaml

# measure before you enforce: Warn returns the message on the client without rejecting
kubectl patch validatingadmissionpolicybinding require-cost-centre --type=merge \
  -p '{"spec":{"validationActions":["Warn","Audit"]}}'
kubectl -n tenant-a create deployment probe --image=nginx     # created, with a warning

kubectl patch validatingadmissionpolicybinding require-cost-centre --type=merge \
  -p '{"spec":{"validationActions":["Deny","Audit"]}}'
kubectl -n tenant-a create deployment nope --image=nginx      # rejected
kubectl -n tenant-a apply -f good-deployment.yaml             # admitted
grep -n 'cc-\[0-9\]' template.yaml                            # same regex in both places

Why: a golden path that is only a convention decays the moment someone is in a hurry, so the durable pattern is template plus gate: the template makes the compliant thing easy, and the gate makes the non-compliant thing impossible. ValidatingAdmissionPolicy is the right gate here because it is built into the API server — CEL evaluated in-process, no webhook to deploy, no extra failure mode when your policy controller is down, and available on a cluster where Kyverno or Gatekeeper is not installed. Three details earn the marks: has() guards must come before any map index or CEL errors on a missing field and failurePolicy: Fail then rejects everything; the Binding is a separate object, so the same policy can be warned in one set of namespaces and denied in another; and rolling out through Warn first is the same measure-then-enforce discipline as Pod Security’s server-side dry run. Keep the regular expression identical in template and policy — two nearly-identical rules is worse than one. See Backstage, security & policy and FinOps.

X8 · Settle a server-side apply fight over one field

Your platform CLI applies a standard resource block to every managed Deployment. On shop/web it fails: Apply failed with 1 conflict: conflict with "argocd-controller". An engineer has already run it with --force-conflicts twice, which fixed the resources block and simultaneously stole spec.replicas from the autoscaler, causing a 3 a.m. capacity incident. You need the resources field and only the resources field.

Your task:

  1. Determine, from the object itself, which field manager currently owns spec.replicas and which owns the container resources block.
  2. Take ownership of the resources block for the manager platform-cli without disturbing ownership of spec.replicas.
  3. Prove afterwards that a plain server-side apply from platform-cli produces no conflict, and that replicas is still owned by its original manager.

Done when: kubectl -n shop get deploy web --show-managed-fields -o jsonpath='{.metadata.managedFields[*].manager}' lists both managers, kubectl -n shop apply --server-side --field-manager=platform-cli --dry-run=server -f web-resources.yaml exits 0 with no conflict, and the manager entry containing f:replicas is not platform-cli. (5 points)

Show the worked solution
# 1. managedFields is hidden by default — you have to ask for it
kubectl -n shop get deploy web --show-managed-fields -o yaml | head -60
kubectl -n shop get deploy web --show-managed-fields \
  -o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\t"}{.subresource}{"\n"}{end}'

# who owns replicas, specifically?
kubectl -n shop get deploy web --show-managed-fields -o json \
  | grep -o '"manager":"[^"]*"\|f:replicas'
# web-resources.yaml — apply ONLY the fields you intend to own.
# Anything you include, you claim; anything you omit later, you release.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
  namespace: shop
spec:
  template:
    spec:
      containers:
        - name: web
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              memory: 512Mi
# 2. this conflicts, and the error names the owner and the exact path
kubectl -n shop apply --server-side --field-manager=platform-cli -f web-resources.yaml

# take ONLY these fields — the file has no replicas in it, so nothing else moves
kubectl -n shop apply --server-side --force-conflicts \
  --field-manager=platform-cli -f web-resources.yaml

# 3. prove it: no conflict on a repeat, and replicas still belongs to someone else
kubectl -n shop apply --server-side --field-manager=platform-cli \
  --dry-run=server -f web-resources.yaml && echo "no conflict"
kubectl -n shop get deploy web --show-managed-fields \
  -o jsonpath='{range .metadata.managedFields[*]}{.manager}{" -> "}{.fieldsV1}{"\n"}{end}' | grep replicas

Why: server-side apply moves the merge from your laptop into the API server and records, per field, which manager owns it. That turns “two controllers fighting” from a mystery into a readable list. The rule that carries you through every SSA question is: a manager owns exactly the fields present in its last apply. So the fix is not a cleverer flag, it is a smaller file — an apply document containing only the resources block can never take spec.replicas, whatever --force-conflicts does. That is the mistake in the story: --force-conflicts is safe when the applied document is scoped and catastrophic when someone forces a full manifest that happens to include a field the autoscaler owns. Two more things worth knowing: dropping a field from a subsequent apply releases it, which is how you hand replicas back deliberately; and --show-managed-fields exists because managedFields was hidden from normal output in Kubernetes 1.21 for being unreadable, not because it stopped mattering. See configuration management and the Kubernetes substrate.

X9 · Find out why a custom resource is accepted and then ignored

Developers create Environment objects and nothing happens. kubectl get environments lists them, the CRD is Established, the controller Deployment is 1/1 Running with no restarts, and its logs look busy. No ConfigMap is ever created, no status is ever written, and kubectl wait --for=condition=Ready times out every time. The controller was working last week, before the platform team tightened RBAC across the cluster.

Your task:

  1. Establish from evidence exactly which verbs on which resources the controller is being denied — do not simply grant it more.
  2. Restore the minimum permissions the controller needs, including the ones it needs for status and finalizers.
  3. Show a fresh Environment reconciling end to end.

Done when: kubectl auth can-i create configmaps --as=system:serviceaccount:platform-system:env-controller -n demo prints yes, and kubectl -n demo wait --for=condition=Ready environment/demo2 --timeout=90s prints condition met. (4 points)

Show the worked solution
# 1. the logs already contain the answer — grep for the shape, not the symptom
kubectl -n platform-system logs deploy/env-controller --tail=200 | grep -i forbidden
# configmaps is forbidden: User "system:serviceaccount:platform-system:env-controller"
#   cannot create resource "configmaps" in API group "" in the namespace "demo"

# 2. confirm it from the caller's identity rather than your own
SA=system:serviceaccount:platform-system:env-controller
kubectl auth can-i --list --as=$SA -n demo
kubectl auth can-i create configmaps --as=$SA -n demo          # no
kubectl auth can-i patch environments/status --as=$SA -n demo  # no
kubectl auth can-i update environments --as=$SA -n demo        # no  <- finalizers need this
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: platform:env-controller
rules:
  # the resource it owns
  - apiGroups: ["platform.acme.io"]
    resources: ["environments"]
    verbs: ["get", "list", "watch", "update", "patch"]
  - apiGroups: ["platform.acme.io"]
    resources: ["environments/status"]
    verbs: ["get", "update", "patch"]
  - apiGroups: ["platform.acme.io"]
    resources: ["environments/finalizers"]
    verbs: ["update"]
  # the children it creates
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  # so it can report what it did
  - apiGroups: [""]
    resources: ["events"]
    verbs: ["create", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: platform:env-controller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: platform:env-controller
subjects:
  - kind: ServiceAccount
    name: env-controller
    namespace: platform-system
kubectl apply -f controller-rbac.yaml
kubectl -n platform-system rollout restart deploy/env-controller

kubectl auth can-i create configmaps --as=$SA -n demo          # yes
kubectl -n demo apply -f environment-demo2.yaml
kubectl -n demo wait --for=condition=Ready environment/demo2 --timeout=90s
kubectl -n demo get cm                                         # the child appears
kubectl -n demo get events --field-selector involvedObject.kind=Environment

Why: an operator with insufficient RBAC is the quietest failure mode in platform engineering, because every surface you would normally check looks fine: the CRD is valid, the object is stored, the pod is running, and the controller is genuinely working — it is retrying a call the API server keeps refusing. The evidence order is therefore fixed: controller logs first, then kubectl auth can-i --list --as= from the controller’s identity, never your own. The three easily-missed grants are the ones this task is really about. <resource>/status is a separate subresource with its own verbs, so a controller can be allowed to update the object and still be unable to write a condition. <resource>/finalizers needs update whenever the controller adds or removes a finalizer. And events needs create and patch, or the controller loses its only way to tell a developer what went wrong. Grant those four blocks and nothing else — the temptation to reach for a wildcard here is exactly how the cluster-admin bindings in X18 got created. See Kubebuilder and the troubleshooting playbook.

X10 · Cap how much of your own API a tenant can consume

A team automated their environment creation and produced 340 Worker objects overnight, each one spawning pods. CPU quota eventually stopped the damage, but only after the control plane had spent an hour reconciling. You want a limit on the objects themselves, not just on what they consume.

Your task:

  1. Create a ResourceQuota in team-a that caps custom resource counts as well as a couple of built-in object counts.
  2. Demonstrate the cap by creating one object past the limit.

Done when: kubectl -n team-a describe quota platform-objects shows the used and hard counts for the custom resources, and creating the sixth Worker fails with exceeded quota: platform-objects. (4 points)

Show the worked solution
apiVersion: v1
kind: ResourceQuota
metadata:
  name: platform-objects
  namespace: team-a
spec:
  hard:
    count/workers.platform.acme.io: "5"
    count/postgresinstances.platform.acme.io: "2"
    count/services.loadbalancers: "1"
    count/secrets: "25"
    count/configmaps: "40"
kubectl apply -f quota.yaml
kubectl -n team-a describe quota platform-objects

for i in 1 2 3 4 5 6; do
  kubectl -n team-a create -f - <<EOF
apiVersion: platform.acme.io/v1
kind: Worker
metadata:
  name: etl-$i
spec:
  queue: etl
EOF
done
# the sixth: Error from server (Forbidden): error when creating "STDIN":
#   workers.platform.acme.io "etl-6" is forbidden: exceeded quota: platform-objects,
#   requested: count/workers.platform.acme.io=1, used: 5, limited: 5

kubectl -n team-a get resourcequota platform-objects -o yaml | grep -A6 'used:'

Why: ResourceQuota is usually taught as a CPU-and-memory tool, but the generic object-count syntax count/<plural>.<group> works for any namespaced resource including your own custom kinds, and it is the only lever that protects the control plane rather than the nodes. That distinction matters: compute quota stops the pods, but ten thousand custom objects still cost you etcd storage, watch traffic and a controller queue that never drains — damage that a CPU limit does nothing about. Two syntax details are worth memorising because you cannot look them up: the group is appended after a dot and the resource name is the plural as it appears in kubectl api-resources; and a handful of built-ins have special forms such as count/services.loadbalancers and count/services.nodeports, which cap the expensive kinds of Service specifically. Quota is enforced at admission, so it stops the create rather than cleaning up afterwards. Pair it with a LimitRange for per-object defaults — see governance & compliance and platform architecture.

🐢 Timmy’s drill · 12 min

Before you sit this paper, do a pure-speed warm-up on the shapes Set 6 assumes you already own. From a cold terminal, with only kubectl explain --recursive and kubectl api-resources, produce a valid skeleton for each of these in under three minutes apiece: a ValidatingAdmissionPolicy with one CEL expression and its binding; a ResourceQuota counting a custom kind; a ClusterRole granting a controller its status and finalizers subresources; and a PrometheusRule with one recording rule. If any of the four takes longer than three minutes, that is your highest-value study target this week — ahead of any concept, and ahead of reading this page again.

Observability & Operations — X11 to X13 (20 points)

☺ Like you’re 10: These three are about noticing things: making sure the watcher is actually watching, turning traces into numbers, and turning log lines into an alarm.

Twenty percent of the paper across three expensive tasks, and the domain that most rewards a fixed evidence order. When something is “not showing up in Prometheus”, resist the urge to edit YAML — go and read what the system actually loaded. Background: observability, Prometheus, OpenTelemetry, Loki, reliability & incidents and the troubleshooting playbook.

X11 · Work out why a ServiceMonitor is scraping nothing

The payments team added a ServiceMonitor a week ago. Their /metrics endpoint works — you can curl it from inside the cluster — but no payments target ever appears in Prometheus, the dashboard is empty, and their SLO alert has never fired, including during a real incident. Nothing in any log mentions it. Someone has already tried deleting and re-applying the ServiceMonitor three times.

Your task:

  1. Work from evidence to find every reason the target is missing — there is more than one, and guessing will cost you the task.
  2. Fix them with the smallest correct changes, on the right objects.
  3. Prove the target is up and that a query against it returns data.

Done when: curl -s 'http://localhost:9090/api/v1/targets?state=active' | grep -c '"job":"api"' returns at least 1 with "health":"up", and curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=up{job="api"}' returns a result whose value is 1. (8 points)

Show the worked solution
# fixed evidence order. Step one is ALWAYS "what did Prometheus actually load?"
kubectl -n monitoring get secret prometheus-kube-prometheus-stack-prometheus \
  -o jsonpath='{.data.prometheus\.yaml\.gz}' | base64 -d | gunzip | grep -A5 payments
# nothing -> the ServiceMonitor was never selected. It is a discovery problem, not a scrape problem.

# what does the Prometheus CR select, and from where?
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorSelector}{"\n"}'
kubectl -n monitoring get prometheus -o jsonpath='{.items[0].spec.serviceMonitorNamespaceSelector}{"\n"}'
# {"matchLabels":{"release":"kube-prometheus-stack"}}   and   {}  (all namespaces)

# what does the ServiceMonitor carry, and what does it select?
kubectl -n payments get servicemonitor api \
  -o jsonpath='{.metadata.labels}{"\n"}{.spec.selector}{"\n"}{.spec.endpoints[*].port}{"\n"}'
kubectl -n payments get svc api -o jsonpath='{.metadata.labels}{"\n"}{.spec.ports[*].name}{"\n"}'
# three faults, three one-line fixes
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: api
  namespace: payments
  labels:
    release: kube-prometheus-stack   # (1) was missing: the Prometheus CR never selected it
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: api    # (2) was app=api; the Service is labelled differently
  namespaceSelector:
    matchNames:
      - payments
  endpoints:
    - port: metrics                  # (3) was "8080": this must be the Service PORT NAME
      path: /metrics
      interval: 30s
---
apiVersion: v1
kind: Service
metadata:
  name: api
  namespace: payments
  labels:
    app.kubernetes.io/name: api
spec:
  selector:
    app.kubernetes.io/name: api
  ports:
    - name: metrics                  # a named port, or the ServiceMonitor has nothing to match
      port: 8080
      targetPort: 8080
kubectl apply -f servicemonitor.yaml -f service.yaml

# the operator regenerates the config; watch it appear rather than assuming
kubectl -n monitoring get secret prometheus-kube-prometheus-stack-prometheus \
  -o jsonpath='{.data.prometheus\.yaml\.gz}' | base64 -d | gunzip | grep -A5 payments

kubectl -n monitoring port-forward svc/kube-prometheus-stack-prometheus 9090:9090 &
curl -s 'http://localhost:9090/api/v1/targets?state=active' | grep -o '"job":"api"[^}]*health":"[a-z]*"'
curl -sG http://localhost:9090/api/v1/query --data-urlencode 'query=up{job="api"}'

Why: “my ServiceMonitor does not work” is three different bugs wearing one costume, and they live at three different layers, which is why a fixed order beats intuition. The operator layer decides whether your ServiceMonitor is even converted into scrape config: the Prometheus CR’s serviceMonitorSelector and serviceMonitorNamespaceSelector must both match, and the kube-prometheus-stack chart defaults to selecting on a release label — miss it and your object is inert with no error anywhere. The selector layer decides which Services it covers, and pod labels are irrelevant here: a ServiceMonitor selects Services. The endpoint layer decides which port, and port means the Service port’s name, not a number — an unnamed port simply cannot be referenced. The single most valuable command in the whole task is decompressing the generated config secret, because it answers “did discovery happen?” definitively and in one step, instead of the three you would otherwise spend guessing. More decision trees in Prometheus and triaging workloads.

X12 · Turn traces into RED metrics and label them with Kubernetes context

Your services emit OpenTelemetry traces, but the platform has no request-rate or latency metrics at all — the application teams never instrumented metrics, and asking twelve teams to do so is not a plan. You also cannot tell which namespace or deployment a trace came from, because the SDK only knows the service name. You are going to derive the metrics from the traces you already have, and enrich the spans with cluster context on the way through.

Your task:

  1. Add the k8sattributes processor so spans gain namespace, pod, node and deployment attributes — including the RBAC it needs, which it does not have.
  2. Add the spanmetrics connector so the traces pipeline also feeds a metrics pipeline, keeping namespace and deployment as dimensions.
  3. Export those metrics for Prometheus to scrape, and prove the series exist with the Kubernetes labels attached.

Done when: kubectl -n observability logs deploy/otel-collector | grep -ci forbidden returns 0, and curl -s http://localhost:8889/metrics | grep traces_span_metrics_calls_total returns series carrying both k8s_namespace_name and k8s_deployment_name labels. (7 points)

Show the worked solution
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 15
  k8sattributes:
    auth_type: serviceAccount
    passthrough: false
    extract:
      metadata:
        - k8s.namespace.name
        - k8s.pod.name
        - k8s.node.name
        - k8s.deployment.name
      labels:
        - tag_name: team
          key: platform.acme.io/team
          from: pod
    pod_association:
      - sources:
          - from: resource_attribute
            name: k8s.pod.ip
      - sources:
          - from: connection
  batch:
    timeout: 5s
    send_batch_size: 1024

connectors:
  spanmetrics:
    histogram:
      explicit:
        buckets: [10ms, 50ms, 100ms, 250ms, 500ms, 1s, 5s]
    dimensions:
      - name: k8s.namespace.name
      - name: k8s.deployment.name
      - name: http.route
    metrics_flush_interval: 15s

exporters:
  otlp/jaeger:
    endpoint: jaeger-collector.observability.svc:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889
    resource_to_telemetry_conversion:
      enabled: true

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter, k8sattributes, batch]
      exporters: [otlp/jaeger, spanmetrics]     # a connector is an exporter here
    metrics/spanmetrics:
      receivers: [spanmetrics]                   # ...and a receiver here
      processors: [batch]
      exporters: [prometheus]
# without this the processor starts, logs "forbidden", and silently enriches nothing
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: otel-collector
rules:
  - apiGroups: [""]
    resources: ["pods", "namespaces", "nodes"]
    verbs: ["get", "list", "watch"]
  - apiGroups: ["apps"]
    resources: ["replicasets"]          # required to derive k8s.deployment.name
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: otel-collector
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: otel-collector
subjects:
  - kind: ServiceAccount
    name: otel-collector
    namespace: observability
kubectl apply -f otel-rbac.yaml
kubectl -n observability create cm otel-collector-conf --from-file=config.yaml \
  --dry-run=client -o yaml | kubectl apply -f -
kubectl -n observability rollout restart deploy/otel-collector
kubectl -n observability logs deploy/otel-collector | grep -i forbidden    # expect nothing

kubectl -n observability port-forward deploy/otel-collector 8889:8889 &
curl -s http://localhost:8889/metrics | grep traces_span_metrics_calls_total
curl -s http://localhost:8889/metrics | grep traces_span_metrics_duration_milliseconds_bucket | head -3

Why: two capabilities, joined by one config file. A connector is the piece people have not met: it is an exporter on one pipeline and a receiver on another, which is how a stream of spans becomes a stream of metrics without leaving the collector. spanmetrics gives you rate, errors and duration for every instrumented service for free — the classic RED signals — and it is the single highest-leverage thing you can turn on when application teams have instrumented traces but not metrics. The dimensions list is what makes it useful: without it every service collapses into one undifferentiated series, and dimensions can only reference attributes that already exist on the span, which is exactly why k8sattributes must sit earlier in the traces pipeline than the connector. And k8sattributes is the chained trap: it talks to the API server, so it needs a ClusterRole, and deriving k8s.deployment.name means walking pod → ReplicaSet → Deployment, so replicasets must be in it. Missing RBAC does not crash the collector; it logs and enriches nothing, which looks exactly like a configuration mistake. Keep memory_limiter first and batch last, as always. See OpenTelemetry and Jaeger.

X13 · Alert on something that only exists in the logs

The payments service logs a stack trace beginning panic: when a particular race condition fires. It does not increment any metric, the request still returns 200 to a retrying client, and the only reason anyone knows is that a developer happened to be tailing logs. Instrumenting the application properly is a two-sprint job; the platform can close the gap this afternoon.

Your task:

  1. Write the LogQL that turns those log lines into a rate series broken down by app, and prove it returns data.
  2. Ship it to the Loki ruler as a rule group containing a recording rule and an alert, mounted where the ruler will actually find it.
  3. Point the ruler at Alertmanager and show the alert leave inactive when panics are generated.

Done when: curl -s http://localhost:3100/prometheus/api/v1/rules | grep -c PaymentsPanicBurst returns at least 1, and after generating panic lines the same endpoint shows the alert in state pending or firing. (5 points)

Show the worked solution
# 1. get the query right interactively before you commit it to a rule file
kubectl -n observability port-forward svc/loki 3100:3100 &
curl -sG http://localhost:3100/loki/api/v1/query_range \
  --data-urlencode 'query=sum by (app) (rate({namespace="payments"} |= `panic:` [5m]))' \
  --data-urlencode 'step=60'
# equivalently, if logcli is on the desktop:
logcli query --since=1h 'sum by (app) (rate({namespace="payments"} |= `panic:` [5m]))'
apiVersion: v1
kind: ConfigMap
metadata:
  name: loki-rules-payments
  namespace: observability
  labels:
    loki_rule: "true"
data:
  # single-tenant Loki (auth_enabled: false) reads rules for the tenant "fake"
  payments.yaml: |
    groups:
      - name: payments-logs
        interval: 1m
        rules:
          - record: payments:log_panics:rate5m
            expr: |
              sum by (namespace, app) (rate({namespace="payments"} |= `panic:` [5m]))

          - alert: PaymentsPanicBurst
            expr: |
              sum by (namespace, app) (rate({namespace="payments"} |= `panic:` [5m])) > 0.1
            for: 5m
            labels:
              severity: critical
              team: payments
            annotations:
              summary: "panics in {{ $labels.app }}"
              description: "more than 0.1 panic lines per second for five minutes in {{ $labels.namespace }}"
# the ruler half of the Loki config — rules are useless if nothing evaluates them
ruler:
  storage:
    type: local
    local:
      directory: /rules
  rule_path: /tmp/loki/rules-temp
  ring:
    kvstore:
      store: inmemory
  enable_api: true
  enable_alertmanager_v2: true
  alertmanager_url: http://kube-prometheus-stack-alertmanager.monitoring.svc:9093
# loki-rules-mount.yaml — a rule file the ruler cannot SEE is a rule file that never runs.
# It must be on disk under ruler.storage.local.directory / <tenant>.
spec:
  template:
    spec:
      containers:
        - name: loki
          volumeMounts:
            - name: loki-rules
              mountPath: /rules/fake      # single-tenant Loki uses the tenant id "fake"
              readOnly: true
      volumes:
        - name: loki-rules
          configMap:
            name: loki-rules-payments
kubectl apply -f loki-rules.yaml
kubectl -n observability patch statefulset loki --patch-file loki-rules-mount.yaml
kubectl -n observability rollout restart statefulset/loki
kubectl -n observability rollout status statefulset/loki

curl -s http://localhost:3100/loki/api/v1/rules                 # the group, as loaded
curl -s http://localhost:3100/prometheus/api/v1/rules | grep PaymentsPanicBurst

# generate the signal, then watch the state machine move
kubectl -n payments exec deploy/api -- sh -c 'for i in 1 2 3 4 5 6 7 8 9 10; do echo "panic: runtime error"; done'
sleep 90
curl -s http://localhost:3100/prometheus/api/v1/rules | grep -o '"state":"[a-z]*"' | sort -u

Why: logs are the signal of last resort, and the whole art is converting them into something with the same shape as a metric so the rest of your alerting machinery works unchanged. LogQL does that in two halves: a stream selector ({namespace="payments"}) that must always come first because it is what makes the query cheap, then a line filter (|=) and an aggregation that produces a range vector Prometheus-style rules can evaluate. Two operational traps decide the marks. First, the Loki ruler is a separate component with its own storage configuration — a rules ConfigMap that is not mounted where ruler.storage.local.directory points is simply never read, and single-tenant Loki looks under the tenant directory fake, which is the detail nobody guesses. Second, the ruler needs alertmanager_url or the alert evaluates perfectly and notifies nobody. Keep the filter as specific as you can afford — a broad |= across a busy namespace is an expensive query running every interval, forever. See Loki and reliability & incidents.

Platform Architecture & Infrastructure — X14 to X16 (15 points)

☺ Like you’re 10: These three are about the boring things that save you: keeping a copy, being able to take a machine away without hurting anyone, and not throwing away the data when you tidy up.

Three self-contained tasks with the best points-per-minute on the paper after the Security pair. They sit late, which means candidates who fall behind never open them — do not be one. Background: platform architecture, storage & state, scaling & scheduling, reliability & incidents and Velero.

X14 · Schedule a backup, then restore it somewhere else

A namespace was deleted by a mis-scoped script last quarter and rebuilt by hand over two days. You are putting a real backup in place — and, because a backup nobody has ever restored is a rumour rather than a backup, you are proving the restore works by bringing the data back into a different namespace so it can be verified without touching production.

Your task:

  1. Diagnose why the first ad-hoc backup completes as PartiallyFailed and fix it.
  2. Create a Schedule that backs up the payments namespace nightly with a 30-day retention, excluding events, and take an immediate backup from it.
  3. Restore that backup into payments-dr using a namespace mapping, and verify the restored objects.

Done when: velero backup get shows the backup in phase Completed, velero restore get shows the restore Completed with 0 errors, and kubectl -n payments-dr get deploy,cm,secret,pvc lists the restored objects. (6 points)

Show the worked solution
# 1. a PartiallyFailed backup almost always starts at the storage location
kubectl -n velero get backupstoragelocation
# NAME      PHASE         LAST VALIDATED   ACCESS MODE
# default   Unavailable                    ReadWrite
kubectl -n velero describe backupstoragelocation default | tail -20
velero backup logs payments-firstrun | grep -i error    # the PartiallyFailed one from the story
kubectl -n velero logs deploy/velero | grep -i 'backup storage location'
# fix the bucket / credentials secret, then wait for PHASE Available
kubectl -n velero get backupstoragelocation -w
apiVersion: velero.io/v1
kind: Schedule
metadata:
  name: payments-nightly
  namespace: velero
spec:
  schedule: "0 2 * * *"
  template:
    includedNamespaces:
      - payments
    excludedResources:
      - events
      - events.events.k8s.io
    snapshotVolumes: false
    defaultVolumesToFsBackup: true      # file-level backup: no CSI snapshot support needed
    ttl: 720h0m0s                       # 30 days
kubectl apply -f schedule.yaml
velero schedule get

# 2. take one now rather than waiting for 02:00
velero backup create payments-adhoc --from-schedule payments-nightly --wait
velero backup describe payments-adhoc --details
velero backup get                                  # PHASE Completed, ERRORS 0

# 3. restore into a different namespace so production is never touched
velero restore create payments-dr --from-backup payments-adhoc \
  --namespace-mappings payments:payments-dr --wait
velero restore describe payments-dr --details
velero restore logs payments-dr | grep -i warn

kubectl -n payments-dr get deploy,cm,secret,pvc
kubectl -n payments-dr get pods

Why: the three objects map onto three ideas. A BackupStorageLocation is the connection to object storage, and it validates itself on a timer — which is why a PartiallyFailed or stuck backup should send you there first rather than into the backup’s own logs. A Schedule is a template plus a cron expression, so anything you can put in a backup spec you can put in a schedule, and --from-schedule lets you take an on-demand backup with identical settings — much better than hand-writing a one-off and discovering later that the nightly one differs. A Restore with --namespace-mappings is what turns a backup into a tested backup: restoring into payments-dr proves the data and the manifests are both recoverable without a maintenance window and without risking the live namespace. Note defaultVolumesToFsBackup: true chooses file-level volume backup, which works on clusters with no CSI snapshot support — the usual situation on a practice cluster — and requires the node agent to be running. See Velero and storage & state.

X15 · Make a node drain that hangs finish safely

You need to take a node out for a kernel patch. kubectl drain has been sitting there for eleven minutes repeating Cannot evict pod as it would violate the pod's disruption budget. Someone suggests --disable-eviction, which would delete the pods outright and cause the exact outage the PDB exists to prevent. There is also a crash-looping pod on the node that the drain cannot get rid of either.

Your task:

  1. Diagnose why eviction is refused, using the disruption budget’s own status rather than the drain output.
  2. Correct the budget so a rolling disruption is possible, and make it able to evict an already-unhealthy pod.
  3. Give the workload a graceful shutdown so the drain does not drop in-flight requests, then drain the node successfully.

Done when: kubectl -n shop get pdb api -o jsonpath='{.status.disruptionsAllowed}' prints a number greater than 0, and kubectl drain <node> --ignore-daemonsets --delete-emptydir-data completes with exit status 0. (5 points)

Show the worked solution
# 1. the PDB status is the whole diagnosis in one line
kubectl -n shop get pdb
# NAME   MIN AVAILABLE   MAX UNAVAILABLE   ALLOWED DISRUPTIONS   AGE
# api    3               N/A               0                     40d
kubectl -n shop get deploy api -o jsonpath='{.spec.replicas}{"\n"}'      # 3
# minAvailable == replicas -> zero disruptions are ever allowed. The drain can never finish.
kubectl -n shop get pdb api -o jsonpath='{.status}{"\n"}'
kubectl -n shop get pods -l app=api -o wide
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: api
  namespace: shop
spec:
  maxUnavailable: 1                       # expressed relatively: survives replica changes
  selector:
    matchLabels:
      app: api
  unhealthyPodEvictionPolicy: AlwaysAllow  # a broken pod can no longer deadlock the drain
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  namespace: shop
spec:
  replicas: 4
  selector:
    matchLabels:
      app: api
  template:
    metadata:
      labels:
        app: api
    spec:
      terminationGracePeriodSeconds: 45
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway
          labelSelector:
            matchLabels:
              app: api
      containers:
        - name: api
          image: ghcr.io/acme/api:5.1.0
          lifecycle:
            preStop:
              exec:
                # let endpoint removal propagate before the process starts shutting down
                command: ["/bin/sh", "-c", "sleep 10"]
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            periodSeconds: 5
kubectl apply -f pdb.yaml -f deployment.yaml
kubectl -n shop get pdb api -o jsonpath='{.status.disruptionsAllowed}{"\n"}'   # 1

kubectl cordon <node>
kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=300s
echo $?                                                    # 0
kubectl -n shop get pods -l app=api -o wide                # rescheduled, never below 3 ready
kubectl uncordon <node>

Why: a PodDisruptionBudget constrains voluntary disruption — evictions from drains, autoscaler scale-downs, node upgrades — and minAvailable equal to the replica count means zero disruptions are permitted, so the drain waits forever by design. Prefer maxUnavailable for exactly this reason: it stays correct when someone scales the Deployment, whereas an absolute minAvailable silently becomes a deadlock the day replicas drop to match it. unhealthyPodEvictionPolicy: AlwaysAllow fixes the second deadlock in the story — by default a pod that is not Ready still counts against the budget, so one crash-looping pod can block a drain indefinitely. The graceful-shutdown half is what makes the drain safe rather than merely possible: eviction and endpoint removal are concurrent, so without a preStop delay the pod can stop accepting connections while proxies are still sending them. terminationGracePeriodSeconds must exceed the preStop sleep plus the real shutdown, or the kubelet SIGKILLs you mid-request. Remember a PDB does not protect you from involuntary disruption — a node that simply dies ignores it entirely. See reliability & incidents and scaling & scheduling.

X16 · Give a StatefulSet a deliberate data-retention policy

The platform’s ledger database runs as a StatefulSet. Two problems: scaling it down from three to two leaves an orphaned PersistentVolumeClaim that nobody cleans up and everybody pays for, while deleting the StatefulSet during a botched migration last year also deleted the claims and cost the team a restore. The team wants the opposite of both behaviours, and they want to canary a config change on one replica before it touches the others.

Your task:

  1. Write the StatefulSet with a headless Service, a volume claim template, and a claim retention policy that deletes on scale-down but retains on delete.
  2. Set a rolling-update partition so only the highest ordinal takes a new template first.
  3. Prove all three behaviours: stable DNS, the scale-down deletion, and the retention on delete.

Done when: kubectl -n platform exec ledger-db-0 -- nslookup ledger-db-1.ledger-db resolves to the pod IP, scaling to 2 leaves kubectl -n platform get pvc without data-ledger-db-2, and after kubectl -n platform delete sts ledger-db the remaining PVCs are still listed. (4 points)

Show the worked solution
apiVersion: v1
kind: Service
metadata:
  name: ledger-db
  namespace: platform
spec:
  clusterIP: None              # headless: gives each pod a stable DNS name
  selector:
    app: ledger-db
  ports:
    - name: pg
      port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: ledger-db
  namespace: platform
spec:
  serviceName: ledger-db       # must name the headless Service above
  replicas: 3
  podManagementPolicy: OrderedReady
  persistentVolumeClaimRetentionPolicy:
    whenScaled: Delete         # scale down -> the surplus claim goes with it
    whenDeleted: Retain        # delete the set -> the data survives
  updateStrategy:
    type: RollingUpdate
    rollingUpdate:
      partition: 2             # only ordinals >= 2 take the new template
  selector:
    matchLabels:
      app: ledger-db
  template:
    metadata:
      labels:
        app: ledger-db
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: db
          image: postgres:16-alpine
          env:
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ledger-db
                  key: password
            - name: PGDATA
              value: /var/lib/postgresql/data/pgdata
          ports:
            - name: pg
              containerPort: 5432
          volumeMounts:
            - name: data
              mountPath: /var/lib/postgresql/data
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        storageClassName: standard
        resources:
          requests:
            storage: 1Gi
kubectl -n platform create secret generic ledger-db --from-literal=password=s3cr3t
kubectl apply -f statefulset.yaml
kubectl -n platform rollout status sts/ledger-db

# stable identity
kubectl -n platform get pvc            # data-ledger-db-0 .. -2
kubectl -n platform exec ledger-db-0 -- nslookup ledger-db-1.ledger-db   # busybox is always there

# whenScaled: Delete
kubectl -n platform scale sts ledger-db --replicas=2
kubectl -n platform get pvc            # data-ledger-db-2 is gone

# whenDeleted: Retain
kubectl -n platform delete sts ledger-db
kubectl -n platform get pvc            # data-ledger-db-0 and -1 still there

Why: a StatefulSet buys you three guarantees a Deployment cannot give: stable network identity (each pod gets <name>-<ordinal>.<service> DNS, which requires the headless Service named in serviceName), stable storage (each ordinal keeps its own PVC across rescheduling, because the claim is named after the ordinal rather than the pod), and ordered operations. persistentVolumeClaimRetentionPolicy is the newer piece and it splits a decision that used to be all-or-nothing: historically every claim was retained forever, so scale-downs quietly leaked storage. Setting whenScaled: Delete with whenDeleted: Retain encodes exactly the intent here — routine capacity changes clean up after themselves, while destroying the object never destroys the data. The partition is the StatefulSet’s canary control: with partition: 2 a template change rolls only ordinals 2 and above, so you validate on one replica and then lower the number to continue. Compare the reclaim policy on the StorageClass, which governs the PV rather than the claim, in storage & state.

Security & Policy Enforcement — X17 to X18 (15 points)

☺ Like you’re 10: Two tasks: check that the parcel really came from your own factory before you open it, and take away the master key somebody left lying on the doormat.

Only two tasks, but fifteen points — the highest points-per-task on the paper, and the pair candidates most often never reach. If you are behind schedule when you get here, do these before you go back to anything you flagged. Background: security & policy enforcement, Sigstore & cosign, Kyverno, secrets management and governance & compliance.

X17 · Refuse to run an image you cannot prove you built

An incident review found that a typosquatted image ran in production for six hours: the tag looked right, the registry path was one character out, and nothing in the platform checked provenance. Builds are already signed with cosign, but nothing verifies the signature at admission. One namespace, legacy-batch, runs vendor images that will never be signed and cannot be migrated this quarter.

Your task:

  1. Sign a build with cosign and confirm the signature verifies from the command line.
  2. Write a Kyverno ClusterPolicy that verifies signatures for your registry, requires a signature, and rewrites the image reference to a digest on admission.
  3. Carve out legacy-batch with a PolicyException rather than by weakening the policy.
  4. Prove all three outcomes: signed admitted with a digest, unsigned rejected, unsigned in the excepted namespace admitted.

Done when: kubectl -n shop get pod signed -o jsonpath='{.spec.containers[0].image}' contains @sha256:, an unsigned image in shop is rejected with an image-verification message, and the same unsigned image creates successfully in legacy-batch. (8 points)

Show the worked solution
# 1. sign, then verify from the CLI before you ever involve admission control
cosign generate-key-pair                       # cosign.key + cosign.pub
# keep signing and verification consistent with the policy's rekor.ignoreTlog below:
# sign without a transparency-log entry, and tell verify not to demand one
cosign sign --key cosign.key --tlog-upload=false -y ghcr.io/acme/checkout:3.4.0
cosign verify --key cosign.pub --insecure-ignore-tlog=true ghcr.io/acme/checkout:3.4.0 | head
cat cosign.pub                                 # this goes into publicKeys, verbatim
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-images
spec:
  validationFailureAction: Enforce
  background: false
  webhookTimeoutSeconds: 30
  failurePolicy: Fail
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/acme/*"
          required: true          # unsigned is a rejection, not a shrug
          mutateDigest: true      # rewrite tag -> digest so the verified bytes are what runs
          verifyDigest: true
          attestors:
            - count: 1
              entries:
                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                      MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEXAMPLEEXAMPLEEXAMPLEEXAMPLE
                      EXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLEEXAMPLE==
                      -----END PUBLIC KEY-----
                    rekor:
                      ignoreTlog: true      # key-based signing without a transparency log
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
  name: allow-legacy-batch
  namespace: kyverno
spec:
  exceptions:
    - policyName: verify-images
      ruleNames:
        - verify-signature
        - autogen-verify-signature     # the auto-generated Deployment/Job variant
  match:
    any:
      - resources:
          kinds:
            - Pod
          namespaces:
            - legacy-batch
kubectl apply -f verify-images.yaml
kubectl get clusterpolicy verify-images                  # READY True before testing

# exceptions must be switched on, and the namespace holding them allow-listed
kubectl -n kyverno get deploy kyverno-admission-controller \
  -o jsonpath='{.spec.template.spec.containers[0].args}' | tr ',' '\n' | grep -i exception
kubectl apply -f policy-exception.yaml

kubectl -n shop run signed --image=ghcr.io/acme/checkout:3.4.0 --restart=Never
kubectl -n shop get pod signed -o jsonpath='{.spec.containers[0].image}{"\n"}'   # ...@sha256:...

kubectl -n shop run bad --image=ghcr.io/acme/checkout:tampered --restart=Never
# error: failed to verify image ghcr.io/acme/checkout:tampered: .attestors[0].entries[0].keys: no matching signatures

kubectl -n legacy-batch run bad --image=ghcr.io/acme/checkout:tampered --restart=Never   # allowed
kubectl get policyreport -A | grep verify-images

Why: signing without verification is theatre — the signature exists and nothing consults it, which is precisely the gap the incident exposed. verifyImages closes it at admission, and three fields carry the actual security. required: true is the difference between “reject unsigned images” and “verify signatures that happen to be present”, which is a much weaker statement than it sounds. mutateDigest: true is the subtle one: verification happens against a digest, but a tag can be repointed at different bytes a second later, so rewriting the reference to @sha256:… is what guarantees the thing you verified is the thing the kubelet pulls. And the exception rather than a namespace-exclusion inside the policy is the governance point — a PolicyException is a separate, auditable object with its own review trail, so “who allowed unsigned images and where?” has an answer, whereas an exclude block buried in the policy quietly weakens it for everyone. Two operational gotchas: exceptions must be enabled on the Kyverno deployment and confined to an allow-listed namespace, and you must except the autogen- rule variants too, or the exception covers bare Pods while Deployments are still blocked. See Sigstore & cosign and Trivy.

X18 · Shut down a service account that can become cluster-admin

A routine review found that deploy-bot in the payments namespace is bound to cluster-admin — added “temporarily” two years ago so a CI job could create a CRD. It can read every Secret in the cluster and create ClusterRoleBindings, which means anyone who obtains its token owns the cluster. Every pod in the namespace also automounts a token by default, so the blast radius is every workload there, not just the CI job.

Your task:

  1. Audit from the caller’s identity: establish exactly what deploy-bot can do today, and which bindings grant it.
  2. Replace the cluster-admin binding with the least privilege the job actually needs, and prove the escalation paths are closed.
  3. Stop the namespace automounting tokens, and give the one workload that needs an identity a short-lived, audience-bound projected token instead.

Done when: kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:payments:deploy-bot prints no, kubectl auth can-i get secrets --as=system:serviceaccount:payments:deploy-bot -A prints no, and kubectl -n payments exec deploy/deploy-bot -- cat /var/run/secrets/tokens/api-token returns a JWT whose aud claim is vault. (7 points)

Show the worked solution
# 1. audit from the identity, never from your own admin context
SA=system:serviceaccount:payments:deploy-bot
kubectl auth can-i --list --as=$SA
kubectl auth can-i create clusterrolebindings --as=$SA          # yes  <- full escalation
kubectl auth can-i get secrets --as=$SA -A                      # yes  <- every secret

# which binding is responsible?
kubectl get clusterrolebinding -o jsonpath='{range .items[?(@.roleRef.name=="cluster-admin")]}{.metadata.name}{"\t"}{.subjects[*].name}{"\n"}{end}'
# anyone holding escalate or bind can grant themselves anything, so check those too
kubectl get clusterroles -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.rules[*].verbs}{"\n"}{end}' | grep -E 'escalate|bind|\*'
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deploy-bot
  namespace: payments
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "patch", "update"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: deploy-bot
  namespace: payments
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: deploy-bot
subjects:
  - kind: ServiceAccount
    name: deploy-bot
    namespace: payments
apiVersion: v1
kind: ServiceAccount
metadata:
  name: default
  namespace: payments
automountServiceAccountToken: false     # nothing in this namespace gets a token by accident
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: deploy-bot
  namespace: payments
spec:
  replicas: 1
  selector:
    matchLabels:
      app: deploy-bot
  template:
    metadata:
      labels:
        app: deploy-bot
    spec:
      serviceAccountName: deploy-bot
      automountServiceAccountToken: false    # no ambient API token...
      containers:
        - name: bot
          image: ghcr.io/acme/deploy-bot:2.0.1
          volumeMounts:
            - name: api-token
              mountPath: /var/run/secrets/tokens
              readOnly: true
      volumes:
        - name: api-token
          projected:
            sources:
              - serviceAccountToken:
                  path: api-token              # ...only this narrow, expiring one
                  audience: vault
                  expirationSeconds: 3600
kubectl delete clusterrolebinding deploy-bot-admin
kubectl apply -f rbac.yaml -f serviceaccount.yaml -f deployment.yaml

kubectl auth can-i create clusterrolebindings --as=$SA           # no
kubectl auth can-i get secrets --as=$SA -A                       # no
kubectl auth can-i patch deployments --as=$SA -n payments        # yes — the job still works
kubectl auth can-i --list --as=$SA -n payments

# the projected token is short-lived and bound to one audience
kubectl -n payments exec deploy/deploy-bot -- cat /var/run/secrets/tokens/api-token \
  | cut -d. -f2 | base64 -d 2>/dev/null
# {"aud":["vault"],"exp":...,"kubernetes.io":{"namespace":"payments",...}}

# and no long-lived token Secret is left lying around
kubectl -n payments get secrets --field-selector type=kubernetes.io/service-account-token

Why: the audit half and the fix half are equally examinable. Auditing means asking the API server from the caller’s identity with --as, because your own context is almost certainly cluster-admin and everything looks permitted; kubectl auth can-i --list --as= is the single most useful RBAC command there is. Two verbs deserve their own search: escalate lets a subject create a Role with permissions it does not itself hold, and bind lets it bind an existing powerful role — either one is equivalent to cluster-admin with an extra step, and neither is obvious from a role’s name. The fix half turns on how tokens work now. Modern Kubernetes issues bound service account tokens: time-limited, audience-scoped, tied to the pod, and rotated automatically — a stolen one expires and is rejected by any service that checks its audience. A legacy kubernetes.io/service-account-token Secret, by contrast, never expires and is valid everywhere, which is why the last check looks for one. Setting automountServiceAccountToken: false on the namespace’s default ServiceAccount removes the ambient credential from every workload that never needed one, and the projected volume gives the single workload that does need one a credential scoped to exactly the audience it talks to. See security & policy and secrets management.

⚠ The two habits that turn a policy rollout into an incident

First, enforcing before measuring. Both security tasks above have a measure step — verifying with the CLI before involving admission control in X17, auditing with --as before deleting a binding in X18 — and skipping it is how a policy change becomes an outage. Deleting a cluster-admin binding without first listing what the subject actually uses breaks a CI system at 09:00 on a Monday, and you will not know which of the removed permissions mattered. Second, testing as yourself. You are almost certainly cluster-admin on your practice cluster, and admin bypasses more than you think: PSA exempts nothing by default, but Kyverno policies commonly exempt system namespaces and service accounts, and ValidatingAdmissionPolicyBinding matching is easy to get subtly wrong. Always verify from the identity and the namespace that will actually be affected.

Score yourself

☺ Like you’re 10: Add up your points, turn it into a percentage, and then — more usefully — look at which questions you lost points on and go and read about those.

Mark after a break; marking your own work while still adrenalised produces generous nonsense. Award full points only when the done-when check actually passed on the cluster, half when the resource exists and is broadly right but the check did not pass, and zero when you did not attempt it. On a chained paper, be strict about the halves: “the AnalysisTemplate was fine but the recording rule never loaded” is half marks, not full, because in production it would still have paged someone.

TaskDomainPointsYour score
X1 · Canary analysis with a missing recording ruleGitOps7
X2 · AppProject fence and a sync windowGitOps4
X3 · Flux image automation writing back to GitGitOps5
X4 · Tekton matrix, when and finallyGitOps5
X5 · Custom Argo CD Lua health checkGitOps4
X6 · Claimable XRD gated by a Kyverno policyPlatform APIs6
X7 · Backstage template plus ValidatingAdmissionPolicyPlatform APIs6
X8 · Server-side apply field ownershipPlatform APIs5
X9 · Controller RBAC: accepted then ignoredPlatform APIs4
X10 · ResourceQuota on custom resource countsPlatform APIs4
X11 · ServiceMonitor that scrapes nothingObservability8
X12 · spanmetrics plus k8sattributes and its RBACObservability7
X13 · Loki ruler alert from log linesObservability5
X14 · Velero schedule, backup and mapped restoreArchitecture6
X15 · Unblock and make safe a hanging drainArchitecture5
X16 · StatefulSet claim retention and partitionArchitecture4
X17 · Verify image signatures with an exceptionSecurity8
X18 · Close a service account escalation pathSecurity7
TotalAll five domains100

Computing your result. The points total 100, so your raw score is your percentage, and the pass mark is 64%. Then do the more useful arithmetic and total each domain separately: a 70 made of five even domains and a 70 made of four strong domains plus a zero are completely different results, and the second one fails a real exam the day the task draw is unkind.

DomainAvailableYoursIf you scored under two-thirds, go here
GitOps & Continuous Delivery25GitOps workflows, CI/CD & progressive delivery, then re-drill the GitOps bank.
Platform APIs & Self-Service25Platform APIs & operators, self-service, then the Platform APIs bank.
Observability & Operations20Observability, reliability & incidents, then the Observability bank.
Platform Architecture & Infrastructure15Platform architecture, storage & state, scaling, then the Architecture bank.
Security & Policy Enforcement15Security & policy, governance, then the Security bank.

What to do with each individual miss. Sort your zeros and halves into three piles, because they need three different treatments. A task you did not recognise at all is a knowledge gap: read the lesson linked in its solution, then re-do the task cold two days later. A task where you knew the shape but burned the clock hunting field names is a speed problem, and the fix is the command reference plus kubectl explain drills, not more reading. A task that broke in a way you could not diagnose is a triage gap — and on this paper that is the pile to watch, because seven tasks were deliberately broken. Work the troubleshooting playbook, triaging delivery and triaging networking until the evidence order is automatic.

What your score means

☺ Like you’re 10: One number does not tell you whether you will pass — but the shape of it tells you what to practise next, which is more useful anyway.

Treat the number as a diagnostic, never as a prediction, and treat it more gently than the earlier papers. Set 6 is deliberately the hardest thing on the site: the tasks chain, seven of them start broken, and nothing is single-step. A score here is not comparable to a score on Set 1 or Set 2, and it should not be — if you scored 78 on Set 2 and 66 here, you have not gone backwards.

ScoreRead it asNext move
80–100Ready with real margin. You handled chained, broken and unfamiliar together.Stop grinding tasks. Work the final checklist, skim the field notes for exam-day logistics, and sit the real thing.
64–79A pass on the hardest paper here — a genuinely good sign, with no margin to waste.Re-drill only your weakest domain, then sit this paper again in a week and compare the domain shape, not the total.
50–63Close. On this paper that is usually triage speed plus one weak domain, not missing knowledge.A fortnight of timed drills from the bank, plus what to know cold for every shape you had to look up.
Under 50Gaps across domains, or you ran out of clock before the tail. Check which before you conclude anything.Back to the lessons for the two lowest domains, then the lab track to rebuild hands-on fluency before another mock.

Two patterns worth checking independently of the total. How many tasks did you leave completely unattempted? More than two and your problem is pacing rather than knowledge — the cheapest thing on this list to fix. And how did you do on the seven broken tasks (X1, X5, X8, X9, X11, X14, X15) as a group? If those seven are where your points went, the fix is not more study, it is the fixed evidence order: read what the system actually loaded before you edit anything, and always ask the API from the affected identity rather than your own.

🎬 At the Platform Guild
🦆

Dot: Sixty-six. On the hardest paper. I would like that engraved on something.

🐢

Timmy: Domain breakdown, please. The total is the least interesting number you have.

🦆

Dot: GitOps 22, Platform APIs 21, Observability 8, Architecture 11, Security 4.

🦫

Benny: Eight out of twenty on Observability. What happened on X11?

🦆

Dot: I rewrote the ServiceMonitor four times. Four correct times. It was never the ServiceMonitor — the Prometheus CR was not selecting it at all.

🤖

Recon: BEEP. One command would have told you that in nine seconds. Decompress the generated config secret. If your object is not in it, discovery never happened and editing the object is editing a thing nobody is reading.

👺

Gizmo: Or — hear me out — just give everything cluster-admin and turn the policies off. Instant twenty points. 🤑

🐢

Timmy: That is task X18, Gizmo. Someone did exactly that two years ago “temporarily”, and it is now worth seven marks to undo.

🦊

Foxy: The bit that got me was X1. I knew the AnalysisTemplate was right, so I kept staring at it.

🦉

Professor Owl: Which is the lesson of the whole paper, and worth more than the score. When a thing you configured has no effect, the question is never “is my YAML correct?” It is “is my YAML loaded?” Ask the running system what it believes, and half of these eighteen tasks collapse into one command.

🐢 Timmy’s checkpoint

1. Which documentation may you have open during the real exam, and name three popular projects whose own docs are not permitted. 2. A PrometheusRule applies cleanly but its series never appears — what is the first thing to check, and what is the definitive command? 3. Argo CD reports a custom resource as Healthy without evaluating anything. Why, and what breaks as a result? 4. In server-side apply, what exactly does a field manager own, and why is a smaller apply document safer than a cleverer flag? 5. Your controller’s objects are stored but never reconciled and the pod is Running. Name the three subresource or event grants people most often forget. 6. In Kyverno verifyImages, what do required: true and mutateDigest: true each buy you? 7. A drain hangs with “would violate the disruption budget”. Give the two PDB fields that resolve it and say what each one fixes. 8. Which two RBAC verbs are equivalent to cluster-admin with one extra step?

Check your answers
  1. kubernetes.io/docs, kubernetes.io/blog, task-specific docs linked from the exam’s Quick Reference box, and the local man / /usr/share documentation on the exam desktop. Not permitted: Argo CD, Flux, Tekton, Crossplane, Backstage, Prometheus, OpenTelemetry, Kyverno, Gatekeeper, Istio and Helm docs (any three). Your substitute is the live API — kubectl explain --recursive, kubectl api-resources, and reading an existing object.
  2. Its labels versus the Prometheus CR’s ruleSelector — the kube-prometheus-stack selects on release, and a mismatched rule applies with no error and never evaluates. The definitive check is what Prometheus actually loaded: curl /api/v1/rules, or decompressing the generated config secret with base64 -d | gunzip.
  3. Argo CD has no built-in health assessment for unknown kinds and defaults them to Healthy. With sync waves that makes the wave boundary meaningless — the next wave starts while the resource is still provisioning. Fix it with a Lua script under resource.customizations.health.<group>_<kind> that returns Progressing by default.
  4. A manager owns exactly the fields present in its last apply. So an apply document containing only the fields you intend to own cannot steal anything else, whatever --force-conflicts does — and dropping a field from a later apply deliberately releases it.
  5. <resource>/status (get/update/patch — a separate subresource, so conditions fail silently without it), <resource>/finalizers (update), and events (create/patch, or the controller cannot tell anyone what went wrong).
  6. required: true makes an unsigned image a rejection rather than a pass — without it you only verify signatures that happen to exist. mutateDigest: true rewrites the tag to the verified @sha256: digest, so the bytes you verified are the bytes that actually run.
  7. maxUnavailable: 1 instead of a minAvailable equal to the replica count — that is what makes any disruption allowed at all; and unhealthyPodEvictionPolicy: AlwaysAllow, which stops an already-broken pod counting against the budget and deadlocking the drain.
  8. escalate (create a Role holding permissions you do not have) and bind (bind an existing powerful role to yourself). Search for both, plus wildcard verbs, in any RBAC audit.

That is the whole paper, and the hardest one on the site. Score it, write the domain breakdown and the broken-task subtotal somewhere you will see them, and let those two numbers choose your last week. When they stop moving, the remaining work is logistical rather than technical: the exam guide covers booking, the environment and the rules; the field notes collect what candidates actually reported from the day; the final checklist is the last thing to read before you sit down; and the command reference, what to know cold, the docs map and the glossary are worth a last skim on the morning. Go again from the drill bank, warm up on Set 1, or head back to the course home to pick a lesson.

⏱ The six papers

Set 1 · Set 2 · Set 3 · Set 4 · Set 5 · Set 6 (you are here). This is the last and hardest paper — if you clear it comfortably, you are ready. All six are weighted to the official blueprint and worth 100 points each; see the practice hub for how to sit them, and the exam guide for the real thing.