Exam Prep · Mock Exam · Set 5

Mock Exam · Set 5

Another whole paper, sat in one unbroken 120-minute block on a throwaway cluster, with only the documentation you would actually be allowed on the day. Eighteen performance-based tasks, weighted exactly like the CNPE blueprint — 25% GitOps, 25% Platform APIs, 20% Observability, 15% Architecture, 15% Security — and every one of them is new. Nothing here repeats a task from the five practice banks or from Set 1, Set 2, Set 3 or Set 4. Set 5 leans harder into the two skills the earlier papers only brush: working from evidence when something is already broken, and fan-out when one change has to land in many places. Each task gives you a realistic situation, two to four numbered steps, an objective done when check you can run yourself, and a point value. Solutions are folded away so you can sit the paper honestly and mark it afterwards — then let the misses, not the total, choose your next fortnight.

☺ Explain it like I’m 10

You have already played two practice matches. This one is against a team who play differently: instead of asking you to build things from scratch, half the questions hand you something that is already broken and ask you to find out why. That is much closer to a real job — and to a real exam, where a task often starts with a cluster somebody else misconfigured on purpose. Same clock, same rules, same scoresheet. Play it properly, then read the scoresheet, not the score.

🐢🦫Your hosts for this topic: Timmy the Turtle & Benny the Beaver — Timmy keeps the conditions honest (one timer, no pausing, only the permitted docs), and Benny only awards points when the done-when command actually prints the right thing on a real cluster. “I knew that one” scores zero here, same as on the day.

Before you start — exam conditions

☺ Like you’re 10: A practice match only teaches you something if you play it like a real one — same rules, same clock, nobody helping from the sidelines.

The fastest way to waste two hours is to sit this comfortably: a pause here, a quick search there, a peek at a worked solution when a manifest will not validate. You end up with a number that flatters you and teaches nothing. Set the conditions first, then start the clock.

One — build the cluster the night before. A throwaway kind or minikube cluster is enough. For this paper you want Argo CD, Argo Rollouts, Flux, Tekton Pipelines, Argo Workflows, the kube-prometheus-stack, an OpenTelemetry Collector with a tracing backend, Crossplane, OpenCost, Istio and the Trivy Operator installed. Several tasks want more than one node and fake zone labels — kind create cluster --config with three workers, then kubectl label node kind-worker topology.kubernetes.io/zone=a and so on. If a component is missing when you reach its task, skip the task and move on; never stop the clock to install things.

Two — one timer, started once. 120 minutes, no pausing for coffee, a broken cluster or a stubborn CRD. Time you lose to your own laptop is data, not an excuse: the real exam eats time in ways you did not plan for either. Sit it at roughly the hour your real exam is booked.

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 Quick Reference box, and the local docs on the exam desktopman pages and whatever ships under /usr/share. That is the whole 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. If you stall, spend your budget, fail the task, and read the solution after the sitting. A task you pasted your way through is a task you will fail again on exam day.

Five — read the whole paper first, then choose your order. Five minutes reading all eighteen tasks is the highest-return five minutes of the sitting: you will spot the three you can finish in four minutes and the one that is going to eat twelve. Bank the cheap points first. Sequential order is a trap in a weighted, partial-credit exam.

⚠ The allowlist is narrow — and everything here can change

That allowlist changes how you must prepare. For every tool whose own documentation is not permitted, the resource shapes have to be in your head or reachable from what to know cold — you cannot look up an ApplicationSet, a Kustomization, a WorkflowTemplate, an AuthorizationPolicy or a FlowSchema 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. Separately: exam specifics move. The task count, the 120-minute duration, the 64% pass mark, the domain weights, the permitted-resource list and the Kubernetes version on the desktop have all changed before and will change again. Everything on this page is a study aid written at a point in time — the official CNCF certification pages and the Linux Foundation training site are the only authority. Confirm before you book.

Your time budget

☺ Like you’re 10: Give every question a small slice of the two hours. When a question runs past its slice, put a sticky note on it and go to the next one — you can always come back.

Eighteen tasks in 120 minutes is about six and a half minutes each, but the tasks are not equal and neither are the points. Budget deliberately: five minutes to read everything, 108 minutes of task work, and seven minutes at the end to re-run your done-when checks. That last block matters more than it sounds — half-finished work you never verified is where points quietly die.

BlockTasksBudgetRunning totalNote
Read the whole paper5 min5Mark every task cheap / medium / expensive before you touch a terminal.
GitOps & Continuous DeliveryT1–T56 + 6 + 7 + 4 + 6 = 29 min34T4 is the cheapest task in this block — four minutes for four points. T3 is the one most likely to overrun.
Platform APIs & Self-ServiceT6–T106 + 6 + 6 + 7 + 4 = 29 min63T7 and T8 are diagnosis, not authoring — they finish fast or not at all.
Observability & OperationsT11–T137 + 7 + 7 = 21 min84Twenty points across three tasks. Never leave this block unopened.
Platform Architecture & InfrastructureT14–T167 + 5 + 4 = 16 min100T16 is four minutes of curl for four points. Excellent points-per-minute.
Security & Policy EnforcementT17–T187 + 6 = 13 min113Two tasks, 15 points between them. The highest value on the paper.
Verify & sweep flagged tasks7 min120Re-run every done-when. Finish the nearly-done before starting anything new.

The flag-and-move rule: when a task passes roughly six minutes with no passing done-when check, stop. Write one line about where you stalled, leave whatever you applied in place — partial credit is real — and move on. The instinct to finish what you started is the most expensive habit in a timed performance exam: it trades a guaranteed five points elsewhere for a possible five points here. Timmy’s version: you are not paid to finish tasks, you are paid to bank points.

120 minutes · 18 tasks · 100 points GitOps 29 min · 5 tasks Platform APIs 29 min · 5 tasks Observability 21 min · 3 Arch 16 min · 3 Sec 13 · 2 read 5m verify 7m Points follow the blueprint, not the clock GitOps 25 · Platform APIs 25 · Observability 20 · Architecture 15 · Security 15 · pass mark 64 Flag-and-move rule Any task past ~6 minutes with no passing done-when check: note where you stalled, leave the partial work, move on.
◆ Key idea

Points, not tasks, are the unit of this exam. At a 64% bar you can fail four or five tasks outright and still pass comfortably — but only if you attempted the cheap ones. Candidates almost never fail because they knew too little; they fail because they spent twenty-two minutes on a Crossplane provider and never opened the two security tasks at the bottom of the paper.

GitOps & Continuous Delivery — T1 to T5 (25 points)

☺ Like you’re 10: These five are about the robot that reads your instructions from a folder: making it stamp the same instructions out to lots of clusters, doing things in the right order, undoing a bad change, and fencing off what one team is allowed to ask for.

The joint-largest slice of the blueprint and the one where speed pays most. Background reading lives in GitOps workflows, CI/CD & progressive delivery and release engineering — after the sitting, not during it.

T1 · Fan the platform add-ons out to every production cluster

You run one Argo CD control plane and six registered clusters. The platform add-on bundle — ingress, cert-manager, the metrics agent — is currently deployed by six hand-written Application objects that have drifted: two point at a stale branch, one was never updated when the repo moved, and nobody noticed the sixth cluster has no monitoring at all. You are replacing all six with one templated object, and it must only touch clusters labelled as production.

Your task:

  1. Label the registered cluster secrets so production clusters carry platform.acme.io/tier=prod.
  2. Write an ApplicationSet using the cluster generator with a label selector, templating one Application per matching cluster from clusters/<cluster-name>/addons in the config repo.
  3. Delete the hand-written Applications and show the generated set covers exactly the production clusters — no more, no fewer.

Done when: kubectl -n argocd get applications -l argocd.argoproj.io/application-set-name=platform-addons -o name | wc -l equals the number of secrets returned by kubectl -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster,platform.acme.io/tier=prod -o name | wc -l, and every one of those Applications reports Synced/Healthy. (5 points)

Show the worked solution
# clusters are registered as Secrets in the argocd namespace
kubectl -n argocd get secrets -l argocd.argoproj.io/secret-type=cluster
kubectl -n argocd label secret cluster-prod-eu   platform.acme.io/tier=prod
kubectl -n argocd label secret cluster-prod-us   platform.acme.io/tier=prod
kubectl -n argocd label secret cluster-prod-apac platform.acme.io/tier=prod
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-addons
  namespace: argocd
spec:
  goTemplate: true
  goTemplateOptions: ["missingkey=error"]
  generators:
    - clusters:
        selector:
          matchLabels:
            platform.acme.io/tier: prod
        values:
          revision: main
  template:
    metadata:
      name: '{{.nameNormalized}}-addons'
    spec:
      project: platform
      source:
        repoURL: https://github.com/acme/platform-config.git
        targetRevision: '{{.values.revision}}'
        path: 'clusters/{{.name}}/addons'
      destination:
        server: '{{.server}}'
        namespace: platform-system
      syncPolicy:
        automated:
          prune: true
          selfHeal: true
        syncOptions:
          - CreateNamespace=true
kubectl apply -f platform-addons-appset.yaml
kubectl -n argocd get applicationset platform-addons -o jsonpath='{.status.conditions[?(@.type=="ResourcesUpToDate")].status}'
kubectl -n argocd get applications -l argocd.argoproj.io/application-set-name=platform-addons
kubectl -n argocd get applications -l argocd.argoproj.io/application-set-name=platform-addons \
  -o custom-columns=NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status

# remove the hand-written ones only after the generated ones are Healthy
kubectl -n argocd delete application prod-eu-addons-manual prod-us-addons-manual

Why: the cluster generator reads the cluster secrets Argo CD already maintains, so “which clusters exist” stops being a fact you re-type into YAML six times and becomes a query. Three details decide whether it works. The selector is what keeps it safe — without it the generator matches every registered cluster including in-cluster, and your add-ons land on the control plane itself. .nameNormalized rather than .name in the Application name protects you from cluster names that are not valid Kubernetes object names. And the generated Applications carry the argocd.argoproj.io/application-set-name label for free, which is what makes the done-when check a one-liner rather than an eyeball exercise. Adding cluster seven is now a kubectl label, not a pull request. More in GitOps workflows and multi-cluster platforms.

T2 · Stop apps reconciling before the controllers they need exist

A cluster rebuild fails the same way every time: Flux applies everything at once, so the tenant apps try to create Certificate and ServiceMonitor objects a few seconds before cert-manager and the Prometheus operator have installed their CRDs. Half the Kustomizations end up stuck with “no matches for kind” errors, and someone fixes it by hand by re-running flux reconcile in the right order. That someone is on holiday.

Your task:

  1. Split the reconciliation into infrastructure (controllers) and apps Kustomizations against the existing GitRepository.
  2. Make apps depend on infrastructure, and make “infrastructure is ready” mean the controllers are actually running — not merely applied.
  3. Prove the ordering holds by breaking infrastructure deliberately and watching apps refuse to progress.

Done when: flux get kustomizations shows both Ready, and after you point infrastructure at a path that does not exist so its Ready condition flips to False, kubectl -n flux-system get kustomization apps -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}' contains dependency 'flux-system/infrastructure' is not ready. (5 points)

Show the worked solution
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: infrastructure
  namespace: flux-system
spec:
  interval: 10m
  retryInterval: 1m
  timeout: 5m
  sourceRef:
    kind: GitRepository
    name: platform-config
  path: ./infrastructure/controllers
  prune: true
  wait: true              # Ready means every applied object is healthy
---
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
  name: apps
  namespace: flux-system
spec:
  interval: 10m
  dependsOn:
    - name: infrastructure
  sourceRef:
    kind: GitRepository
    name: platform-config
  path: ./apps/production
  prune: true
  wait: false             # not waiting, so name the checks that matter
  healthChecks:
    - apiVersion: apps/v1
      kind: Deployment
      name: cert-manager
      namespace: cert-manager
    - apiVersion: apps/v1
      kind: Deployment
      name: kube-prometheus-stack-operator
      namespace: monitoring
kubectl apply -f flux-kustomizations.yaml
flux get kustomizations
flux tree kustomization apps

# prove the gate — break the dependency for real so its Ready condition flips to False
kubectl -n flux-system patch kustomization infrastructure --type=merge \
  -p '{"spec":{"path":"./infrastructure/does-not-exist"}}'
flux reconcile kustomization infrastructure --with-source     # fails: path not found
flux get kustomizations infrastructure                        # READY False

flux reconcile kustomization apps                             # refuses to run
kubectl -n flux-system get kustomization apps \
  -o jsonpath='{.status.conditions[?(@.type=="Ready")].message}{"\n"}'
#   dependency 'flux-system/infrastructure' is not ready

# put it back
kubectl -n flux-system patch kustomization infrastructure --type=merge \
  -p '{"spec":{"path":"./infrastructure/controllers"}}'
flux reconcile kustomization infrastructure --with-source

Why: dependsOn is a gate, not a hint — the kustomize-controller will not apply anything in apps until every named dependency reports Ready=True, and it re-checks on every reconcile, so the ordering survives a full cluster rebuild rather than only the first one. The subtlety is what “Ready” means. With wait: false and no health checks, a Kustomization is Ready the moment its objects are applied, which is exactly the race you started with: the CRDs exist as objects while the controller behind them is still pulling its image. wait: true makes readiness mean all applied resources are healthy; healthChecks lets you name a specific short list instead, which is cheaper on a big tree. Dependencies may cross namespaces by adding namespace: to the entry, and a cycle is rejected rather than deadlocking. One trap when you go to prove the gate: flux suspend is the wrong lever, because a suspended Kustomization simply keeps whatever Ready condition it last had and different Flux versions describe a suspended dependency differently — break the dependency for real and the message is unambiguous. See Flux and configuration management.

T3 · Get out of a failed canary and back to the last good revision

Release 4.7.0 of checkout went out as a canary at 06:40. The analysis run measuring the error rate failed at the 25% step, the rollout stopped, and the on-call engineer — not knowing the commands — deleted a pod, scaled the Rollout, and then went back to bed. It is now 07:10, the Rollout is Degraded, live traffic is split, and the release manager wants 4.6.3 serving everything.

Your task:

  1. Establish, from the cluster, which revision is stable, which is canary, and why the analysis failed.
  2. Abort the in-flight rollout so all traffic returns to the stable ReplicaSet.
  3. Return the Rollout spec itself to the last good revision so the controller stops trying to progress, and confirm it reports Healthy.

Done when: kubectl -n checkout get rollout checkout -o jsonpath='{.status.phase}' prints Healthy, kubectl argo rollouts get rollout checkout -n checkout shows a single ReplicaSet at full weight running the 4.6.3 image, and kubectl -n checkout get analysisrun shows the failed run retained for the post-incident review. (6 points)

Show the worked solution
# 1. evidence first — what does the controller think is happening?
kubectl argo rollouts get rollout checkout -n checkout
kubectl -n checkout get rollout checkout \
  -o jsonpath='{.status.phase}{" / "}{.status.message}{"\n"}'
kubectl -n checkout get rollout checkout \
  -o jsonpath='{.status.stableRS}{" stable | canary "}{.status.currentPodHash}{"\n"}'

# why did the analysis fail? the measurements are on the AnalysisRun, not the Rollout
kubectl -n checkout get analysisrun
kubectl -n checkout describe analysisrun checkout-6d4f9c7b8-4-1 | grep -A12 'Metric Results'
# 2. abort: traffic goes back to stable immediately, rollout stays Degraded on purpose
kubectl argo rollouts abort checkout -n checkout
kubectl argo rollouts status checkout -n checkout --timeout 60s   # Degraded

# 3. the spec still asks for 4.7.0, so revert the pod template to the last good revision
kubectl argo rollouts undo checkout -n checkout --to-revision=4
#   equivalent, and clearer in a git-managed world — change it in Git and let Argo CD sync:
#   kubectl argo rollouts set image checkout checkout=ghcr.io/acme/checkout:4.6.3 -n checkout

kubectl argo rollouts status checkout -n checkout   # Healthy
kubectl -n checkout get rollout checkout -o jsonpath='{.status.phase}{"\n"}'
kubectl argo rollouts get rollout checkout -n checkout
# the guardrail that would have made 07:10 unnecessary:
spec:
  progressDeadlineSeconds: 600
  progressDeadlineAbort: true      # a stuck rollout aborts itself instead of hanging Degraded
  strategy:
    canary:
      abortScaleDownDelaySeconds: 30   # keep the canary RS briefly for forensics
      steps:
        - setWeight: 25
        - pause: { duration: 5m }
        - analysis:
            templates:
              - templateName: error-rate
      analysis:
        templates:
          - templateName: error-rate
        startingStep: 1

Why: abort and rollback are two different actions and the exam separates them. Abort is a runtime instruction: the controller shifts all traffic back to the stable ReplicaSet at once and marks the Rollout Degraded. It deliberately does not change your desired state, which is why the Rollout stays Degraded — the spec still asks for the bad image, and a controller that quietly rewrote your spec would be lying to Git. Rollback is the second half: undo (or, better, a revert commit that your GitOps controller syncs) puts the previous pod template back, at which point stable and desired agree and the phase returns to Healthy. If you only abort, the next reconcile or a careless promote re-launches the bad version. Two supporting details: abortScaleDownDelaySeconds keeps the canary pods alive briefly so you can still read their logs, and progressDeadlineAbort makes a stalled rollout abort itself rather than sit half-shipped waiting for a human. Deeper treatment in Argo Rollouts and triaging delivery.

T4 · Fence a tenant into what they are allowed to deploy

A team’s Argo CD Application, deployed from their own repo, created a ClusterRoleBinding granting their service account cluster-admin. Nobody reviewed it because it was “just YAML in their repo”. Every tenant currently uses the default project, which permits any repo, any cluster, any namespace and any resource kind.

Your task:

  1. Create an AppProject for the payments tenant that allows only their repo, only namespaces matching payments-* on the in-cluster destination, and no cluster-scoped resources at all.
  2. Move their Application into the project.
  3. Prove a forbidden resource is now rejected at sync time.

Done when: kubectl -n argocd get app payments -o jsonpath='{.spec.project}' prints payments, and a sync of a commit containing a ClusterRoleBinding fails with a message containing not permitted in project — visible in kubectl -n argocd get app payments -o jsonpath='{.status.conditions[*].message}'. (4 points)

Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: payments
  namespace: argocd
spec:
  description: Payments tenant — their repo, their namespaces, nothing cluster-scoped.
  sourceRepos:
    - https://github.com/acme/payments-config.git
  destinations:
    - server: https://kubernetes.default.svc
      namespace: 'payments-*'
  clusterResourceWhitelist: []          # empty list = no cluster-scoped kinds permitted
  namespaceResourceBlacklist:
    - group: ''
      kind: ResourceQuota
    - group: ''
      kind: LimitRange
    - group: rbac.authorization.k8s.io
      kind: Role
    - group: rbac.authorization.k8s.io
      kind: RoleBinding
  orphanedResources:
    warn: true
kubectl apply -f payments-appproject.yaml
kubectl -n argocd patch app payments --type=merge -p '{"spec":{"project":"payments"}}'
kubectl -n argocd get app payments -o jsonpath='{.spec.project}{"\n"}'

argocd proj get payments
argocd app sync payments            # fails on the ClusterRoleBinding
kubectl -n argocd get app payments -o jsonpath='{.status.conditions[*].message}{"\n"}'
argocd proj allow-cluster-resource payments '' Namespace   # if they genuinely need one kind

Why: an AppProject is the multi-tenancy boundary of Argo CD, and it is enforced by the controller before it applies anything — which matters, because Argo CD’s own service account is usually powerful enough to do whatever the tenant’s manifests ask. Without a project, cluster RBAC is no protection at all: the tenant is not applying the manifest, Argo CD is. Three fields carry the weight. sourceRepos stops a tenant pointing an Application at somebody else’s repo. destinations (globs allowed) confines them to their namespaces. clusterResourceWhitelist: [] is the one that would have prevented this incident: an empty allowlist denies every cluster-scoped kind, whereas omitting the field entirely denies nothing. Add kinds back one at a time as tenants prove they need them. Note the blacklist entries above use the namespaced RBAC kinds — ClusterRole and ClusterRoleBinding are cluster-scoped and are already covered by the empty whitelist. Background in Argo CD and governance & compliance.

T5 · Publish only from main, and clean up whatever happens

The build pipeline pushes an image to the registry on every branch, which has filled the registry with 4,000 tags from feature branches and twice pushed a branch build over a release tag. Separately, when the test task fails the pipeline stops dead, leaving its PVC workspace and a scratch namespace behind; the cluster has thirty of them.

Your task:

  1. Add a when expression so the publish task only runs when the branch param is main.
  2. Add a finally section that always runs a cleanup task and reports the aggregate pipeline status.
  3. Run it once with branch=feature-x and once with branch=main, and show the difference in the run status.

Done when: for the feature-branch run, kubectl get pipelinerun <run> -o jsonpath='{.status.skippedTasks[*].name}' prints publish while tkn pipelinerun describe <run> shows the cleanup task Succeeded; for the main run, skippedTasks is empty and publish ran. (5 points)

Show the worked solution
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: build-release
spec:
  params:
    - name: branch
      type: string
      default: main
  workspaces:
    - name: shared
  tasks:
    - name: unit-tests
      taskRef:
        name: run-tests
      workspaces:
        - name: source
          workspace: shared
    - name: publish
      runAfter: [unit-tests]
      when:
        - input: "$(params.branch)"
          operator: in
          values: ["main"]
      taskRef:
        name: publish-image
      workspaces:
        - name: source
          workspace: shared
  finally:
    - name: cleanup
      taskRef:
        name: cleanup-workspace
      workspaces:
        - name: source
          workspace: shared
    - name: report
      params:
        - name: status
          value: "$(tasks.status)"          # Succeeded | Failed | Completed | None
        - name: tests
          value: "$(tasks.unit-tests.status)"
      taskRef:
        name: post-status
kubectl apply -f pipeline.yaml

tkn pipeline start build-release -p branch=feature-x \
  --workspace name=shared,volumeClaimTemplateFile=vct.yaml --showlog
kubectl get pipelinerun --sort-by=.metadata.creationTimestamp -o name | tail -1
kubectl get pipelinerun <run> -o jsonpath='{.status.skippedTasks[*].name}{"\n"}'   # publish
tkn pipelinerun describe <run>                                                    # cleanup Succeeded

tkn pipeline start build-release -p branch=main \
  --workspace name=shared,volumeClaimTemplateFile=vct.yaml --showlog
kubectl get pipelinerun <run2> -o jsonpath='{.status.skippedTasks[*].name}{"\n"}'  # empty

Why: when and finally are the two pieces of pipeline control flow that turn a linear script into something safe to run on every push. A when expression is evaluated before the task starts, so a skipped task costs nothing and — importantly — skipped is not failed: the PipelineRun still succeeds, and the skip is recorded in status.skippedTasks, which is why that field makes a much better verification than reading logs. By default, tasks that depend on a skipped task are skipped too, which is usually what you want. finally tasks run after everything else regardless of outcome — success, failure or skip — which is the only correct place for cleanup, notifications and artifact publication. Inside finally you get context variables the main tasks do not have: $(tasks.status) is the aggregate outcome and $(tasks.<name>.status) the individual one, so one pipeline can report “tests failed” and “nothing published” without a second pipeline. See Tekton and release engineering.

🦆 Dot’s-eye view

“Four of those five are invisible to me and I feel every one. The ApplicationSet means the new cluster already had monitoring on the day it appeared. The dependsOn means the Monday-morning rebuild does not page anyone. The abort-then-rollback means the person on call at 06:40 has two commands to remember instead of a decision tree. And the project fence means I cannot accidentally grant myself cluster-admin from a YAML file — which, honestly, I would have.”

Platform APIs & Self-Service — T6 to T10 (25 points)

☺ Like you’re 10: These five are about your own kind of Kubernetes object — making it explain itself when someone types kubectl get, making its children tidy themselves up, and finding out why the machine that builds cloud things has stopped working.

The other 25%. Two of these are authoring tasks you can drill to reflex; three are diagnosis, which is where most candidates lose the domain. Background: Platform APIs & operators, self-service & golden paths, IaC & control planes and developer experience.

T6 · Make kubectl get answer the support question

Half the tickets your platform team receives are “my Environment isn’t working” and every one of them starts with the same three commands: get, then describe, then scrolling for the conditions. The Environment CRD currently prints only NAME and AGE. Your controller already writes a Ready condition; nobody can see it.

Your task:

  1. Add additionalPrinterColumns so kubectl get environments shows the tier, the Ready condition status and the age — and put the condition’s reason and message behind -o wide only.
  2. Make sure the CRD has the status subresource enabled and that the schema actually declares the condition fields the columns read.
  3. Write a condition onto a live object through the status subresource and show the column flip.

Done when: kubectl get environments prints a READY column reading False, then True after your status patch, kubectl get environments -o wide additionally shows REASON, and kubectl patch environment demo --type=merge -p '{"status":{"phase":"hacked"}}' (without --subresource) leaves the status unchanged. (5 points)

Show the worked solution
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: environments.platform.acme.io
spec:
  group: platform.acme.io
  names:
    kind: Environment
    plural: environments
    singular: environment
    shortNames: [env]
    categories: [platform]        # kubectl get platform lists it alongside the rest
  scope: Namespaced
  versions:
    - name: v1beta1
      served: true
      storage: true
      subresources:
        status: {}
      additionalPrinterColumns:
        - name: Tier
          type: string
          jsonPath: .spec.tier
        - name: Ready
          type: string
          jsonPath: .status.conditions[?(@.type=="Ready")].status
        - name: Reason
          type: string
          priority: 1             # priority > 0 = only shown with -o wide
          jsonPath: .status.conditions[?(@.type=="Ready")].reason
        - name: Message
          type: string
          priority: 1
          jsonPath: .status.conditions[?(@.type=="Ready")].message
        - name: Age
          type: date
          jsonPath: .metadata.creationTimestamp
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [tier]
              properties:
                tier:
                  type: string
                  enum: [dev, staging, prod]
            status:
              type: object
              properties:
                conditions:
                  type: array
                  items:
                    type: object
                    required: [type, status, lastTransitionTime, reason]
                    properties:
                      type:               { type: string }
                      status:             { type: string, enum: ["True", "False", "Unknown"] }
                      reason:             { type: string }
                      message:            { type: string }
                      observedGeneration: { type: integer, format: int64 }
                      lastTransitionTime: { type: string, format: date-time }
                  x-kubernetes-list-type: map
                  x-kubernetes-list-map-keys: [type]
kubectl apply -f environments-crd.yaml
kubectl get environments
kubectl get environments -o wide

# status is a separate endpoint now — this is how a controller writes it
kubectl patch environment demo --subresource=status --type=merge -p '{
  "status": {"conditions": [{
    "type": "Ready", "status": "True", "reason": "Provisioned",
    "message": "namespace, quota and rolebinding created",
    "lastTransitionTime": "2026-07-21T09:14:00Z"
  }]}}'

kubectl get environments          # READY now True
kubectl patch environment demo --type=merge -p '{"status":{"phase":"hacked"}}'
kubectl get environment demo -o jsonpath='{.status}{"\n"}'   # unchanged — status ignored

Why: printer columns are the cheapest developer-experience win a platform API has, and the exam likes them because they are pure API mechanics. The JSONPath dialect the API server accepts here is a small one, but it does include the filter form [?(@.type=="Ready")], which is exactly what you need to surface one condition out of the list — this is how Certificate, HelmRelease and every other well-behaved custom resource prints a Ready column. priority: 1 (or higher) hides a column unless the user asks for -o wide, so you can be generous with detail without making the default output unreadable. The status subresource is the second half: it splits writes into two endpoints so that a user updating spec cannot touch status and your controller updating status cannot clobber spec — which is why the last check in the task quietly succeeds while changing nothing. Declaring conditions as a list-map keyed on type makes server-side apply merge them per condition instead of replacing the whole array. More in Platform APIs & operators and Kubebuilder.

T7 · The children keep vanishing

Your Environment operator creates a ConfigMap of rendered settings for each environment. Since a refactor last week, those ConfigMaps disappear within a few seconds of being created — the operator recreates them, they vanish again, and the reconcile loop is now hot. The operator logs show successful creates and no deletes. Nobody has run a delete command.

Your task:

  1. Find the evidence that names the deleter, rather than guessing.
  2. Explain in one line why the garbage collector is removing an object whose owner exists.
  3. Fix the ownership so the ConfigMap survives, and confirm it is still collected when its owner is genuinely deleted.

Done when: kubectl get events -A --field-selector reason=OwnerRefInvalidNamespace shows the event before your fix and no new ones after, the ConfigMap survives for at least two minutes, and kubectl -n team-a delete environment demo still removes it within seconds. (5 points)

Show the worked solution
# 1. the garbage collector announces itself — this event is the whole diagnosis
kubectl get events -A --field-selector reason=OwnerRefInvalidNamespace
kubectl -n platform get cm demo-settings -o jsonpath='{.metadata.ownerReferences}{"\n"}'
kubectl -n team-a get environment demo -o jsonpath='{.metadata.uid}{"\n"}'
# 2. the bug: the child was created in `platform`, its owner lives in `team-a`.
#    Owner references may NOT cross namespaces. The GC treats a namespaced
#    dependent whose owner is in another namespace as having an unresolvable
#    owner and deletes it — immediately, every time.

# 3. the fix: create the child in the owner's namespace and use the live uid
apiVersion: v1
kind: ConfigMap
metadata:
  name: demo-settings
  namespace: team-a                      # same namespace as the Environment
  ownerReferences:
    - apiVersion: platform.acme.io/v1beta1
      kind: Environment
      name: demo
      uid: 8c1f4a2e-77b1-4f0a-9d2c-3b6e5a4c1f90    # the LIVE object's uid
      controller: true
      blockOwnerDeletion: true
data:
  TIER: dev
UID=$(kubectl -n team-a get environment demo -o jsonpath='{.metadata.uid}')
kubectl -n team-a create cm demo-settings --from-literal=TIER=dev
kubectl -n team-a patch cm demo-settings --type=merge -p "{\"metadata\":{\"ownerReferences\":[{\"apiVersion\":\"platform.acme.io/v1beta1\",\"kind\":\"Environment\",\"name\":\"demo\",\"uid\":\"$UID\",\"controller\":true,\"blockOwnerDeletion\":true}]}}"

sleep 120 && kubectl -n team-a get cm demo-settings        # still there
kubectl -n team-a delete environment demo
kubectl -n team-a get cm demo-settings                     # NotFound — collected

Why: ownership in Kubernetes is deliberately narrow, and the two rules it enforces are the two ways an operator gets this wrong. First, a namespaced dependent must live in the same namespace as its owner; a cross-namespace reference is not an error at admission time — it is accepted, and then the garbage collector, which cannot resolve it, deletes the dependent and emits OwnerRefInvalidNamespace. Second, a cluster-scoped resource cannot be owned by a namespaced one for the same reason; only the reverse works. The third failure mode with identical symptoms is a stale uid: names are reusable, uids are not, so an owner reference copied from a recreated object points at something that no longer exists and the child is collected on sight. Always read the uid from the live object. The lesson for operator authors is that when you genuinely need a child elsewhere, owner references are the wrong tool — use a finalizer and delete it yourself.

T8 · A Crossplane provider that installed but never worked

Developers claiming an XBucket get a claim that stays READY: False forever. kubectl get providers shows the S3 provider as INSTALLED: True but HEALTHY: False. It worked in the staging cluster. This cluster was rebuilt on Friday.

Your task:

  1. Work down from the provider to the provider revision to the pod, and name the failure.
  2. Fix it, and check the second, quieter failure the first one is hiding: managed resources that cannot find their ProviderConfig credentials.
  3. Show a claim reconciling end to end.

Done when: kubectl get providers shows INSTALLED=True HEALTHY=True, kubectl get managed shows the composed resource SYNCED=True, and kubectl get xbucket (or the claim) reports READY=True. (5 points)

Show the worked solution
# 1. walk the chain: Provider -> ProviderRevision -> Deployment -> Pod
kubectl get providers
kubectl describe provider provider-aws-s3 | tail -25
kubectl get providerrevisions
kubectl describe providerrevision provider-aws-s3-9f2c1ab | tail -25
kubectl -n crossplane-system get pods
kubectl -n crossplane-system describe pod -l pkg.crossplane.io/revision=provider-aws-s3-9f2c1ab | grep -A8 Events
#   Failed to pull image ... manifest unknown  -> the package tag does not exist
# 2a. fix the package reference (a real, existing tag) and let the new revision take over
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
  name: provider-aws-s3
spec:
  package: xpkg.upbound.io/upbound/provider-aws-s3:v1.21.0
  revisionActivationPolicy: Automatic
  revisionHistoryLimit: 1
---
# 2b. the second failure: no credentials for the ProviderConfig the composition names
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: creds
kubectl create secret generic aws-creds -n crossplane-system --from-file=creds=./aws-creds.txt
kubectl apply -f provider.yaml -f providerconfig.yaml

kubectl get providers -w                  # INSTALLED True, HEALTHY True
kubectl get providerrevisions             # old revision Inactive, new one Active

kubectl apply -f claim.yaml
kubectl get claim,composite,managed
kubectl describe bucket <name> | grep -A10 Conditions   # Synced/Ready and the real cause if not

Why: a Crossplane provider is three objects deep and the useful error is always at the bottom. The Provider is your declaration; the ProviderRevision is the specific package version, and it owns a Deployment in crossplane-system that runs the controller. INSTALLED=True HEALTHY=False means the package was fetched and unpacked but its controller is not running, so the answer is in that pod’s events — usually a bad tag, a private registry with no pull secret, or a missing service account from a DeploymentRuntimeConfig. The second failure is the one people miss because it produces no provider-level symptom at all: with the controller finally running, every managed resource still reports SYNCED=False with “cannot get referenced ProviderConfig” or an auth error, because credentials are configured separately from the package. The habit to build: for anything Crossplane, read Conditions on the managed resource, not on the claim — the claim only ever mirrors what is below it. See Crossplane and IaC & control planes.

T9 · Make the self-service provisioning workflow safe to re-run

Your provision-namespace WorkflowTemplate creates a namespace, a ResourceQuota and a RoleBinding for a new team. Last Tuesday it failed halfway — the namespace was created, the quota was not — and when the platform engineer re-submitted it, the run failed instantly with namespaces "team-research" already exists. Three teams are now in this half-provisioned state and the only known fix is finishing them by hand.

Your task:

  1. Change the resource steps so re-running converges instead of failing on objects that already exist.
  2. Add a success condition so a step is only “done” when the object is actually usable, plus a retry policy for transient API errors.
  3. Submit the template twice for the same team and show both runs succeed and the end state is complete.

Done when: two consecutive argo submit --from workflowtemplate/provision-namespace -p team=research --wait runs both report Succeeded (argo list -n platform), and kubectl get ns team-research, kubectl -n team-research get resourcequota team-quota and kubectl -n team-research get rolebinding team-admins all exist. (6 points)

Show the worked solution
apiVersion: argoproj.io/v1alpha1
kind: WorkflowTemplate
metadata:
  name: provision-namespace
  namespace: platform
spec:
  serviceAccountName: namespace-provisioner
  entrypoint: provision
  arguments:
    parameters:
      - name: team
      - name: quotaCPU
        value: "8"
  templates:
    - name: provision
      inputs:
        parameters:
          - name: team
          - name: quotaCPU
      steps:
        - - name: namespace
            template: apply-namespace
            arguments:
              parameters: [{ name: team, value: "{{inputs.parameters.team}}" }]
        - - name: quota
            template: apply-quota
            arguments:
              parameters:
                - { name: team,     value: "{{inputs.parameters.team}}" }
                - { name: quotaCPU, value: "{{inputs.parameters.quotaCPU}}" }
        - - name: rolebinding
            template: apply-rolebinding
            arguments:
              parameters: [{ name: team, value: "{{inputs.parameters.team}}" }]

    - name: apply-namespace
      inputs:
        parameters: [{ name: team }]
      retryStrategy:
        limit: "3"
        retryPolicy: OnError            # transient API errors only, not logic failures
        backoff:
          duration: "5s"
          factor: "2"
      resource:
        action: apply                   # idempotent: create-or-update, never "already exists"
        successCondition: status.phase == Active
        failureCondition: status.phase == Terminating
        manifest: |
          apiVersion: v1
          kind: Namespace
          metadata:
            name: team-{{inputs.parameters.team}}
            labels:
              platform.acme.io/team: "{{inputs.parameters.team}}"
              platform.acme.io/managed: "true"

    - name: apply-quota
      inputs:
        parameters: [{ name: team }, { name: quotaCPU }]
      resource:
        action: apply
        manifest: |
          apiVersion: v1
          kind: ResourceQuota
          metadata:
            name: team-quota
            namespace: team-{{inputs.parameters.team}}
          spec:
            hard:
              requests.cpu: "{{inputs.parameters.quotaCPU}}"
              requests.memory: 16Gi
              count/pods: "60"

    - name: apply-rolebinding
      inputs:
        parameters: [{ name: team }]
      resource:
        action: apply
        manifest: |
          apiVersion: rbac.authorization.k8s.io/v1
          kind: RoleBinding
          metadata:
            name: team-admins
            namespace: team-{{inputs.parameters.team}}
          roleRef:
            apiGroup: rbac.authorization.k8s.io
            kind: ClusterRole
            name: admin
          subjects:
            - kind: Group
              apiGroup: rbac.authorization.k8s.io
              name: "{{inputs.parameters.team}}-admins"
kubectl apply -f provision-namespace.yaml

argo submit -n platform --from workflowtemplate/provision-namespace -p team=research --wait
argo submit -n platform --from workflowtemplate/provision-namespace -p team=research --wait
argo list -n platform                       # both Succeeded

kubectl get ns team-research --show-labels
kubectl -n team-research get resourcequota team-quota
kubectl -n team-research get rolebinding team-admins

# if a step hangs, it is nearly always RBAC on the workflow service account:
kubectl auth can-i create namespaces --as=system:serviceaccount:platform:namespace-provisioner

Why: a provisioning workflow is a controller with a worse memory, and the property that makes it survivable is idempotency: running it twice must be indistinguishable from running it once. action: create gives you exactly the opposite — it is a one-shot that fails on the second run, which means any partial failure becomes a manual repair job. action: apply is create-or-update, so re-running a half-finished provision simply completes it. successCondition is the other half: without it a resource step finishes as soon as the API call returns, so the next step can race a namespace that is still being created; with it, the step polls until the expression is true. Keep retryPolicy: OnError rather than Always so you retry API blips but not genuine failures — retrying a logic failure three times just makes the incident slower. And when a resource step hangs forever with no error, suspect the workflow service account: resource templates run kubectl as that identity, and a missing verb produces silence rather than a clear message. Compare with the declarative version in self-service & golden paths and Argo Workflows.

T10 · Stop one controller from starving the API server

A misconfigured controller went into a hot loop over the weekend and issued 4,000 LIST requests a second against your platform.acme.io resources. The API server shed load, kubectl became unusable for everyone, and the Argo CD and Flux controllers started timing out — an unrelated team’s deploys failed because of your operator. You want that blast radius contained the next time it happens.

Your task:

  1. Create a PriorityLevelConfiguration with a small concurrency share and a queue, for the platform controllers’ traffic.
  2. Create a FlowSchema matching your operator’s service account and routing it to that priority level.
  3. Prove requests from that identity are actually classified into your flow schema.

Done when: kubectl get flowschema platform-controllers -o jsonpath='{.status.conditions[?(@.type=="Dangling")].status}' prints False, and a request made as the operator’s service account with -v=8 shows a response header X-Kubernetes-Pf-Flowschema-Uid equal to kubectl get flowschema platform-controllers -o jsonpath='{.metadata.uid}'. (4 points)

Show the worked solution
apiVersion: flowcontrol.apiserver.k8s.io/v1     # v1beta3 on clusters older than 1.29
kind: PriorityLevelConfiguration
metadata:
  name: platform-controllers
spec:
  type: Limited
  limited:
    nominalConcurrencyShares: 10
    lendablePercent: 50
    limitResponse:
      type: Queue
      queuing:
        queues: 64
        handSize: 6
        queueLengthLimit: 50
---
apiVersion: flowcontrol.apiserver.k8s.io/v1
kind: FlowSchema
metadata:
  name: platform-controllers
spec:
  matchingPrecedence: 700          # lower number wins; must beat the catch-all at 10000
  priorityLevelConfiguration:
    name: platform-controllers
  distinguisherMethod:
    type: ByUser                   # one queue set per identity, so one bad SA cannot starve its peers
  rules:
    - subjects:
        - kind: ServiceAccount
          serviceAccount:
            name: environment-controller
            namespace: platform
      resourceRules:
        - verbs: ["*"]
          apiGroups: ["platform.acme.io"]
          resources: ["*"]
          clusterScope: true
          namespaces: ["*"]
kubectl api-resources | grep flowcontrol          # confirm the served version first
kubectl apply -f apf.yaml

kubectl get flowschema platform-controllers \
  -o jsonpath='{.status.conditions[?(@.type=="Dangling")].status}{"\n"}'      # False = it resolves

kubectl get flowschema platform-controllers -o jsonpath='{.metadata.uid}{"\n"}'
kubectl get environments -A -v=8 \
  --as=system:serviceaccount:platform:environment-controller 2>&1 | grep -i 'Pf-'

# and the load-shedding evidence, once it is under pressure:
kubectl get --raw /metrics | grep apiserver_flowcontrol_current_inqueue_requests
kubectl get --raw /debug/api_priority_and_fairness/dump_priority_levels

Why: API Priority and Fairness is the API server’s built-in bulkhead, and it is the only answer to “one client took the control plane down” that does not depend on that client behaving. A FlowSchema classifies incoming requests by identity and resource; a PriorityLevelConfiguration decides how much concurrency that class gets and what happens when it is exceeded — queue (fair, adds latency) or reject with 429 (fast, sheds load). Two fields do most of the work. matchingPrecedence is evaluated lowest-first, so a schema numbered above the built-in catch-all never matches anything; keep custom schemas well under 1000. distinguisherMethod: ByUser subdivides the level into per-identity queues, which is what stops a single looping controller from consuming the whole share it nominally belongs to. Note the API group has moved through several versions — v1beta3 before 1.29, v1 after, and the field was called assuredConcurrencyShares in older betas — so check kubectl api-resources before writing the manifest rather than after it is rejected. Background in the Kubernetes substrate and reliability & incidents.

🐢 Timmy’s drill · 12 min

Before you sit the paper, warm up on pure speed. From a cold terminal, and using only kubectl explain --recursive and an existing object on the cluster, produce a valid skeleton of each of these in under three minutes each: an ApplicationSet with one generator, a Flux Kustomization with dependsOn, a CRD version block with a status subresource and two printer columns, and a FlowSchema. None of those four projects’ documentation is permitted on exam day, so the only question that matters is whether you can rebuild the shape from the live API. Whichever one takes longest is your highest-value study target — ahead of any concept.

Observability & Operations — T11 to T13 (20 points)

☺ Like you’re 10: These three are about knowing how much “being broken” you are allowed before you must stop shipping, following one request as it hops between services, and untangling one failure that caused two more.

Twenty percent of the paper, and the domain that most rewards a fixed method: when something is already broken, go through the same evidence order every time rather than guessing. Background: observability, Prometheus, OpenTelemetry, reliability & incidents and the troubleshooting playbook.

T11 · Turn a 99.9% promise into two alerts and a budget

The checkout service has an availability SLO of 99.9% over 30 days, measured as the ratio of non-5xx responses. Today it alerts on “error rate > 1% for 5 minutes”, which pages twice a week for blips that consume a trivial fraction of the budget and stayed silent through a nine-hour partial outage that ate most of it. You are replacing it with burn-rate alerting.

Your task:

  1. Write recording rules for the error ratio over the five windows the alerts and the budget view need: 5m, 30m, 1h, 6h and 3d.
  2. Write a fast-burn alert (14.4× the budget rate, 1h and 5m windows both breaching) that pages, and a slow-burn alert (6×, 6h and 30m) that raises a ticket.
  3. State the size of the 30-day error budget in minutes, and how long a 14.4× burn takes to exhaust it.

Done when: promtool check rules passes on the rule groups — promtool reads a plain rules file, not the CRD wrapper, so lift the spec block into its own file first — kubectl -n monitoring get prometheusrule checkout-slo exists, querying sli:checkout_errors:ratio_rate5m in Prometheus returns a value, and ALERTS{alertname="CheckoutErrorBudgetBurnFast"} returns no series in steady state. (7 points)

Show the worked solution
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: checkout-slo
  namespace: monitoring
  labels:
    release: kube-prometheus-stack      # must match the Prometheus ruleSelector
spec:
  groups:
    - name: checkout-sli
      interval: 30s
      rules:
        - record: sli:checkout_errors:ratio_rate5m
          expr: |
            sum(rate(http_requests_total{job="checkout",code=~"5.."}[5m]))
              /
            sum(rate(http_requests_total{job="checkout"}[5m]))
        - record: sli:checkout_errors:ratio_rate30m
          expr: |
            sum(rate(http_requests_total{job="checkout",code=~"5.."}[30m]))
              /
            sum(rate(http_requests_total{job="checkout"}[30m]))
        - record: sli:checkout_errors:ratio_rate1h
          expr: |
            sum(rate(http_requests_total{job="checkout",code=~"5.."}[1h]))
              /
            sum(rate(http_requests_total{job="checkout"}[1h]))
        - record: sli:checkout_errors:ratio_rate6h
          expr: |
            sum(rate(http_requests_total{job="checkout",code=~"5.."}[6h]))
              /
            sum(rate(http_requests_total{job="checkout"}[6h]))
        - record: sli:checkout_errors:ratio_rate3d
          expr: |
            sum(rate(http_requests_total{job="checkout",code=~"5.."}[3d]))
              /
            sum(rate(http_requests_total{job="checkout"}[3d]))

    - name: checkout-slo-burn
      rules:
        - alert: CheckoutErrorBudgetBurnFast
          expr: |
            sli:checkout_errors:ratio_rate1h  > (14.4 * 0.001)
            and
            sli:checkout_errors:ratio_rate5m  > (14.4 * 0.001)
          for: 2m
          labels:
            severity: critical
            team: checkout
          annotations:
            summary: "checkout is burning its 30-day error budget 14.4x"
            description: "2% of the 30-day budget consumed in the last hour. At this rate the whole budget is gone in ~50 hours."
        - alert: CheckoutErrorBudgetBurnSlow
          expr: |
            sli:checkout_errors:ratio_rate6h  > (6 * 0.001)
            and
            sli:checkout_errors:ratio_rate30m > (6 * 0.001)
          for: 15m
          labels:
            severity: warning
            team: checkout
          annotations:
            summary: "checkout is burning its error budget 6x — ticket, do not page"
# promtool validates a plain Prometheus rules file, not the PrometheusRule wrapper —
# lift the spec (the groups: block) into its own file before checking it
yq '.spec' checkout-slo.yaml > /tmp/checkout-rules.yaml
promtool check rules /tmp/checkout-rules.yaml

kubectl apply -f checkout-slo.yaml
kubectl -n monitoring get prometheusrule checkout-slo

kubectl -n monitoring port-forward svc/prometheus-operated 9090 &
curl -s 'http://localhost:9090/api/v1/query?query=sli:checkout_errors:ratio_rate5m'
curl -s 'http://localhost:9090/api/v1/rules' | grep -c CheckoutErrorBudget
curl -s --get --data-urlencode 'query=ALERTS{alertname="CheckoutErrorBudgetBurnFast"}' \
  http://localhost:9090/api/v1/query      # empty result vector in steady state

Why: the arithmetic first, because the exam can ask for it directly. A 99.9% target over 30 days allows 0.1% of 43,200 minutes = 43.2 minutes of full unavailability — that is the budget. “Burn rate” is how fast you are spending it relative to spending it evenly: a burn rate of 1 exhausts it in exactly 30 days, so 14.4× exhausts it in 30 ÷ 14.4 days ≈ 50 hours, and consumes 2% of the budget in one hour. That is why 14.4 is the canonical page threshold and 6 the canonical ticket threshold. The two-window construction is the part candidates skip: the long window (1h) makes the alert significant, and the short window (5m, one twelfth of the long one) makes it reset quickly — without it, a brief spike keeps the alert firing for the rest of the long window long after the problem is gone. Recording rules are not decoration either: those [3d] range queries are expensive, and evaluating them once per interval instead of once per alert evaluation is the difference between a healthy Prometheus and a slow one. Note the release label — a PrometheusRule the operator’s ruleSelector does not match is silently ignored, which is the single most common reason a correct rule “does nothing”. More in observability and reliability & incidents.

T12 · One request, three disconnected traces

A latency complaint about checkout cannot be investigated: in the tracing backend, a single user request appears as three separate single-span traces — one from the gateway, one from checkout, one from payments — with no parent-child relationship. All three services are instrumented and all three export spans successfully. Someone has already restarted the collector twice.

Your task:

  1. Send one request with a known traceparent header and establish which hop loses the context.
  2. Fix the propagation so all three services join one trace.
  3. Verify with a single trace ID that returns spans from all three services.

Done when: after a request carrying traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01, querying the backend for that trace ID returns spans from three distinct services — e.g. curl -s "http://jaeger-query.observability:16686/api/traces/4bf92f3577b34da6a3ce929d0e0e4736" lists three serviceName values and more than three spans. (7 points)

Show the worked solution
# 1. inject a known trace id at the edge and follow it. W3C format:
#    00-<32 hex trace id>-<16 hex span id>-01   (01 = sampled)
TP='00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
kubectl -n checkout run probe --rm -it --restart=Never --image=curlimages/curl --command -- \
  curl -s -H "traceparent: $TP" -o /dev/null -w '%{http_code}\n' http://gateway.edge:8080/api/checkout

# does the next hop even receive the header? echo it back from the receiving service
kubectl -n payments logs deploy/payments | grep -i traceparent | tail -5

# what does each service think it is configured to do?
kubectl -n payments set env deploy/payments --list | grep OTEL
kubectl -n checkout  set env deploy/checkout  --list | grep OTEL
# 2. the two usual causes, both fixed in the workload, not the collector

# (a) a service configured for a different propagation format: it emits and
#     expects b3 headers, its neighbours emit W3C traceparent. Nothing matches,
#     so every service starts a brand new trace.
env:
  - name: OTEL_PROPAGATORS
    value: tracecontext,baggage        # the same list in EVERY service
  - name: OTEL_SERVICE_NAME
    value: payments
  - name: OTEL_EXPORTER_OTLP_ENDPOINT
    value: http://otel-collector.observability.svc:4318
  - name: OTEL_TRACES_SAMPLER
    value: parentbased_always_on        # never let a child re-decide sampling

# (b) a proxy or gateway that does not forward the header. For an Envoy/Istio
#     gateway the app must still pass it through on the outbound call; for an
#     nginx ingress, stop stripping unknown headers.
kubectl -n payments rollout restart deploy/payments
kubectl -n checkout  rollout restart deploy/checkout

kubectl -n observability port-forward svc/jaeger-query 16686 &
curl -s "http://localhost:16686/api/traces/4bf92f3577b34da6a3ce929d0e0e4736" \
  | tr ',' '\n' | grep -c '"serviceName"'
curl -s "http://localhost:16686/api/services"

Why: the instinct to restart the collector is the wrong model of how tracing works. Context propagation happens between your services, in-process and over the wire — the collector never sees it and cannot repair it. Each service extracts the incoming traceparent, makes its span a child of that span ID, and injects an updated header on its outbound calls; break any link in that chain and the next service starts a fresh trace, which is exactly the “three single-span traces” symptom. There are only three places it breaks: a service using a different propagator (B3 versus W3C tracecontext is the classic — OTEL_PROPAGATORS must agree across every service), an infrastructure hop that strips the header, or an outbound client the auto-instrumentation does not wrap, so the header is never injected at all. The sampling detail is worth carrying too: with a non-parent-based sampler each service decides independently, so you get traces where the middle service is missing — parentbased_always_on (or parentbased_traceidratio) makes the edge’s decision authoritative for the whole trace. Injecting a known trace ID by hand, as in step one, is the fastest diagnostic in this entire domain. See OpenTelemetry and Jaeger.

T13 · Work one incident back to its cause

At 08:05 the ledger Deployment dropped to 1/3 available. Right now one pod is Running, one is CrashLoopBackOff with 14 restarts, and one has been Pending for twelve minutes. Someone has already “fixed” it once by raising the memory request to 8Gi. There was no deployment this morning; traffic is up 30% because of a marketing email.

Your task:

  1. Name the causal chain in order — which failure caused which — using evidence, not intuition.
  2. Undo the wrong fix and apply the right one with the smallest correct change.
  3. Return the Deployment to 3/3 with no restarts.

Done when: kubectl -n ledger get deploy ledger -o jsonpath='{.status.availableReplicas}' prints 3, kubectl -n ledger get pods -o jsonpath='{.items[*].status.containerStatuses[*].restartCount}' prints all zeros for the new pods, and five minutes later kubectl -n ledger get pods -o jsonpath='{.items[*].status.containerStatuses[*].lastState.terminated.reason}' is empty — nothing has been OOMKilled since the change. (6 points)

Show the worked solution
# 1. evidence, in a fixed order. Start with why the crashing pod died LAST time.
kubectl -n ledger get pods -o wide
kubectl -n ledger get pod ledger-7d9c-abcde \
  -o jsonpath='{.status.containerStatuses[0].lastState.terminated.reason}{" exit "}{.status.containerStatuses[0].lastState.terminated.exitCode}{"\n"}'
#   OOMKilled exit 137   -> the kernel killed it; this is a limits problem, not a bug

kubectl -n ledger get pod ledger-7d9c-abcde \
  -o jsonpath='{.spec.containers[0].resources}{"\n"}'
kubectl top pods -n ledger

# then why the third pod cannot start at all
kubectl -n ledger describe pod ledger-7d9c-fghij | grep -A6 Events
#   0/3 nodes are available: 3 Insufficient memory  -> nothing to do with the app
kubectl describe nodes | grep -A6 'Allocated resources'
kubectl get events -n ledger --sort-by=.lastTimestamp | tail -20
# the chain: traffic up 30% -> working set exceeded the 256Mi limit -> container
# OOMKilled -> restart backoff -> CrashLoopBackOff. The 8Gi "fix" then made the
# replacement pod unschedulable, because no node has 8Gi free. Two symptoms,
# one cause, and the second symptom is self-inflicted.

spec:
  template:
    spec:
      containers:
        - name: ledger
          image: ghcr.io/acme/ledger:4.6.3
          resources:
            requests:
              cpu: 200m
              memory: 512Mi      # observed working set ~430Mi under the new load
            limits:
              memory: 768Mi      # headroom, and small enough to schedule
              # no cpu limit: CPU is compressible, throttling is worse than bursting
kubectl -n ledger apply -f ledger-deployment.yaml
kubectl -n ledger rollout status deploy/ledger

kubectl -n ledger get deploy ledger -o jsonpath='{.status.availableReplicas}{"\n"}'
kubectl -n ledger get pods -o jsonpath='{.items[*].status.containerStatuses[*].restartCount}{"\n"}'

# five minutes later: no container has died since the change
kubectl -n ledger get pods \
  -o jsonpath='{.items[*].status.containerStatuses[*].lastState.terminated.reason}{"\n"}'
# and no node-level OOM either — the kubelet reports those against the Node as SystemOOM
kubectl get events -A --field-selector reason=SystemOOM

Why: the useful skill here is refusing to treat three symptoms as three problems. lastState.terminated.reason is the single most valuable field in workload triage — it tells you why the previous container instance died, which is the only evidence a crash-looping pod leaves behind, and OOMKilled with exit code 137 points at the memory limit rather than the application. CrashLoopBackOff is never a root cause; it is the kubelet’s exponential backoff after repeated exits, so the real question is always “exited how?”. The Pending pod is a different kind of evidence: scheduling failures are reported in the pod’s events as a per-node tally (“Insufficient memory”), and they are about requests, never about actual usage — which is why an oversized request creates unschedulable pods on a cluster with plenty of free memory. Set requests from observed usage, set memory limits close above them because memory is incompressible, and be sparing with CPU limits because throttling a latency-sensitive service is usually worse than letting it burst. The full decision trees are in triaging workloads and the troubleshooting playbook.

Platform Architecture & Infrastructure — T14 to T16 (15 points)

☺ Like you’re 10: These three are about spreading your helpers out so losing one room does not lose them all, making sure the important ones only talk to each other properly, and finding out where the money is going.

Three self-contained tasks with good points-per-minute — do not leave them unattempted just because they sit late in the paper. Background: platform architecture, scaling & scheduling, Istio and FinOps.

T14 · Survive a zone loss and a node drain

Last month an availability-zone incident took out five of the six ingress pods, because the scheduler had packed them onto two nodes in the same zone. The fix someone applied — a PodDisruptionBudget with minAvailable: 6 on a 6-replica Deployment — has since made every node drain hang forever, so the cluster upgrade is stalled and the platform team drains nodes by deleting pods directly.

Your task:

  1. Spread the six ingress pods evenly across zones, hard, and across nodes, softly.
  2. Replace the PDB with one that protects availability and permits voluntary disruption.
  3. Prove both: show the pods land in at least two zones, and drain a node without the drain hanging.

Done when: kubectl get pdb ingress -n platform -o jsonpath='{.status.disruptionsAllowed}' prints a number greater than 0, counting zones across the ingress pods returns at least two distinct zones with a skew of no more than 1, and kubectl drain <node> --ignore-daemonsets --delete-emptydir-data --timeout=120s completes rather than timing out. (6 points)

Show the worked solution
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ingress
  namespace: platform
spec:
  replicas: 6
  selector:
    matchLabels:
      app: ingress
  template:
    metadata:
      labels:
        app: ingress
    spec:
      topologySpreadConstraints:
        - maxSkew: 1
          topologyKey: topology.kubernetes.io/zone
          whenUnsatisfiable: DoNotSchedule       # zones: enforce
          labelSelector:
            matchLabels:
              app: ingress
        - maxSkew: 1
          topologyKey: kubernetes.io/hostname
          whenUnsatisfiable: ScheduleAnyway      # nodes: prefer, never block
          labelSelector:
            matchLabels:
              app: ingress
      containers:
        - name: ingress
          image: ghcr.io/acme/ingress:1.9.2
          resources:
            requests: { cpu: 200m, memory: 256Mi }
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: ingress
  namespace: platform
spec:
  maxUnavailable: 1          # NOT minAvailable: 6 — that permits zero disruptions
  selector:
    matchLabels:
      app: ingress
  unhealthyPodEvictionPolicy: AlwaysAllow   # policy/v1, GA in newer clusters
kubectl apply -f ingress.yaml
kubectl -n platform rollout status deploy/ingress

# how many zones are the pods actually in?
for n in $(kubectl -n platform get pods -l app=ingress -o jsonpath='{.items[*].spec.nodeName}'); do
  kubectl get node "$n" -o jsonpath='{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}'
done | sort | uniq -c

kubectl -n platform get pdb ingress -o jsonpath='{.status.disruptionsAllowed}{"\n"}'
kubectl drain kind-worker2 --ignore-daemonsets --delete-emptydir-data --timeout=120s
kubectl uncordon kind-worker2

Why: these two objects defend against two different threats and the exam likes pairing them. Topology spread defends against involuntary loss — a zone, a rack, a node going away — by constraining where the scheduler may place pods. maxSkew: 1 with whenUnsatisfiable: DoNotSchedule on the zone key means a pod stays Pending rather than making the imbalance worse, which is right for zones; using the same setting on kubernetes.io/hostname is how people accidentally make their Deployment unschedulable on a small cluster, so the hostname constraint should almost always be ScheduleAnyway. PodDisruptionBudgets defend against voluntary disruption only — drains, node upgrades, the descheduler — and here the previous fix was self-defeating: minAvailable equal to the replica count (like maxUnavailable: 0) means the eviction API must always refuse, so kubectl drain retries until it times out and the node is never emptied. Express the budget as maxUnavailable: 1, then check status.disruptionsAllowed — that field is the honest answer to “can this cluster be maintained?”. unhealthyPodEvictionPolicy: AlwaysAllow handles the other classic deadlock, where pods are already broken and therefore never count as available, so nothing may be evicted to fix them. More in scaling & scheduling and reliability & incidents.

T15 · Make the payments namespace refuse anonymous callers

The payments namespace is meshed and mTLS is available, but PeerAuthentication is in PERMISSIVE mode, so a pod without a sidecar can still call payments-api in plaintext — and a test harness in default has been doing exactly that for months. Security wants proven identity on every call and a caller allowlist, not just encryption.

Your task:

  1. Set namespace-wide STRICT mTLS for payments.
  2. Add a deny-by-default AuthorizationPolicy for the namespace, then a single ALLOW rule permitting only the checkout service account to GET/POST paths under /v1/charges.
  3. Prove both directions: the allowed caller succeeds, an unauthorised meshed caller gets 403, and a plaintext caller cannot connect at all.

Done when: a curl from the checkout pod to payments-api.payments:8080/v1/charges returns 200, the same call from a pod running as a different service account returns 403, and a call from a pod with no sidecar fails to connect. (5 points)

Show the worked solution
apiVersion: security.istio.io/v1
kind: PeerAuthentication
metadata:
  name: default
  namespace: payments
spec:
  mtls:
    mode: STRICT              # no selector = the whole namespace
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: deny-all
  namespace: payments
spec: {}                      # no rules, default action ALLOW = nothing is allowed
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
  name: allow-checkout-to-charges
  namespace: payments
spec:
  selector:
    matchLabels:
      app: payments-api
  action: ALLOW
  rules:
    - from:
        - source:
            principals: ["cluster.local/ns/checkout/sa/checkout"]
      to:
        - operation:
            methods: ["GET", "POST"]
            paths: ["/v1/charges", "/v1/charges/*"]
kubectl apply -f payments-mtls.yaml
istioctl analyze -n payments

# allowed caller -> 200
kubectl -n checkout exec deploy/checkout -c checkout -- \
  curl -s -o /dev/null -w '%{http_code}\n' http://payments-api.payments:8080/v1/charges

# meshed but wrong identity -> 403 (RBAC: access denied)
kubectl -n analytics exec deploy/reporter -c reporter -- \
  curl -s -o /dev/null -w '%{http_code}\n' http://payments-api.payments:8080/v1/charges

# no sidecar, plaintext -> connection reset by the STRICT listener
kubectl -n default run probe --rm -it --restart=Never --image=curlimages/curl --command -- \
  curl -sv --max-time 5 http://payments-api.payments:8080/v1/charges

kubectl -n payments get peerauthentication,authorizationpolicy

Why: these are two layers and the task fails if you conflate them. PeerAuthentication is authentication — it decides whether a connection must present a workload certificate. STRICT rejects plaintext outright, which is what closes the unsidecarred test harness; PERMISSIVE, the default, accepts both and is only meant as a migration state. AuthorizationPolicy is authorization — who may do what, once identity is established. The deny-by-default idiom is worth memorising because it looks like a mistake: a policy with an empty spec: {} selects every workload in the namespace, has no rules to match, and its default action is ALLOW, so nothing matches and everything is denied. Order matters conceptually too: DENY policies are evaluated before ALLOW, and an ALLOW policy only ever adds permitted traffic. Finally, the layers depend on each other — principals is derived from the peer certificate, so under PERMISSIVE a plaintext caller simply has no principal and your allowlist silently never matches. Authenticate first, then authorize. See Istio and networking.

T16 · Find out where the money actually goes

The monthly cluster bill went up 40% and nobody can attribute it. Finance wants a per-team number by Friday, the platform team believes the increase is “the analytics people”, and the only evidence anyone has produced so far is a screenshot of a node count. OpenCost is installed and has been collecting for three weeks.

Your task:

  1. Query OpenCost’s allocation API for the last 7 days aggregated by namespace, accumulated into a single total per namespace.
  2. Produce the same figure aggregated by the team label, and report idle cost separately rather than hiding it.
  3. Name the top spender and state how much of the total is idle — the capacity you are paying for and not requesting.

Done when: curl -s "http://localhost:9003/allocation/compute?window=7d&aggregate=namespace&accumulate=true" returns a JSON body containing per-namespace totalCost values, and the same query with &includeIdle=true contains an __idle__ entry. (4 points)

Show the worked solution
kubectl -n opencost get deploy opencost
kubectl -n opencost port-forward svc/opencost 9003:9003 &

# 1. by namespace, one row per namespace for the whole window
curl -s "http://localhost:9003/allocation/compute?window=7d&aggregate=namespace&accumulate=true" \
  | tr '}' '}\n' | grep -E '"name"|totalCost' | head -40

# 2. by team label, with idle reported as its own line rather than shared out
curl -s "http://localhost:9003/allocation/compute?window=7d&aggregate=label:team&accumulate=true&includeIdle=true"

# 3. the same window shared instead, for a chargeback number that sums to the bill
curl -s "http://localhost:9003/allocation/compute?window=7d&aggregate=namespace&accumulate=true&shareIdle=true"
# label-based aggregation only works if the labels exist. This is the platform's job:
apiVersion: v1
kind: Namespace
metadata:
  name: analytics
  labels:
    team: data-platform
    platform.acme.io/cost-centre: "CC-4402"
# right-sizing evidence for the follow-up conversation: requested vs actually used
kubectl -n analytics get pods \
  -o custom-columns='POD:.metadata.name,CPU_REQ:.spec.containers[*].resources.requests.cpu,MEM_REQ:.spec.containers[*].resources.requests.memory'
kubectl top pods -n analytics

Why: cost on Kubernetes is an allocation problem, not a billing problem: the cloud invoice knows about nodes, and everything interesting — who, which team, which service — lives in labels the invoice has never heard of. OpenCost joins the two by pricing each workload’s requested resources over time and rolling that up by whatever key you ask for, which is why aggregate=label:team is only as good as your namespace labelling discipline. Two parameters carry the meaning. accumulate=true collapses the window into one figure per key instead of a per-day series — that is the number Finance wants. And idle cost, the gap between what the cluster costs and what anyone requested, is the one number platform teams must not hide: includeIdle=true reports it as its own __idle__ allocation so you can see it, while shareIdle=true spreads it proportionally across tenants so the chargeback adds up to the invoice. Report both. High idle means over-provisioned nodes or bad bin-packing and is the platform team’s problem; high allocated-but-unused means teams requesting far more than they use, which is a right-sizing conversation with evidence. See OpenCost and FinOps.

Security & Policy Enforcement — T17 to T18 (15 points)

☺ Like you’re 10: These two are about locking the doors without locking yourself out, and being able to answer “does the thing we are running right now contain the bad code everyone is talking about?”

Only two tasks, but 15 points — the highest value per task on the paper. Never leave these unattempted. Background: security & policy enforcement, networking, Trivy and governance & compliance.

T17 · The default-deny that broke three things

Someone applied a default-deny NetworkPolicy to payments at 16:50 on a Friday. Since then: DNS resolution fails inside the namespace, every Prometheus target in it is DOWN, and the payment gateway integration times out. Nobody wants to delete the policy — the pen-test finding it closes is real. Restore each broken path with the narrowest rule that works, and leave lateral pod-to-pod traffic denied.

Your task:

  1. Keep the default-deny for both ingress and egress, and add an egress rule for cluster DNS.
  2. Add an ingress rule allowing only the monitoring namespace to scrape the metrics port.
  3. Add an egress rule allowing only the payments-api pods to reach the gateway’s public CIDR on 443, and confirm an unrelated destination is still refused.

Done when: from a payments-api pod, DNS resolves; kubectl -n monitoring get servicemonitor targets for the namespace report up in Prometheus; a TCP connection to the gateway CIDR on 443 succeeds; a connection to any other external address times out; and a pod-to-pod call from another namespace still fails. (8 points)

Show the worked solution
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: payments
spec:
  podSelector: {}                 # every pod in the namespace
  policyTypes: [Ingress, Egress]  # both directions, no rules = deny both
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns-egress
  namespace: payments
spec:
  podSelector: {}
  policyTypes: [Egress]
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: kube-system
          podSelector:
            matchLabels:
              k8s-app: kube-dns       # AND, not OR: both selectors in ONE list item
      ports:
        - protocol: UDP
          port: 53
        - protocol: TCP
          port: 53                    # TCP 53 too — truncated answers and some resolvers
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-metrics-scrape
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes: [Ingress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
      ports:
        - protocol: TCP
          port: 9090
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-gateway-egress
  namespace: payments
spec:
  podSelector:
    matchLabels:
      app: payments-api
  policyTypes: [Egress]
  egress:
    - to:
        - ipBlock:
            cidr: 203.0.113.0/24
            except:
              - 203.0.113.7/32
      ports:
        - protocol: TCP
          port: 443
kubectl apply -f payments-netpol.yaml
kubectl -n payments get netpol

kubectl -n payments exec deploy/payments-api -- nslookup payments-db.data.svc.cluster.local
kubectl -n payments exec deploy/payments-api -- \
  timeout 5 wget -qO- --timeout=4 https://203.0.113.10/health ; echo "exit=$?"
kubectl -n payments exec deploy/payments-api -- \
  timeout 5 wget -qO- --timeout=4 https://example.com ; echo "exit=$? (expect non-zero)"

# lateral movement still denied
kubectl -n analytics run probe --rm -it --restart=Never --image=curlimages/curl --command -- \
  curl -s --max-time 4 http://payments-api.payments:8080/ ; echo "exit=$?"

Why: four things about NetworkPolicy explain nearly every outage caused by one. They are additive — there is no ordering and no deny rule; the union of every policy selecting a pod is what is permitted, so you restore broken paths by adding narrow policies, never by loosening the deny. Egress is where DNS dies: the moment Egress appears in policyTypes with no matching rule, name resolution stops, and because everything then fails with a confusing DNS error rather than a connection error, this is the single most common self-inflicted cluster incident. Allow UDP and TCP 53 to the kube-dns pods, using the automatic kubernetes.io/metadata.name namespace label rather than hoping someone labelled kube-system by hand. The selector trap: a namespaceSelector and a podSelector inside the same list item are ANDed (pods matching X in namespaces matching Y); put a dash in front of the second one and they become two separate OR’d peers, which quietly allows every pod in the namespace. And probes are not affected — kubelet health checks originate from the node, not from a pod, so a default-deny never breaks readiness probes, which is worth knowing before you spend ten minutes writing a policy for them. Compare the ingress-first version in the security bank and the CNI details in Cilium.

T18 · Answer “are we affected?” before the meeting

A critical CVE lands in a widely-used library at 09:00. Your CTO asks the only question that matters — are we running it? — and wants the answer, plus the fix, before an 11:00 call. The Trivy Operator is installed and scanning workloads. Guessing from image tags is not an acceptable answer, because three teams build from the same base image and two of them have rebuilt since.

Your task:

  1. From inside the cluster, list which running workloads currently have CRITICAL findings and identify the affected image.
  2. Generate a CycloneDX SBOM for that image and confirm the vulnerable package and version are present.
  3. Roll the workload to a rebuilt image and prove the critical count returns to zero.

Done when: kubectl get vulnerabilityreports -A -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,CRIT:.report.summary.criticalCount' shows the offending workload before the fix and 0 for every workload after, and trivy sbom sbom.cdx.json --severity CRITICAL --exit-code 1 exits non-zero on the old image’s SBOM and zero on the new one. (7 points)

Show the worked solution
# 1. what is running, and what is wrong with it — from the cluster, not from memory
kubectl get vulnerabilityreports -A \
  -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,CRIT:.report.summary.criticalCount,HIGH:.report.summary.highCount'
kubectl -n checkout get vulnerabilityreport replicaset-checkout-7d9c -o yaml | grep -B2 -A6 CRITICAL
kubectl -n checkout get vulnerabilityreport replicaset-checkout-7d9c \
  -o jsonpath='{.report.artifact.repository}:{.report.artifact.tag}{"\n"}'
# 2. the SBOM answers "which version of the package is actually in this image"
trivy image --format cyclonedx --output sbom.cdx.json ghcr.io/acme/checkout:4.6.3
grep -o '"name":"libxyz"[^}]*' sbom.cdx.json | head

# scan the SBOM itself — no registry pull, and it is the artifact you keep for audit
trivy sbom sbom.cdx.json --severity CRITICAL --exit-code 1 ; echo "exit=$?"

# the same gate in CI, and the flag that decides whether the gate is honest:
trivy image --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 \
  ghcr.io/acme/checkout:4.6.4
# 3. roll forward to the rebuilt image and let the operator re-scan
kubectl -n checkout set image deploy/checkout checkout=ghcr.io/acme/checkout:4.6.4
kubectl -n checkout rollout status deploy/checkout

kubectl get vulnerabilityreports -A \
  -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,CRIT:.report.summary.criticalCount'
kubectl -n checkout get vulnerabilityreport -o jsonpath='{.items[*].report.summary.criticalCount}{"\n"}'

Why: the question “are we affected?” is about what is running, and that is a different dataset from what CI scanned at build time — images get retagged, rebuilt, mirrored and pinned to digests, and a tag tells you almost nothing. The Trivy Operator solves it by scanning the workloads actually deployed and writing the results back as ordinary Kubernetes objects, which means the answer is a kubectl get with a jsonpath rather than a spreadsheet: vulnerabilityreports are namespaced, owned by the ReplicaSet or Pod they describe, and garbage-collected with it, so the inventory stays current for free. The SBOM is the second half of the answer and the part that survives the incident: it is an inventory of what is in the image, so you can re-ask “does this contain package X at version Y?” next month without re-pulling anything, and trivy sbom scans it offline. Two flags decide whether your build-time gate is honest — --exit-code 1 is what actually fails the pipeline (without it Trivy prints findings and exits zero, which is how teams end up with a green pipeline and a vulnerable image), and --ignore-unfixed keeps the gate actionable by hiding findings with no available patch, at the cost of hiding real risk, so make that choice deliberately rather than by copying someone’s pipeline. Pair this with signing and provenance from Sigstore for the other half of supply-chain security.

⚠ The two habits that cost the most in this domain

First, applying an enforcing control without measuring first. T17 exists precisely because someone skipped that step: a default-deny is correct and it took out DNS, metrics and a payment integration, and thirty seconds of --dry-run=server, an audit-mode pass or a staging namespace would have found all three. Second, testing a control as yourself. You are almost certainly cluster-admin on your practice cluster, and admin bypasses more than you expect — verify from the identity that will actually be affected (--as for RBAC, a real pod in a real namespace for network and mesh policy). A policy you only tested from your own terminal is a guess with extra steps.

Score yourself

☺ Like you’re 10: Add up your points, turn it into a percentage, and then — much more usefully — look at which questions you lost, 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. There is no credit for “I knew how to do that one”.

TaskDomainPointsYour score
T1 · ApplicationSet cluster generator fan-outGitOps5
T2 · Flux dependsOn and real health checksGitOps5
T3 · Abort a canary, then roll back to stableGitOps6
T4 · AppProject fence for a tenantGitOps4
T5 · Tekton when plus a finally cleanupGitOps5
T6 · Printer columns and the status subresourcePlatform APIs5
T7 · Cross-namespace owner reference and the GCPlatform APIs5
T8 · Crossplane provider health and credentialsPlatform APIs5
T9 · Idempotent namespace-provisioning workflowPlatform APIs6
T10 · FlowSchema and priority level for a controllerPlatform APIs4
T11 · Error budget and multi-window burn-rate alertsObservability7
T12 · Trace context propagation across three servicesObservability7
T13 · OOMKilled → CrashLoop → Pending, one causeObservability6
T14 · Topology spread plus a PDB that permits drainsArchitecture6
T15 · STRICT mTLS and an AuthorizationPolicy allowlistArchitecture5
T16 · OpenCost allocation and idle costArchitecture4
T17 · Restore DNS, scraping and egress under default-denySecurity8
T18 · Vulnerability inventory, SBOM and a real gateSecurity7
TotalAll five domains100

Computing your result. The points total 100, so your raw score is your percentage, and the bar is 64%. That leaves 36 points spendable — you can drop six of the five-point tasks outright and still clear the bar with six to spare. Then do the more useful arithmetic and total each domain separately. A 70 built from five even domains and a 70 built from 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 & golden paths, then the Platform APIs bank.
Observability & Operations20Observability, reliability & incidents, then the Observability bank.
Platform Architecture & Infrastructure15Platform architecture, scaling & scheduling, FinOps, then the Architecture bank.
Security & Policy Enforcement15Security & policy, governance & compliance, 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: work the troubleshooting playbook, triaging workloads and triaging delivery until the evidence order is automatic. Set 5 is deliberately heavy on that third pile — six of its eighteen tasks hand you something already broken — so a low score concentrated there is a specific, fixable diagnosis rather than a general one.

What your score means

☺ Like you’re 10: One score 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. A mock is harsher in some ways — no Quick Reference box tailored to the task, tasks written by someone who cannot see your cluster — and gentler in others: your own cluster, your own aliases, no exam-day nerves. Read the band, not the decimal.

ScoreRead it asNext move
80–100Comfortably ready, with margin for a bad task draw.Stop grinding tasks. Work the final checklist, skim the field notes for exam-day logistics, and book it.
64–79A pass on this paper — but with no margin if two tasks go sideways.Re-drill only your weakest domain, then sit another timed paper. Speed work, not new topics.
50–63Close. Usually a speed problem plus one weak domain, not a knowledge problem.Two weeks of timed drills from the bank, plus what to know cold for every shape you had to look up.
Under 50Genuine gaps across domains. Booking now wastes the sitting.Back to the lessons for your two lowest domains, then the lab track to rebuild hands-on fluency before another mock.

One pattern worth checking independently of the total: how many tasks did you leave completely unattempted? More than two and your problem is pacing, not knowledge — and pacing is the cheapest thing on this list to fix. Sit the paper again in a fortnight with a hard rule that you touch every task once before returning to any of them.

🎬 At the Platform Guild
🦆

Dot: 66. I passed! Barely, but I passed.

🐢

Timmy: Where did the 34 go?

🦆

Dot: Almost all of it in the broken ones. T7, T8, T13, T17 — every task that started with something already failing. I built things fine. I just could not find things.

🦫

Benny: That is a much better result than a flat 66, Dot. You do not have a knowledge gap spread across five domains, you have one missing habit: reading evidence in a fixed order instead of forming a theory in the first ten seconds.

👺

Gizmo: Easy fix — memorise the answers to these eighteen. Next time you will get 100! 🤑

🦉

Professor Owl: And learn nothing at all, since the exam will break something different. Dot, do the opposite: take four working namespaces, have someone else break one thing in each, and practise only the finding. Three commands before any theory — what does it say it is, what did it last do, what does the API say about it.

🐿️

Nutty: I broke four namespaces before breakfast! Some of them on purpose!

🐢

Timmy: …that is genuinely useful, Nutty. Write down what you changed, though. A puzzle with no answer key is just an outage.

🐢 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. In an Argo CD AppProject, what is the difference between omitting clusterResourceWhitelist and setting it to []? 3. After kubectl argo rollouts abort, why does the Rollout stay Degraded, and what clears it? 4. Your operator’s ConfigMaps are deleted seconds after creation with no delete in the logs — what is the single most likely cause, and which event names it? 5. For a 99.9% / 30-day SLO, how big is the error budget in minutes, and how long does a 14.4× burn take to exhaust it? 6. A default-deny NetworkPolicy is applied and everything in the namespace breaks with DNS errors — what exactly must you allow, and to where? 7. A PDB with minAvailable equal to the replica count: what does it protect, and what does it break?

Check your answers
  1. kubernetes.io/docs, kubernetes.io/blog, task-specific documentation linked from the exam’s Quick Reference box, and the local man / /usr/share docs 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. Omitting the field denies nothing — cluster-scoped resources are unrestricted. Setting it to an empty list [] denies every cluster-scoped kind, which is what stops a tenant repo creating a ClusterRoleBinding.
  3. Abort is a runtime action: it shifts traffic back to the stable ReplicaSet but deliberately does not modify your desired state, and the spec still requests the bad version — so the Rollout correctly reports Degraded. It is cleared by changing the spec back: kubectl argo rollouts undo, a set image to the previous version, or (better) a revert commit that your GitOps controller syncs.
  4. An owner reference that crosses namespaces — a namespaced dependent whose owner lives in a different namespace (or a cluster-scoped dependent owned by a namespaced object). The garbage collector cannot resolve it, deletes the dependent, and emits the OwnerRefInvalidNamespace event. A stale uid from a recreated owner produces identical symptoms.
  5. 0.1% of 43,200 minutes = 43.2 minutes over the 30 days. A burn rate of 14.4× exhausts it in 30 ÷ 14.4 days ≈ 50 hours, consuming about 2% of the budget in the first hour — which is why 14.4 is the canonical page threshold.
  6. Egress to the cluster DNS pods on both UDP and TCP port 53 — selected by a namespaceSelector on kubernetes.io/metadata.name: kube-system combined (in the same peer entry) with a podSelector on k8s-app: kube-dns. Adding Egress to policyTypes with no rules blocks DNS, and every subsequent failure then looks like a name-resolution bug.
  7. It protects against voluntary disruption absolutely — and therefore breaks every node drain, because the eviction API must always refuse, so kubectl drain retries until it times out and cluster upgrades stall. Express it as maxUnavailable: 1 and check status.disruptionsAllowed is greater than zero.

That is the whole paper. Score it, write the domain breakdown somewhere you will see it, and let your two weakest numbers choose the next fortnight. If the broken-cluster tasks are where your points went, that is the most fixable diagnosis on this site — go and live in the troubleshooting playbook for a week. When the numbers 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 and the glossary are worth a last skim on the morning. Compare your shape against Set 1, Set 2, Set 3 and Set 4, go back to the drill bank when you are ready to go again — or the course home to pick a lesson.

⏱ The six papers

Set 1 · Set 2 · Set 3 · Set 4 · Set 5 (you are here) · Set 6. Next up: Set 6. 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.